From f8ecbbd79f4f176f8b11873bade4756fdfe6bcca Mon Sep 17 00:00:00 2001 From: Gene Hoffman Date: Thu, 13 Aug 2026 13:51:28 -0700 Subject: [PATCH 1/7] Collapse duplicated operator models found in the quality audit. Give posted-maker shape, funding selection, ladder/dust jobs, and vault hints a single owner so CLI and Coinset stop re-deriving the same answers. --- .../src/adapters/dexie/client.rs | 7 +- .../src/adapters/dexie/errors.rs | 34 ++ greenfloor-engine/src/adapters/dexie/mod.rs | 2 + greenfloor-engine/src/adapters/http_client.rs | 13 + greenfloor-engine/src/adapters/mod.rs | 6 +- greenfloor-engine/src/adapters/splash/mod.rs | 7 +- greenfloor-engine/src/cli_util.rs | 8 +- greenfloor-engine/src/coin_ops/amounts.rs | 12 + .../jobs.rs => coin_ops/dust_jobs.rs} | 14 +- .../src/coin_ops/execution/cap.rs | 9 +- .../src/coin_ops/execution/context.rs | 4 +- .../src/coin_ops/execution/managed/combine.rs | 15 +- .../src/coin_ops/execution/managed/tests.rs | 2 +- .../input_selection/combine_inputs.rs | 16 +- .../src/{manager_cli => coin_ops}/ladder.rs | 18 +- greenfloor-engine/src/coin_ops/mod.rs | 17 +- greenfloor-engine/src/coin_ops/selection.rs | 141 ++++++- .../src/coin_ops/shape/deficit.rs | 13 +- .../src/coin_ops/shape/funding.rs | 16 +- .../src/coin_ops/shape_protection.rs | 4 +- .../src/coin_ops/vault_controlled.rs | 121 ++++++ greenfloor-engine/src/coinset/api/rpc.rs | 2 +- greenfloor-engine/src/coinset/asset.rs | 30 -- greenfloor-engine/src/coinset/cats/resolve.rs | 2 +- .../src/coinset/coin_select/mod.rs | 160 ++++---- .../src/coinset/coin_select/tests.rs | 83 ++-- greenfloor-engine/src/coinset/mod.rs | 4 +- .../src/coinset/pagination/cursor.rs | 2 +- .../src/coinset/pagination/mod.rs | 2 +- .../src/coinset/pagination/tests.rs | 4 +- greenfloor-engine/src/coinset/retry.rs | 8 +- greenfloor-engine/src/coinset/rpc_result.rs | 2 +- .../src/coinset_cli/tests/dispatch_tests.rs | 7 +- .../src/config/cat_ticker_index.rs | 7 + greenfloor-engine/src/config/markets/parse.rs | 30 +- greenfloor-engine/src/config/mod.rs | 3 +- greenfloor-engine/src/config/program.rs | 2 + .../src/config/program_parse/mod.rs | 1 + .../src/config/program_parse/sections.rs | 14 +- greenfloor-engine/src/cycle/market.rs | 7 +- greenfloor-engine/src/cycle/mod.rs | 11 +- greenfloor-engine/src/cycle/orchestration.rs | 11 +- .../reconcile/cancel_submitted_policy/mod.rs | 5 +- greenfloor-engine/src/cycle/reconcile/mod.rs | 2 +- .../src/cycle/reconcile/state.rs | 21 + .../src/daemon/coinset_spendable.rs | 6 +- greenfloor-engine/src/daemon/cycle_entry.rs | 2 +- .../src/daemon/inventory_phase.rs | 26 +- greenfloor-engine/src/daemon/loop_harness.rs | 5 + greenfloor-engine/src/daemon/market_cycle.rs | 24 +- .../src/daemon/offer_dispatch/managed_post.rs | 6 +- .../src/daemon/offer_dispatch/parallel.rs | 14 +- .../offer_dispatch/tests/classify_tests.rs | 6 +- .../src/daemon/run_once/summary.rs | 9 +- .../src/daemon/run_once/tests.rs | 26 +- .../src/daemon/strategy_phase.rs | 1 + greenfloor-engine/src/daemon/watchlist/mod.rs | 5 +- greenfloor-engine/src/error/mod.rs | 81 ++-- greenfloor-engine/src/error/transport.rs | 15 + greenfloor-engine/src/hex/mod.rs | 28 +- greenfloor-engine/src/manager_cli/cats.rs | 3 +- .../src/manager_cli/cats_catalog.rs | 8 +- .../src/manager_cli/coin_op_loop/combine.rs | 2 +- .../manager_cli/coin_op_loop/loop_context.rs | 2 +- .../src/manager_cli/coin_op_loop/split.rs | 2 +- .../src/manager_cli/coins_balance.rs | 134 ++----- .../combine_test_support/mod.rs | 2 +- .../combine_market_cat_dust/mod.rs | 3 +- .../combine_market_cat_dust/report.rs | 2 +- .../commands/run/build_offer/mod.rs | 56 ++- greenfloor-engine/src/manager_cli/mod.rs | 1 - greenfloor-engine/src/manager_cli/offers.rs | 2 +- .../src/manager_cli/setup/doctor.rs | 20 +- .../src/manager_cli/setup/validate.rs | 12 +- greenfloor-engine/src/manager_cli/tests.rs | 4 + .../src/manager_cli/vault_asset_trace.rs | 231 +---------- greenfloor-engine/src/offer/action.rs | 4 +- greenfloor-engine/src/offer/assets.rs | 30 ++ greenfloor-engine/src/offer/build.rs | 1 - greenfloor-engine/src/offer/cancel_input.rs | 6 +- greenfloor-engine/src/offer/codec.rs | 22 +- greenfloor-engine/src/offer/invariants.rs | 8 +- .../offer/lifecycle/cancel/broadcast/tests.rs | 2 +- .../src/offer/lifecycle/cancel/build.rs | 14 +- .../src/offer/lifecycle/cancel_context.rs | 8 +- .../src/offer/lifecycle/cancel_eligibility.rs | 4 +- .../lifecycle/market_reconcile/augment.rs | 15 +- .../offer/lifecycle/market_reconcile/cycle.rs | 19 +- .../lifecycle/market_reconcile/transition.rs | 16 +- greenfloor-engine/src/offer/lifecycle/mod.rs | 6 +- .../src/offer/lifecycle/persist.rs | 8 +- .../lifecycle/reconcile_prep/dexie_fetch.rs | 51 ++- .../lifecycle/reconcile_prep/fetch_apply.rs | 61 --- .../src/offer/lifecycle/reconcile_prep/mod.rs | 9 +- .../lifecycle/reconcile_prep/watch_plan.rs | 84 ++-- .../lifecycle/reconcile_watched_offers.rs | 22 +- .../src/offer/lifecycle/signal_apply.rs | 358 ++++++++++-------- .../src/offer/lifecycle/transition.rs | 9 +- .../src/offer/lifecycle/ws_apply/mod.rs | 21 +- greenfloor-engine/src/offer/maker_shape.rs | 96 +++++ greenfloor-engine/src/offer/mod.rs | 4 +- .../offer/operator/build_and_post/create.rs | 2 +- .../operator/build_and_post/iteration.rs | 6 +- .../src/offer/operator/build_and_post/mod.rs | 84 ++-- .../offer/operator/build_and_post/publish.rs | 49 +-- .../offer/operator/build_and_post/tests.rs | 76 +++- .../offer/operator/build_and_post/types.rs | 2 +- .../src/offer/operator/ensure_size/mod.rs | 65 +--- greenfloor-engine/src/offer/operator/mod.rs | 11 +- .../signer_denomination/bootstrap_execute.rs | 1 + .../offer/operator/signer_denomination/mod.rs | 5 +- .../operator/signer_denomination/wait.rs | 3 +- greenfloor-engine/src/offer/plan.rs | 10 +- greenfloor-engine/src/offer/presplit/build.rs | 2 +- .../src/offer/presplit/orphan_discover.rs | 21 +- greenfloor-engine/src/offer/presplit/split.rs | 2 +- .../src/offer/publish/dexie/mod.rs | 55 ++- .../src/offer/publish/dexie/visibility.rs | 6 +- greenfloor-engine/src/offer/reclaim.rs | 2 +- greenfloor-engine/src/offer/types.rs | 118 +++--- greenfloor-engine/src/storage/sqlite/mod.rs | 13 + .../src/storage/sqlite/offer_cancel.rs | 2 +- .../storage/sqlite/offer_coin_watches/mod.rs | 4 +- .../src/storage/sqlite/offers.rs | 16 +- .../src/test_support/bootstrap_shape.rs | 1 + greenfloor-engine/src/vault/materialize.rs | 6 +- greenfloor-engine/src/vault/messages.rs | 8 +- .../src/vault_coinset_scan/asset_trace.rs | 22 +- .../src/vault_coinset_scan/hints.rs | 123 ++++++ .../src/vault_coinset_scan/mod.rs | 2 + .../src/vault_coinset_scan/request.rs | 90 +++++ greenfloor-engine/tests/cat_parse_replay.rs | 8 +- greenfloor-engine/tests/sqlite/offer_state.rs | 2 +- 133 files changed, 1987 insertions(+), 1345 deletions(-) create mode 100644 greenfloor-engine/src/adapters/dexie/errors.rs create mode 100644 greenfloor-engine/src/adapters/http_client.rs rename greenfloor-engine/src/{manager_cli/combine_market_cat_dust/jobs.rs => coin_ops/dust_jobs.rs} (93%) rename greenfloor-engine/src/{manager_cli => coin_ops}/ladder.rs (87%) create mode 100644 greenfloor-engine/src/coin_ops/vault_controlled.rs delete mode 100644 greenfloor-engine/src/coinset/asset.rs create mode 100644 greenfloor-engine/src/error/transport.rs delete mode 100644 greenfloor-engine/src/offer/lifecycle/reconcile_prep/fetch_apply.rs create mode 100644 greenfloor-engine/src/offer/maker_shape.rs create mode 100644 greenfloor-engine/src/vault_coinset_scan/hints.rs diff --git a/greenfloor-engine/src/adapters/dexie/client.rs b/greenfloor-engine/src/adapters/dexie/client.rs index 27cdbe14..487d5568 100644 --- a/greenfloor-engine/src/adapters/dexie/client.rs +++ b/greenfloor-engine/src/adapters/dexie/client.rs @@ -18,9 +18,14 @@ pub struct DexieClient { impl DexieClient { pub fn new(base_url: impl Into) -> Self { + Self::with_http(base_url, crate::adapters::shared_http_client()) + } + + #[must_use] + pub fn with_http(base_url: impl Into, http: reqwest::Client) -> Self { Self { base_url: base_url.into().trim_end_matches('/').to_string(), - http: reqwest::Client::new(), + http, } } diff --git a/greenfloor-engine/src/adapters/dexie/errors.rs b/greenfloor-engine/src/adapters/dexie/errors.rs new file mode 100644 index 00000000..a8259975 --- /dev/null +++ b/greenfloor-engine/src/adapters/dexie/errors.rs @@ -0,0 +1,34 @@ +//! Dexie error-text classification shared by lifecycle, publish, and stale sweep. + +/// Whether Dexie error text means the offer is missing (404 / not found). +#[must_use] +pub fn is_dexie_offer_missing_error_text(error_text: &str) -> bool { + let normalized = error_text.trim().to_ascii_lowercase(); + if normalized.is_empty() { + return false; + } + (normalized.contains("dexie_get_offer_error") && normalized.contains("404")) + || normalized.contains("dexie_http_error:404") + || (normalized.contains("http error 404") && normalized.contains("not found")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_dexie_404_shapes() { + assert!(is_dexie_offer_missing_error_text( + "dexie_http_error:404 not found" + )); + assert!(is_dexie_offer_missing_error_text( + "dexie_get_offer_error:404 missing" + )); + assert!(is_dexie_offer_missing_error_text( + "HTTP error 404: Not Found" + )); + assert!(!is_dexie_offer_missing_error_text( + "dexie_offer_offered_asset_missing:cat" + )); + } +} diff --git a/greenfloor-engine/src/adapters/dexie/mod.rs b/greenfloor-engine/src/adapters/dexie/mod.rs index a7c4342c..e04d734d 100644 --- a/greenfloor-engine/src/adapters/dexie/mod.rs +++ b/greenfloor-engine/src/adapters/dexie/mod.rs @@ -1,8 +1,10 @@ mod client; +mod errors; mod response; mod tokens; mod view_url; pub use client::DexieClient; +pub use errors::is_dexie_offer_missing_error_text; pub use response::DexieResponse; pub use view_url::dexie_offer_view_url; diff --git a/greenfloor-engine/src/adapters/http_client.rs b/greenfloor-engine/src/adapters/http_client.rs new file mode 100644 index 00000000..b7e96a55 --- /dev/null +++ b/greenfloor-engine/src/adapters/http_client.rs @@ -0,0 +1,13 @@ +//! Shared `reqwest::Client` for Dexie/Splash so daemon cycles reuse connections. + +use std::sync::LazyLock; + +use reqwest::Client; + +static SHARED_HTTP: LazyLock = LazyLock::new(Client::new); + +/// Process-wide HTTP client for venue adapters. +#[must_use] +pub fn shared_http_client() -> Client { + SHARED_HTTP.clone() +} diff --git a/greenfloor-engine/src/adapters/mod.rs b/greenfloor-engine/src/adapters/mod.rs index a3e541b7..bfc0948d 100644 --- a/greenfloor-engine/src/adapters/mod.rs +++ b/greenfloor-engine/src/adapters/mod.rs @@ -1,6 +1,10 @@ mod dexie; +mod http_client; mod http_json; mod splash; -pub use dexie::{dexie_offer_view_url, DexieClient, DexieResponse}; +pub use dexie::{ + dexie_offer_view_url, is_dexie_offer_missing_error_text, DexieClient, DexieResponse, +}; +pub use http_client::shared_http_client; pub use splash::{SplashClient, SplashResponse}; diff --git a/greenfloor-engine/src/adapters/splash/mod.rs b/greenfloor-engine/src/adapters/splash/mod.rs index 0acbf28d..fb19d380 100644 --- a/greenfloor-engine/src/adapters/splash/mod.rs +++ b/greenfloor-engine/src/adapters/splash/mod.rs @@ -21,9 +21,14 @@ pub struct SplashClient { impl SplashClient { pub fn new(base_url: impl Into) -> Self { + Self::with_http(base_url, crate::adapters::shared_http_client()) + } + + #[must_use] + pub fn with_http(base_url: impl Into, http: reqwest::Client) -> Self { Self { base_url: base_url.into().trim_end_matches('/').to_string(), - http: reqwest::Client::new(), + http, } } diff --git a/greenfloor-engine/src/cli_util.rs b/greenfloor-engine/src/cli_util.rs index c31b124c..5b85c379 100644 --- a/greenfloor-engine/src/cli_util.rs +++ b/greenfloor-engine/src/cli_util.rs @@ -3,7 +3,7 @@ use serde::Serialize; use serde_json::{json, Value}; -use crate::error::{SignerError, SignerResult}; +use crate::error::{SignerError, SignerResult, TransportError}; const RETRYABLE_COINSET_TRANSPORT_MARKERS: &[&str] = &[ "operation timed out", @@ -38,7 +38,9 @@ pub fn script_coinset_transport_retryable(message: &str) -> bool { #[must_use] pub fn script_engine_error_retryable(err: &SignerError) -> bool { match err { - SignerError::Coinset(message) => script_coinset_transport_retryable(message), + SignerError::Transport(TransportError::Coinset(message)) => { + script_coinset_transport_retryable(message) + } _ => false, } } @@ -164,7 +166,7 @@ mod tests { #[test] fn script_engine_error_retryable_classifies_coinset_and_parse_errors() { - assert!(script_engine_error_retryable(&SignerError::Coinset( + assert!(script_engine_error_retryable(&SignerError::coinset( "error decoding response body".to_string() ))); assert!(!script_engine_error_retryable(&SignerError::Other( diff --git a/greenfloor-engine/src/coin_ops/amounts.rs b/greenfloor-engine/src/coin_ops/amounts.rs index d0b1977e..b073f00a 100644 --- a/greenfloor-engine/src/coin_ops/amounts.rs +++ b/greenfloor-engine/src/coin_ops/amounts.rs @@ -44,6 +44,12 @@ pub fn total_for_coin_ids(spendable: &[SpendableCoin], coin_ids: &[String]) -> i .sum() } +/// Receive inventory plus known unreturned maker amount (saturating). +#[must_use] +pub fn vault_controlled_total(receive_amount: u64, unreturned_amount: u64) -> u64 { + receive_amount.saturating_add(unreturned_amount) +} + #[cfg(test)] mod tests { use super::*; @@ -82,4 +88,10 @@ mod tests { 12 ); } + + #[test] + fn vault_controlled_sums_receive_and_unreturned() { + assert_eq!(vault_controlled_total(1_000, 2_000), 3_000); + assert_eq!(vault_controlled_total(u64::MAX, 1), u64::MAX); + } } diff --git a/greenfloor-engine/src/manager_cli/combine_market_cat_dust/jobs.rs b/greenfloor-engine/src/coin_ops/dust_jobs.rs similarity index 93% rename from greenfloor-engine/src/manager_cli/combine_market_cat_dust/jobs.rs rename to greenfloor-engine/src/coin_ops/dust_jobs.rs index d795676f..867e342d 100644 --- a/greenfloor-engine/src/manager_cli/combine_market_cat_dust/jobs.rs +++ b/greenfloor-engine/src/coin_ops/dust_jobs.rs @@ -1,12 +1,14 @@ +//! Group enabled markets into CAT dust-combine jobs (shared by manager CLI). + use std::collections::{BTreeMap, HashSet}; use serde_json::Value; -use crate::config::MarketsConfig; +use crate::config::{load_cats_catalog, resolve_asset_id_from_catalog, MarketsConfig}; use crate::error::{SignerError, SignerResult}; use crate::hex::{is_hex_id, normalize_hex_id}; -use crate::manager_cli::cats_catalog::{load_cats_catalog, resolve_asset_id_from_catalog}; +/// One CAT dust-combine job: one signer + asset, possibly spanning several markets. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CatDustJob { pub cat_asset_id: String, @@ -15,6 +17,8 @@ pub struct CatDustJob { pub market_ids: Vec, } +/// Resolve a market base label to a CAT asset id (hex id, or catalog ticker/symbol). +#[must_use] pub fn resolve_market_base_cat_asset_id( base_asset: &str, base_symbol: &str, @@ -28,6 +32,12 @@ pub fn resolve_market_base_cat_asset_id( .or_else(|| resolve_asset_id_from_catalog(catalog, base_symbol)) } +/// Group enabled markets into CAT dust-combine jobs (same signer + asset share a job). +/// +/// # Errors +/// +/// Returns an error if the cats catalog cannot be loaded, or if markets that share a +/// signer and CAT disagree on `receive_address`. pub fn build_enabled_cat_jobs( markets: &MarketsConfig, cats_config_path: &std::path::Path, diff --git a/greenfloor-engine/src/coin_ops/execution/cap.rs b/greenfloor-engine/src/coin_ops/execution/cap.rs index 2134571e..3356df2d 100644 --- a/greenfloor-engine/src/coin_ops/execution/cap.rs +++ b/greenfloor-engine/src/coin_ops/execution/cap.rs @@ -1,8 +1,5 @@ -/// Resolve combine input cap once at `CoinOpExecContext` construction. +/// Resolve combine input cap from program config (minimum 2, default 5). #[must_use] -pub fn resolve_combine_input_cap() -> i64 { - std::env::var("GREENFLOOR_COIN_OPS_COMBINE_INPUT_COIN_CAP") - .ok() - .and_then(|raw| raw.trim().parse::().ok()) - .map_or(5, |value| value.max(2)) +pub fn resolve_combine_input_cap(configured: i64) -> i64 { + configured.max(2) } diff --git a/greenfloor-engine/src/coin_ops/execution/context.rs b/greenfloor-engine/src/coin_ops/execution/context.rs index ebb30c21..2019e97c 100644 --- a/greenfloor-engine/src/coin_ops/execution/context.rs +++ b/greenfloor-engine/src/coin_ops/execution/context.rs @@ -51,7 +51,9 @@ impl CoinOpExecContext { base_unit_mojo_multiplier: default_mojo_multiplier_for_asset( gated.market_row.base_asset.trim(), ), - combine_input_cap: resolve_combine_input_cap(), + combine_input_cap: resolve_combine_input_cap( + gated.program.coin_ops_combine_input_coin_cap, + ), gated, resolved_base_asset_id, watched_coin_ids, diff --git a/greenfloor-engine/src/coin_ops/execution/managed/combine.rs b/greenfloor-engine/src/coin_ops/execution/managed/combine.rs index e698677e..b7cf29f6 100644 --- a/greenfloor-engine/src/coin_ops/execution/managed/combine.rs +++ b/greenfloor-engine/src/coin_ops/execution/managed/combine.rs @@ -1,3 +1,4 @@ +use crate::coin_ops::shape::{plan_combine_inputs_for_target, ShapeCoin}; use crate::coin_ops::{i64_to_usize, plan_exact_amount_combine_inputs, CoinOpPlan, SpendableCoin}; use super::items::{ @@ -42,13 +43,25 @@ async fn prepare_managed_combine_inputs( i64_to_usize(capped_number_of_coins, "combine.capped_op_count"), )?; - let combine_input_coin_ids = plan_exact_amount_combine_inputs( + let mut combine_input_coin_ids = plan_exact_amount_combine_inputs( &spendable, requested_count, target_coin_amount_mojos, None, Some(capped_count), ); + if combine_input_coin_ids.len() < 2 { + let shape_coins: Vec = spendable + .iter() + .map(|coin| ShapeCoin::new(coin.id.clone(), coin.amount)) + .collect(); + let cover = target_coin_amount_mojos.saturating_mul(2); + if let Some(planned) = + plan_combine_inputs_for_target(&shape_coins, cover, capped_number_of_coins) + { + combine_input_coin_ids = planned.input_coin_ids; + } + } if combine_input_coin_ids.len() < 2 { return Err(plan_skip(plan, "no_spendable_combine_coin_available")); } diff --git a/greenfloor-engine/src/coin_ops/execution/managed/tests.rs b/greenfloor-engine/src/coin_ops/execution/managed/tests.rs index 624a70a6..38295740 100644 --- a/greenfloor-engine/src/coin_ops/execution/managed/tests.rs +++ b/greenfloor-engine/src/coin_ops/execution/managed/tests.rs @@ -66,7 +66,7 @@ fn test_exec_context( ), resolved_base_asset_id: "xch".to_string(), base_unit_mojo_multiplier: 1_000, - combine_input_cap: resolve_combine_input_cap(), + combine_input_cap: resolve_combine_input_cap(5), watched_coin_ids: HashSet::new(), test_overrides: CoinOpTestOverrides::new( Some(spendable), diff --git a/greenfloor-engine/src/coin_ops/input_selection/combine_inputs.rs b/greenfloor-engine/src/coin_ops/input_selection/combine_inputs.rs index 0156d2f8..1b46fd3c 100644 --- a/greenfloor-engine/src/coin_ops/input_selection/combine_inputs.rs +++ b/greenfloor-engine/src/coin_ops/input_selection/combine_inputs.rs @@ -1,6 +1,6 @@ use std::collections::HashSet; -use crate::coin_ops::selection::{select_exact_amount_coin_ids, SpendableCoin}; +use crate::coin_ops::selection::{select_funding_coin_ids, FundingSelectionMode, SpendableCoin}; fn normalized_exclude_ids(exclude_coin_ids: Option<&HashSet>) -> HashSet { exclude_coin_ids @@ -21,10 +21,12 @@ pub fn plan_exact_amount_combine_inputs( exclude_coin_ids: Option<&HashSet>, max_count: Option, ) -> Vec { - select_exact_amount_coin_ids( + let excluded = normalized_exclude_ids(exclude_coin_ids); + select_funding_coin_ids( + FundingSelectionMode::ExactDenom, spendable_coins, amount_mojos, - &normalized_exclude_ids(exclude_coin_ids), + Some(&excluded), Some(capped_count(number_of_coins, max_count)), ) } @@ -38,14 +40,18 @@ pub fn plan_largest_combine_inputs( max_count: Option, ) -> Vec { let excluded = normalized_exclude_ids(exclude_coin_ids); + let cap = capped_count(number_of_coins, max_count); let mut eligible: Vec<&SpendableCoin> = spendable_coins .iter() - .filter(|coin| !coin.id.is_empty() && !excluded.contains(&coin.id.to_ascii_lowercase())) + .filter(|coin| { + !coin.id.is_empty() + && !crate::coin_ops::selection::coin_id_is_excluded(&coin.id, &excluded) + }) .collect(); eligible.sort_by_key(|coin| std::cmp::Reverse(coin.amount)); eligible .iter() - .take(capped_count(number_of_coins, max_count)) + .take(cap) .map(|coin| coin.id.clone()) .collect() } diff --git a/greenfloor-engine/src/manager_cli/ladder.rs b/greenfloor-engine/src/coin_ops/ladder.rs similarity index 87% rename from greenfloor-engine/src/manager_cli/ladder.rs rename to greenfloor-engine/src/coin_ops/ladder.rs index fc6ec747..8758370c 100644 --- a/greenfloor-engine/src/manager_cli/ladder.rs +++ b/greenfloor-engine/src/coin_ops/ladder.rs @@ -1,8 +1,13 @@ -//! Sell-ladder resolution for manager coin-op commands. +//! Sell-ladder resolution shared by CLI coin-ops and daemon planning. use crate::config::{LadderEntry, MarketConfig}; use crate::error::{SignerError, SignerResult}; +/// Look up the sell-ladder row for `size_base_units`. +/// +/// # Errors +/// +/// Returns an error when the market has no sell ladder or no row for `size_base_units`. pub fn sell_ladder_entry_for_size( market: &MarketConfig, size_base_units: i64, @@ -28,6 +33,11 @@ pub fn sell_ladder_entry_for_size( }) } +/// Resolve split amount and count from explicit args or the sell-ladder row. +/// +/// # Errors +/// +/// Returns an error when `size_base_units` is set but no matching sell-ladder row exists. pub fn resolve_split_targets( market: &MarketConfig, amount_per_coin: i64, @@ -52,6 +62,11 @@ pub fn resolve_split_targets( Ok((amount_per_coin, number_of_coins)) } +/// Resolve combine input count from explicit args or the sell-ladder excess threshold. +/// +/// # Errors +/// +/// Returns an error when `size_base_units` is set but ladder math is invalid or missing. pub fn resolve_combine_count( market: &MarketConfig, number_of_coins: i64, @@ -73,6 +88,7 @@ pub fn resolve_combine_count( Ok(number_of_coins) } +#[must_use] pub fn split_required_count(entry: &LadderEntry) -> i64 { entry.target_count + entry.split_buffer_count } diff --git a/greenfloor-engine/src/coin_ops/mod.rs b/greenfloor-engine/src/coin_ops/mod.rs index 56125d25..6a60e272 100644 --- a/greenfloor-engine/src/coin_ops/mod.rs +++ b/greenfloor-engine/src/coin_ops/mod.rs @@ -3,12 +3,14 @@ //! Lives in the `greenfloor-engine` crate alongside vault signing and cycle policy. mod amounts; +mod dust_jobs; mod effective_counts; pub mod execution; mod fee_budget; mod gate; mod input_selection; mod inventory; +mod ladder; mod plan; mod policy; mod scalars; @@ -17,9 +19,13 @@ pub mod shape; pub mod shape_ownership; pub mod shape_protection; mod unit_convert; +mod vault_controlled; mod wallet_coin; -pub use amounts::{combine_output_amounts, total_for_coin_ids, COMBINE_SINGLE_OUTPUT_COUNT}; +pub use amounts::{ + combine_output_amounts, total_for_coin_ids, vault_controlled_total, COMBINE_SINGLE_OUTPUT_COUNT, +}; +pub use dust_jobs::{build_enabled_cat_jobs, resolve_market_base_cat_asset_id, CatDustJob}; pub use effective_counts::effective_sell_bucket_counts_for_coin_ops; pub use execution::{ execute_managed_coin_op_plans, persist_coin_op_execution, CoinOpExecContext, CoinOpExecItem, @@ -39,6 +45,9 @@ pub use input_selection::{ SubCatChangeSkipData, }; pub use inventory::compute_bucket_counts_from_coins; +pub use ladder::{ + resolve_combine_count, resolve_split_targets, sell_ladder_entry_for_size, split_required_count, +}; pub use plan::{ plan_coin_ops, BucketSpec, CoinOpKind, CoinOpPlan, CoinOpPlanReason, CoinOpPlanningResult, LadderTargetRow, @@ -52,8 +61,9 @@ pub use scalars::{ coin_op_non_negative_u64, coin_op_non_negative_u64_saturating, i64_to_usize, usize_to_i64, }; pub use selection::{ - select_exact_amount_coin_ids, select_largest_spendable_coin, - select_spendable_coins_for_target_amount, split_would_create_sub_cat_change, SpendableCoin, + select_exact_amount_coin_ids, select_funding_coin_ids, select_largest_spendable_coin, + select_spendable_coins_for_target_amount, split_would_create_sub_cat_change, + FundingSelectionMode, SpendableCoin, }; pub use shape_ownership::{ aggregate_covers_without_single_coin, bootstrap_handoff, daemon_low_watermark_handoff, @@ -68,4 +78,5 @@ pub use unit_convert::{ cat_units_string_from_mojos, exact_whole_units_from_mojos, floored_units_from_mojos, mojos_from_whole_units, CAT_MOJOS_PER_UNIT, }; +pub use vault_controlled::{vault_controlled_balance, UnreturnedMakerCoin, VaultControlledBalance}; pub use wallet_coin::{is_spendable_coin_state, is_spendable_wallet_coin}; diff --git a/greenfloor-engine/src/coin_ops/selection.rs b/greenfloor-engine/src/coin_ops/selection.rs index 942adf54..86d2eacd 100644 --- a/greenfloor-engine/src/coin_ops/selection.rs +++ b/greenfloor-engine/src/coin_ops/selection.rs @@ -84,6 +84,11 @@ impl SpendableCoin { } } +pub(crate) fn coin_id_is_excluded(coin_id: &str, exclude_coin_ids: &HashSet) -> bool { + let lower = coin_id.to_ascii_lowercase(); + exclude_coin_ids.contains(coin_id) || exclude_coin_ids.contains(&lower) +} + #[must_use] pub fn select_largest_spendable_coin<'a>( coins: &'a [SpendableCoin], @@ -94,7 +99,7 @@ pub fn select_largest_spendable_coin<'a>( .iter() .filter(|coin| { !coin.id.is_empty() - && !exclude_coin_ids.contains(&coin.id) + && !coin_id_is_excluded(&coin.id, exclude_coin_ids) && coin.amount >= min_amount_mojos }) .max_by_key(|coin| coin.amount) @@ -112,7 +117,7 @@ pub fn select_exact_amount_coin_ids( if coin.id.is_empty() { continue; } - if exclude_coin_ids.contains(&coin.id.to_ascii_lowercase()) { + if coin_id_is_excluded(&coin.id, exclude_coin_ids) { continue; } if coin.amount != amount_mojos { @@ -128,6 +133,116 @@ pub fn select_exact_amount_coin_ids( selected } +/// Operator coin-selection mode (amount-scaled). Coinset CAT listing maps coins +/// to [`SpendableCoin`] and delegates here. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FundingSelectionMode { + /// Accumulate smallest coins until the running total covers `target_amount`. + SmallestFirst, + /// Subset-sum cover of `target_amount` (shape / bootstrap combine-first). + TargetCover, + /// Pick coins whose amount equals `target_amount` (exact-denomination combine). + ExactDenom, + /// Use every listed coin in input order (explicit coin-id sets). + AllListed, +} + +/// Select coin ids for `mode`. `target_amount` is on-chain mojos. +/// `cap` limits how many ids are returned (`None` = unbounded). +#[must_use] +pub fn select_funding_coin_ids( + mode: FundingSelectionMode, + coins: &[SpendableCoin], + target_amount: i64, + exclude_coin_ids: Option<&HashSet>, + cap: Option, +) -> Vec { + let excluded = exclude_coin_ids.cloned().unwrap_or_default(); + match mode { + FundingSelectionMode::ExactDenom => { + select_exact_amount_coin_ids(coins, target_amount, &excluded, cap) + } + FundingSelectionMode::TargetCover => { + let options = TargetAmountSelectionOptions { + max_input_count: cap, + ..TargetAmountSelectionOptions::default() + }; + let (ids, _, _) = select_spendable_coins_for_target_amount_with_options( + coins, + target_amount, + options, + ); + ids + } + FundingSelectionMode::SmallestFirst => { + select_smallest_first_spendable_ids(coins, target_amount, &excluded, cap) + } + FundingSelectionMode::AllListed => select_all_listed_spendable_ids(coins, &excluded, cap), + } +} + +fn select_all_listed_spendable_ids( + coins: &[SpendableCoin], + exclude_coin_ids: &HashSet, + cap: Option, +) -> Vec { + let mut ids = Vec::new(); + for coin in coins { + if coin.id.is_empty() || coin_id_is_excluded(&coin.id, exclude_coin_ids) { + continue; + } + ids.push(coin.id.clone()); + if cap.is_some_and(|limit| ids.len() >= limit) { + break; + } + } + ids +} + +fn select_smallest_first_spendable_ids( + coins: &[SpendableCoin], + target_amount: i64, + exclude_coin_ids: &HashSet, + cap: Option, +) -> Vec { + if target_amount <= 0 { + return Vec::new(); + } + let mut eligible: Vec<&SpendableCoin> = coins + .iter() + .filter(|coin| { + !coin.id.is_empty() + && !coin_id_is_excluded(&coin.id, exclude_coin_ids) + && coin.amount > 0 + }) + .collect(); + if let Some(coin) = eligible.iter().find(|coin| coin.amount == target_amount) { + return vec![coin.id.clone()]; + } + if let Some(coin) = eligible + .iter() + .filter(|coin| coin.amount >= target_amount) + .min_by_key(|coin| coin.amount) + { + return vec![coin.id.clone()]; + } + eligible.sort_by_key(|coin| coin.amount); + let mut selected = Vec::new(); + let mut running = 0i64; + for coin in eligible { + running = running.saturating_add(coin.amount); + selected.push(coin.id.clone()); + if cap.is_some_and(|max| selected.len() >= max) || running >= target_amount { + break; + } + } + if running >= target_amount { + selected + } else { + Vec::new() + } +} + /// Whether splitting `selected_amount_mojos` down to `required_amount_mojos` leaves CAT dust. /// /// Both amounts must be in on-chain **mojos** (daemon coin-op paths only). @@ -478,4 +593,26 @@ mod tests { HashSet::from(["old25_a", "old25_b", "remainder"].map(str::to_string)) ); } + + #[test] + fn funding_selection_mode_dispatches_exact_and_smallest() { + let list = coins(&[("tiny", 100), ("exact", 1000), ("big", 2500)]); + let exact = + select_funding_coin_ids(FundingSelectionMode::ExactDenom, &list, 1000, None, Some(2)); + assert_eq!(exact, vec!["exact"]); + let smallest = + select_funding_coin_ids(FundingSelectionMode::SmallestFirst, &list, 1000, None, None); + assert_eq!(smallest, vec!["exact"]); + let cover = select_funding_coin_ids( + FundingSelectionMode::TargetCover, + &list, + 2600, + None, + Some(3), + ); + assert!(!cover.is_empty()); + let listed = + select_funding_coin_ids(FundingSelectionMode::AllListed, &list, 2600, None, None); + assert_eq!(listed, vec!["tiny", "exact", "big"]); + } } diff --git a/greenfloor-engine/src/coin_ops/shape/deficit.rs b/greenfloor-engine/src/coin_ops/shape/deficit.rs index dd123c75..a16527ee 100644 --- a/greenfloor-engine/src/coin_ops/shape/deficit.rs +++ b/greenfloor-engine/src/coin_ops/shape/deficit.rs @@ -29,11 +29,6 @@ pub fn protected_slots_for_rows(rows: &[ShapeLadderRow]) -> HashMap { } /// Deficits and mixed-split output amounts from shape context (`sorted_rows` sorted by size). -/// -/// # Panics -/// -/// Panics if a computed deficit somehow exceeds `i64::MAX` as a `usize` (unreachable for any -/// realistic ladder configuration). #[must_use] pub fn collect_shape_deficits( sorted_rows: &[ShapeLadderRow], @@ -58,10 +53,10 @@ pub fn collect_shape_deficits( required_count: required, current_count: current, }); - output_amounts.extend(std::iter::repeat_n( - size, - usize::try_from(deficit).expect("deficit is positive"), - )); + let Some(repeat) = usize::try_from(deficit).ok() else { + continue; + }; + output_amounts.extend(std::iter::repeat_n(size, repeat)); } (deficits, output_amounts) } diff --git a/greenfloor-engine/src/coin_ops/shape/funding.rs b/greenfloor-engine/src/coin_ops/shape/funding.rs index 75cffd09..9c0d185f 100644 --- a/greenfloor-engine/src/coin_ops/shape/funding.rs +++ b/greenfloor-engine/src/coin_ops/shape/funding.rs @@ -203,12 +203,7 @@ fn select_smallest_non_cannibalizing_shape_coin<'a>( /// /// `ladder_shape` must be `Some` whenever `policy` prefers smallest non-cannibalizing /// selection or protects ladder rows during combine (i.e. [`ShapeFundingPolicy::Bootstrap`] -/// or [`ShapeFundingPolicy::DaemonProtected`]). -/// -/// # Panics -/// -/// Panics if `ladder_shape` is `None` while `policy` requires a shape context — every such -/// call site must supply one. +/// or [`ShapeFundingPolicy::DaemonProtected`]). Missing shape context yields [`CannotFund`]. #[must_use] pub fn resolve_shape_funding( coins: &[ShapeCoin], @@ -218,8 +213,9 @@ pub fn resolve_shape_funding( ) -> ShapeFundingResolution { let flags = policy.flags(); let selected = if flags.prefer_smallest_non_cannibalizing { - let ctx = ladder_shape - .expect("ladder_shape required when prefer_smallest_non_cannibalizing is set"); + let Some(ctx) = ladder_shape else { + return ShapeFundingResolution::CannotFund { required_amount }; + }; select_smallest_non_cannibalizing_shape_coin( coins, required_amount, @@ -263,7 +259,9 @@ pub fn resolve_shape_funding( } let combine = if ladder_preserving { - let ctx = ladder_shape.expect("ladder_shape required when protect_ladder_rows is set"); + let Some(ctx) = ladder_shape else { + return ShapeFundingResolution::CannotFund { required_amount }; + }; plan_ladder_preserving_combine( coins, &ctx.protected_slots, diff --git a/greenfloor-engine/src/coin_ops/shape_protection.rs b/greenfloor-engine/src/coin_ops/shape_protection.rs index 0825a814..bf6fccb8 100644 --- a/greenfloor-engine/src/coin_ops/shape_protection.rs +++ b/greenfloor-engine/src/coin_ops/shape_protection.rs @@ -11,7 +11,7 @@ use std::collections::{HashMap, HashSet}; use crate::config::LadderEntry; -use super::selection::SpendableCoin; +use super::selection::{coin_id_is_excluded, SpendableCoin}; use super::unit_convert::exact_whole_units_from_mojos; /// Exact whole ladder-unit amounts from spendable coins (for bucket / protection / ownership). @@ -324,7 +324,7 @@ pub fn select_smallest_non_cannibalizing_spendable<'a>( .iter() .filter(|coin| { !coin.id.is_empty() - && !exclude_coin_ids.contains(&coin.id) + && !coin_id_is_excluded(&coin.id, exclude_coin_ids) && coin.amount >= required_mojos }) .map(|coin| SplittableCandidate::from_mojos(coin.id.as_str(), coin.amount, multiplier)) diff --git a/greenfloor-engine/src/coin_ops/vault_controlled.rs b/greenfloor-engine/src/coin_ops/vault_controlled.rs new file mode 100644 index 00000000..e454c5b4 --- /dev/null +++ b/greenfloor-engine/src/coin_ops/vault_controlled.rs @@ -0,0 +1,121 @@ +//! Vault-controlled CAT balance: receive inventory plus known unreturned makers. + +use std::collections::HashSet; + +use crate::coinset::OfferCoinsetBackend; +use crate::cycle::{unreturned_row_priority, ReconcileState}; +use crate::error::{SignerError, SignerResult}; +use crate::hex::{hex_to_bytes32, normalize_hex_id}; +use crate::storage::SqliteStore; + +use super::amounts::vault_controlled_total; + +#[must_use] +fn cat_matches_asset_filter(cat_asset_id: &str, filter_asset_id: &str) -> bool { + normalize_hex_id(cat_asset_id) == normalize_hex_id(filter_asset_id) +} + +/// One unreturned maker coin included in vault-controlled balance. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnreturnedMakerCoin { + pub coin_id: String, + pub amount: u64, + pub fixed_delegated_puzzle_hash: String, + pub offer_id: String, + pub state: String, + pub size_base_units: Option, + pub reclaimable: bool, +} + +/// Receive inventory plus known unreturned makers for one CAT asset. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VaultControlledBalance { + pub receive_amount: u64, + pub unreturned_amount: u64, + pub vault_controlled_amount: u64, + pub unreturned_coins: Vec, +} + +/// Query unreturned makers, fetch CAT amounts, and sum vault-controlled inventory. +/// +/// # Errors +/// +/// Returns an error when `SQLite` or Coinset lookups fail. +pub async fn vault_controlled_balance( + store: &SqliteStore, + backend: &C, + market_id: &str, + filter_asset_id: &str, + receive_amount: u64, +) -> SignerResult { + let mut makers: Vec<_> = store + .list_unreturned_presplit_makers(Some(market_id))? + .into_iter() + .map(|row| { + let state = ReconcileState::parse(&row.state).ok(); + (row, state) + }) + .collect(); + makers.sort_by(|(a, a_state), (b, b_state)| { + unreturned_row_priority(a_state.as_ref()) + .cmp(&unreturned_row_priority(b_state.as_ref())) + .then_with(|| a.offer_id.cmp(&b.offer_id)) + }); + + let mut seen_coins = HashSet::new(); + let mut unreturned_amount = 0u64; + let mut unreturned_coins = Vec::new(); + for (row, state) in makers { + let coin_id = normalize_hex_id(&row.cancel_input_coin_id); + if !seen_coins.insert(coin_id.clone()) { + continue; + } + let Ok(bytes) = hex_to_bytes32(&coin_id) else { + continue; + }; + let amount = match backend.fetch_offer_input_cat(bytes).await { + Ok(cat) => { + let maker_asset = hex::encode(cat.info.asset_id); + if !cat_matches_asset_filter(&maker_asset, filter_asset_id) { + continue; + } + cat.coin.amount + } + Err(SignerError::PresplitCoinNotFound) => continue, + Err(err) => return Err(err), + }; + unreturned_amount = unreturned_amount.saturating_add(amount); + unreturned_coins.push(UnreturnedMakerCoin { + coin_id, + amount, + fixed_delegated_puzzle_hash: normalize_hex_id(&row.fixed_delegated_puzzle_hash), + offer_id: row.offer_id, + state: row.state, + size_base_units: row.size_base_units, + reclaimable: state + .as_ref() + .is_some_and(ReconcileState::is_ops_reclaimable), + }); + } + + Ok(VaultControlledBalance { + receive_amount, + unreturned_amount, + vault_controlled_amount: vault_controlled_total(receive_amount, unreturned_amount), + unreturned_coins, + }) +} + +#[cfg(test)] +mod tests { + use super::cat_matches_asset_filter; + + #[test] + fn unreturned_makers_filter_by_asset_id() { + let asset_a = "aa".repeat(32); + let asset_b = "bb".repeat(32); + assert!(cat_matches_asset_filter(&asset_a, &asset_a)); + assert!(cat_matches_asset_filter(&format!("0x{asset_a}"), &asset_a)); + assert!(!cat_matches_asset_filter(&asset_a, &asset_b)); + } +} diff --git a/greenfloor-engine/src/coinset/api/rpc.rs b/greenfloor-engine/src/coinset/api/rpc.rs index 7bbb4d53..e77699a5 100644 --- a/greenfloor-engine/src/coinset/api/rpc.rs +++ b/greenfloor-engine/src/coinset/api/rpc.rs @@ -52,7 +52,7 @@ async fn post_coinset_rpc_with( client .make_post_request(endpoint, body) .await - .map_err(SignerError::from) + .map_err(|err| SignerError::coinset(err.to_string())) } /// Script/scan Coinset RPC via the direct API host (`api.coinset.org` defaults). diff --git a/greenfloor-engine/src/coinset/asset.rs b/greenfloor-engine/src/coinset/asset.rs deleted file mode 100644 index 6ffe576a..00000000 --- a/greenfloor-engine/src/coinset/asset.rs +++ /dev/null @@ -1,30 +0,0 @@ -/// Canonical XCH / TXCH asset identifiers for coinset and BLS paths. -/// -/// Empty/whitespace is **not** XCH. Use [`is_xch_like_asset`] at signer payload -/// boundaries where empty means native XCH. -#[must_use] -pub fn is_canonical_xch_asset(asset_id: &str) -> bool { - matches!( - asset_id.trim().to_ascii_lowercase().as_str(), - "xch" | "txch" | "1" - ) -} - -#[must_use] -pub fn is_xch_like_asset(asset_id: &str) -> bool { - asset_id.trim().is_empty() || is_canonical_xch_asset(asset_id) -} - -#[cfg(test)] -mod tests { - use super::{is_canonical_xch_asset, is_xch_like_asset}; - - #[test] - fn recognizes_xch_like_assets() { - assert!(is_xch_like_asset("xch")); - assert!(is_xch_like_asset("TXCH")); - assert!(is_xch_like_asset("")); - assert!(!is_canonical_xch_asset("")); - assert!(!is_xch_like_asset(&"aa".repeat(32))); - } -} diff --git a/greenfloor-engine/src/coinset/cats/resolve.rs b/greenfloor-engine/src/coinset/cats/resolve.rs index f619b882..1b33be34 100644 --- a/greenfloor-engine/src/coinset/cats/resolve.rs +++ b/greenfloor-engine/src/coinset/cats/resolve.rs @@ -24,7 +24,7 @@ fn unparseable_cat_lineage(detail: impl Into) -> SignerError { /// /// # Errors /// -/// Returns [`SignerError::Coinset`] on transport/API failure. +/// Returns [`crate::error::TransportError::Coinset`] on transport/API failure. pub async fn fetch_parent_coin_spend( client: &CoinsetClient, parent_coin_info: Bytes32, diff --git a/greenfloor-engine/src/coinset/coin_select/mod.rs b/greenfloor-engine/src/coinset/coin_select/mod.rs index 08c94fbf..0096efba 100644 --- a/greenfloor-engine/src/coinset/coin_select/mod.rs +++ b/greenfloor-engine/src/coinset/coin_select/mod.rs @@ -1,6 +1,6 @@ //! Coin listing and selection (CAT; shared by vault and BLS paths). -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use chia_protocol::Bytes32; use chia_sdk_coinset::{CoinRecord, CoinsetClient}; @@ -9,7 +9,9 @@ use chia_sdk_driver::Cat; use super::cats::{ cat_from_record, coin_records_for_cat_outer_puzzle_hash, coin_records_for_coin_ids, }; +use crate::coin_ops::{select_funding_coin_ids, FundingSelectionMode, SpendableCoin}; use crate::error::{SignerError, SignerResult}; +use crate::hex::{bytes32_to_hex, normalize_hex_id}; /// Minimum CAT output amount for offer/dust policy (1000 mojos = 1 CAT unit). pub const MIN_CAT_OUTPUT_MOJOS: u64 = 1000; @@ -21,112 +23,96 @@ pub struct SelectedCats { pub change_amount: u64, } -#[must_use] -fn select_smallest_first_by_amount( - items: Vec, - target_total: u64, - amount: impl Fn(&T) -> u64, -) -> Vec { - if target_total == 0 { - return Vec::new(); - } - if let Some(item) = items - .iter() - .find(|item| amount(item) == target_total) - .copied() - { - return vec![item]; - } - if let Some(item) = items - .iter() - .filter(|item| amount(item) >= target_total) - .min_by_key(|item| amount(item)) - .copied() - { - return vec![item]; - } - let mut sorted = items; - sorted.sort_by_key(|item| amount(item)); - let mut selected = Vec::new(); - let mut running = 0u64; - for item in sorted { - running = running.saturating_add(amount(&item)); - selected.push(item); - if running >= target_total { - return selected; - } - } - Vec::new() +fn amount_i64(amount: u64) -> i64 { + i64::try_from(amount).unwrap_or(i64::MAX) } -#[must_use] -pub fn select_cats_smallest_first(cats: Vec, target_total: u64) -> Vec { - select_smallest_first_by_amount(cats, target_total, |cat| cat.coin.amount) +fn coin_spendable_id(coin_id: Bytes32) -> String { + normalize_hex_id(&bytes32_to_hex(coin_id)) } -/// How to reduce a CAT list to the coins that cover *`target_amount`*. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CoinSelectionMode { - /// Smallest-first subset until the running total reaches the target. - SmallestFirst, - /// Use every listed coin; fail when the sum is below the target. - ExplicitSum, +fn cat_to_spendable(cat: &Cat) -> SpendableCoin { + SpendableCoin::with_puzzle_hash( + coin_spendable_id(cat.coin.coin_id()), + amount_i64(cat.coin.amount), + normalize_hex_id(&bytes32_to_hex(cat.coin.puzzle_hash)), + ) } -impl CoinSelectionMode { - /// Wallet listing uses smallest-first; explicit coin ids use the full set. - pub fn from_explicit_ids(explicit_coin_ids: &[Bytes32]) -> Self { - if explicit_coin_ids.is_empty() { - CoinSelectionMode::SmallestFirst - } else { - CoinSelectionMode::ExplicitSum - } +fn record_to_spendable(record: &CoinRecord) -> SpendableCoin { + SpendableCoin::with_puzzle_hash( + coin_spendable_id(record.coin.coin_id()), + amount_i64(record.coin.amount), + normalize_hex_id(&bytes32_to_hex(record.coin.puzzle_hash)), + ) +} + +fn funding_mode_for_explicit_ids(explicit_coin_ids: &[Bytes32]) -> FundingSelectionMode { + if explicit_coin_ids.is_empty() { + FundingSelectionMode::SmallestFirst + } else { + FundingSelectionMode::AllListed + } +} + +fn reorder_by_ids(items: Vec, ids: &[String], id_of: impl Fn(&T) -> String) -> Vec { + let mut by_id: HashMap = HashMap::with_capacity(items.len()); + for item in items { + by_id.insert(id_of(&item), item); } + ids.iter().filter_map(|id| by_id.remove(id)).collect() } -fn select_from_list( +fn select_from_spendable( items: Vec, target_amount: u64, - mode: CoinSelectionMode, + mode: FundingSelectionMode, + to_spendable: impl Fn(&T) -> SpendableCoin, amount: impl Fn(&T) -> u64, - empty_list_err: SignerError, - insufficient_err: SignerError, ) -> SignerResult> { if items.is_empty() { - return Err(empty_list_err); + return Err(SignerError::NoUnspentCatCoins); } - let selected = match mode { - CoinSelectionMode::SmallestFirst => { - select_smallest_first_by_amount(items, target_amount, &amount) - } - CoinSelectionMode::ExplicitSum => items, - }; - if selected.is_empty() { - return Err(insufficient_err); + let spendable: Vec = items.iter().map(&to_spendable).collect(); + let ids = select_funding_coin_ids(mode, &spendable, amount_i64(target_amount), None, None); + if ids.is_empty() { + return Err(SignerError::InsufficientCatCoins); } + let selected = reorder_by_ids(items, &ids, |item| to_spendable(item).id); let offered_total: u64 = selected.iter().map(&amount).sum(); if offered_total < target_amount { - return Err(insufficient_err); + return Err(SignerError::InsufficientCatCoins); } Ok(selected) } -fn finalize_amount_selection( +#[must_use] +pub fn select_cats_smallest_first(cats: Vec, target_total: u64) -> Vec { + select_from_spendable( + cats, + target_total, + FundingSelectionMode::SmallestFirst, + cat_to_spendable, + |cat| cat.coin.amount, + ) + .unwrap_or_default() +} + +fn finalize_amount_selection( items: Vec, explicit_coin_ids: &[Bytes32], target_amount: u64, + to_spendable: impl Fn(&T) -> SpendableCoin, amount: impl Fn(&T) -> u64, ) -> SignerResult<(Vec, u64)> { - let mode = CoinSelectionMode::from_explicit_ids(explicit_coin_ids); - let selected = select_from_list( + let selected = select_from_spendable( items, target_amount, - mode, + funding_mode_for_explicit_ids(explicit_coin_ids), + to_spendable, &amount, - SignerError::NoUnspentCatCoins, - SignerError::InsufficientCatCoins, )?; - let offered_total: u64 = selected.iter().map(&amount).sum(); + let offered_total: u64 = selected.iter().map(amount).sum(); Ok((selected, offered_total)) } @@ -135,10 +121,13 @@ pub(crate) fn finalize_selected_cats( explicit_coin_ids: &[Bytes32], target_amount: u64, ) -> SignerResult { - let (selected, offered_total) = - finalize_amount_selection(cats, explicit_coin_ids, target_amount, |cat| { - cat.coin.amount - })?; + let (selected, offered_total) = finalize_amount_selection( + cats, + explicit_coin_ids, + target_amount, + cat_to_spendable, + |cat| cat.coin.amount, + )?; Ok(SelectedCats { change_amount: offered_total.saturating_sub(target_amount), selected, @@ -213,10 +202,13 @@ async fn select_cats_for_spend_from_records( .copied() .filter(|record| !excluded.contains(&record.coin.coin_id())) .collect(); - let (selected_records, _offered_total) = - finalize_amount_selection(available, explicit_coin_ids, target_amount, |record| { - record.coin.amount - })?; + let (selected_records, _offered_total) = finalize_amount_selection( + available, + explicit_coin_ids, + target_amount, + record_to_spendable, + |record| record.coin.amount, + )?; let mut selected = Vec::with_capacity(selected_records.len()); let mut unresolvable = Vec::new(); for record in selected_records { diff --git a/greenfloor-engine/src/coinset/coin_select/tests.rs b/greenfloor-engine/src/coinset/coin_select/tests.rs index 463eb9ee..d914ec9f 100644 --- a/greenfloor-engine/src/coinset/coin_select/tests.rs +++ b/greenfloor-engine/src/coinset/coin_select/tests.rs @@ -1,11 +1,13 @@ use chia_protocol::{Bytes32, CoinSpend}; use chia_sdk_coinset::CoinsetClient; +use chia_sdk_driver::Cat; use chia_sdk_test::Simulator; use super::{ - finalize_preselected_cats_for_spend, finalize_selected_cats, select_cats_for_spend, - select_from_list, CoinSelectionMode, + cat_to_spendable, finalize_preselected_cats_for_spend, finalize_selected_cats, + select_cats_for_spend, select_from_spendable, }; +use crate::coin_ops::FundingSelectionMode; use crate::coinset::test_support::{ cat_with_amount, mock_get_coin_record_by_name_body, mock_get_coin_records_by_puzzle_hash_body, mock_get_puzzle_and_solution_body, @@ -13,6 +15,16 @@ use crate::coinset::test_support::{ use crate::error::SignerError; use crate::test_support::simulator::harness::SimulatorVaultHarness; +fn select_cats_for_mode( + cats: Vec, + target_amount: u64, + mode: FundingSelectionMode, +) -> Result, SignerError> { + select_from_spendable(cats, target_amount, mode, cat_to_spendable, |cat| { + cat.coin.amount + }) +} + fn parent_spent_block_index(sim: &Simulator, parent_coin_id: Bytes32) -> u32 { sim.coin_state(parent_coin_id) .and_then(|state| state.spent_height) @@ -31,15 +43,8 @@ fn smallest_first_prefers_exact_single_coin() { cat_with_amount(10_000), cat_with_amount(100_000), ]; - let selected = select_from_list( - cats, - 10_000, - CoinSelectionMode::SmallestFirst, - |cat| cat.coin.amount, - SignerError::NoUnspentCatCoins, - SignerError::InsufficientCatCoins, - ) - .expect("selection"); + let selected = + select_cats_for_mode(cats, 10_000, FundingSelectionMode::SmallestFirst).expect("selection"); assert_eq!(selected.len(), 1); assert_eq!(selected[0].coin.amount, 10_000); } @@ -51,15 +56,8 @@ fn smallest_first_prefers_smallest_single_cover_coin() { cat_with_amount(20_000), cat_with_amount(100_000), ]; - let selected = select_from_list( - cats, - 10_000, - CoinSelectionMode::SmallestFirst, - |cat| cat.coin.amount, - SignerError::NoUnspentCatCoins, - SignerError::InsufficientCatCoins, - ) - .expect("selection"); + let selected = + select_cats_for_mode(cats, 10_000, FundingSelectionMode::SmallestFirst).expect("selection"); assert_eq!(selected.len(), 1); assert_eq!(selected[0].coin.amount, 20_000); } @@ -71,15 +69,8 @@ fn smallest_first_accumulates_when_no_single_coin_covers_target() { cat_with_amount(1000), cat_with_amount(1500), ]; - let selected = select_from_list( - cats, - 2500, - CoinSelectionMode::SmallestFirst, - |cat| cat.coin.amount, - SignerError::NoUnspentCatCoins, - SignerError::InsufficientCatCoins, - ) - .expect("selection"); + let selected = + select_cats_for_mode(cats, 2500, FundingSelectionMode::SmallestFirst).expect("selection"); assert_eq!(selected.len(), 2); assert_eq!(selected[0].coin.amount, 1000); assert_eq!(selected[1].coin.amount, 1500); @@ -87,29 +78,17 @@ fn smallest_first_accumulates_when_no_single_coin_covers_target() { #[test] fn smallest_first_empty_list_uses_empty_error() { - use chia_sdk_driver::Cat; - - let err = select_from_list( - Vec::::new(), - 1000, - CoinSelectionMode::SmallestFirst, - |cat| cat.coin.amount, - SignerError::NoUnspentCatCoins, - SignerError::InsufficientCatCoins, - ) - .expect_err("empty"); + let err = select_cats_for_mode(Vec::::new(), 1000, FundingSelectionMode::SmallestFirst) + .expect_err("empty"); assert!(matches!(err, SignerError::NoUnspentCatCoins)); } #[test] fn smallest_first_insufficient_uses_insufficient_error() { - let err = select_from_list( + let err = select_cats_for_mode( vec![cat_with_amount(500)], 1000, - CoinSelectionMode::SmallestFirst, - |cat| cat.coin.amount, - SignerError::NoUnspentCatCoins, - SignerError::InsufficientCatCoins, + FundingSelectionMode::SmallestFirst, ) .expect_err("insufficient"); assert!(matches!(err, SignerError::InsufficientCatCoins)); @@ -117,13 +96,10 @@ fn smallest_first_insufficient_uses_insufficient_error() { #[test] fn explicit_sum_requires_full_set_total() { - let selected = select_from_list( + let selected = select_cats_for_mode( vec![cat_with_amount(700), cat_with_amount(400)], 1000, - CoinSelectionMode::ExplicitSum, - |cat| cat.coin.amount, - SignerError::NoUnspentCatCoins, - SignerError::InsufficientCatCoins, + FundingSelectionMode::AllListed, ) .expect("sum covers target"); assert_eq!(selected.len(), 2); @@ -135,13 +111,10 @@ fn explicit_sum_requires_full_set_total() { #[test] fn explicit_sum_fails_when_total_below_target() { - let err = select_from_list( + let err = select_cats_for_mode( vec![cat_with_amount(400)], 1000, - CoinSelectionMode::ExplicitSum, - |cat| cat.coin.amount, - SignerError::NoUnspentCatCoins, - SignerError::InsufficientCatCoins, + FundingSelectionMode::AllListed, ) .expect_err("below target"); assert!(matches!(err, SignerError::InsufficientCatCoins)); diff --git a/greenfloor-engine/src/coinset/mod.rs b/greenfloor-engine/src/coinset/mod.rs index 15f37962..1c8f3e9e 100644 --- a/greenfloor-engine/src/coinset/mod.rs +++ b/greenfloor-engine/src/coinset/mod.rs @@ -1,5 +1,4 @@ mod api; -mod asset; mod backend; mod batch; mod broadcast; @@ -25,14 +24,13 @@ mod wallet_io; mod ws_event; mod xch; +pub use crate::hex::{is_canonical_xch_asset, is_xch_like_asset}; pub use api::{ conservative_fee_from_payload, direct_coinset_client, filter_confirmed_tx_ids, get_all_mempool_tx_ids, get_conservative_fee_estimate, get_conservative_fee_estimate_for_signer, get_fee_estimate, is_transaction_confirmed, post_coinset_coin_records, post_coinset_record, post_coinset_rpc, push_offer_text, push_tx_hex, }; -pub use asset::is_canonical_xch_asset; -pub use asset::is_xch_like_asset; pub use backend::{coin_id_is_unspent, LiveCoinset, OfferCoinsetBackend}; pub use batch::chunk_values; pub use broadcast::{ diff --git a/greenfloor-engine/src/coinset/pagination/cursor.rs b/greenfloor-engine/src/coinset/pagination/cursor.rs index 9404f16f..74c691e7 100644 --- a/greenfloor-engine/src/coinset/pagination/cursor.rs +++ b/greenfloor-engine/src/coinset/pagination/cursor.rs @@ -46,7 +46,7 @@ pub(crate) fn pagination_from_payload(payload: &Value) -> CoinsetRecordsPaginati /// Returns an error when `truncated` is true and `next_cursor` is missing. pub(crate) fn ensure_complete_page(pagination: &CoinsetRecordsPagination) -> SignerResult<()> { if pagination.truncated && pagination.next_cursor.is_none() { - return Err(SignerError::Coinset( + return Err(SignerError::coinset( "coinset response truncated without next_cursor".to_string(), )); } diff --git a/greenfloor-engine/src/coinset/pagination/mod.rs b/greenfloor-engine/src/coinset/pagination/mod.rs index d489a81e..dba6016b 100644 --- a/greenfloor-engine/src/coinset/pagination/mod.rs +++ b/greenfloor-engine/src/coinset/pagination/mod.rs @@ -55,7 +55,7 @@ where } return Ok(all); } - Err(SignerError::Coinset(format!( + Err(SignerError::coinset(format!( "coinset pagination exceeded {MAX_COINSET_RECORD_PAGES} pages for {endpoint}" ))) } diff --git a/greenfloor-engine/src/coinset/pagination/tests.rs b/greenfloor-engine/src/coinset/pagination/tests.rs index 3ccf8805..e51736f9 100644 --- a/greenfloor-engine/src/coinset/pagination/tests.rs +++ b/greenfloor-engine/src/coinset/pagination/tests.rs @@ -41,7 +41,7 @@ async fn fetch_all_coinset_pages_follows_cursor_for_typed_responses() { next_cursor: None, }) } - Some(other) => Err(SignerError::Coinset(format!("unexpected cursor {other}"))), + Some(other) => Err(SignerError::coinset(format!("unexpected cursor {other}"))), } }) .await @@ -116,7 +116,7 @@ async fn coin_records_from_json_endpoint_follows_cursor() { next_cursor: None, }, )), - Some(other) => Err(SignerError::Coinset(format!("unexpected cursor {other}"))), + Some(other) => Err(SignerError::coinset(format!("unexpected cursor {other}"))), } }) .await diff --git a/greenfloor-engine/src/coinset/retry.rs b/greenfloor-engine/src/coinset/retry.rs index 5db7073c..6e0b56e6 100644 --- a/greenfloor-engine/src/coinset/retry.rs +++ b/greenfloor-engine/src/coinset/retry.rs @@ -126,7 +126,11 @@ where { with_script_retries_with_policy(policy, || { let future = operation(); - async move { future.await.map_err(SignerError::from) } + async move { + future + .await + .map_err(|err| SignerError::coinset(err.to_string())) + } }) .await } @@ -151,7 +155,7 @@ mod tests { attempts += 1; async move { if attempts == 1 { - Err(SignerError::Coinset( + Err(SignerError::coinset( "error sending request for url (http://127.0.0.1:1/): connection refused" .to_string(), )) diff --git a/greenfloor-engine/src/coinset/rpc_result.rs b/greenfloor-engine/src/coinset/rpc_result.rs index dd0c1119..133b46d2 100644 --- a/greenfloor-engine/src/coinset/rpc_result.rs +++ b/greenfloor-engine/src/coinset/rpc_result.rs @@ -10,7 +10,7 @@ pub(crate) fn ensure_coinset_success( if success { return Ok(()); } - Err(SignerError::Coinset(error.map_or_else( + Err(SignerError::coinset(error.map_or_else( || failure_default.to_string(), str::to_string, ))) diff --git a/greenfloor-engine/src/coinset_cli/tests/dispatch_tests.rs b/greenfloor-engine/src/coinset_cli/tests/dispatch_tests.rs index 6d812c18..28cf0da6 100644 --- a/greenfloor-engine/src/coinset_cli/tests/dispatch_tests.rs +++ b/greenfloor-engine/src/coinset_cli/tests/dispatch_tests.rs @@ -8,7 +8,7 @@ use crate::coinset_cli::{ run_coinset_command, CoinsetCliArgs, CoinsetClientArgs, CoinsetCoinIdFromRecordArgs, CoinsetCommands, CoinsetPostArgs, CoinsetResolveClientArgs, }; -use crate::error::SignerError; +use crate::error::{SignerError, TransportError}; use chia_protocol::SpendBundle; use chia_protocol::{Bytes32, Coin}; use chia_traits::Streamable; @@ -122,7 +122,10 @@ async fn coin_records_surfaces_retryable_error_on_http_503() { ) .await .expect_err("503"); - assert!(matches!(err, SignerError::Coinset(_))); + assert!(matches!( + err, + SignerError::Transport(TransportError::Coinset(_)) + )); assert!(script_engine_error_retryable(&err)); } diff --git a/greenfloor-engine/src/config/cat_ticker_index.rs b/greenfloor-engine/src/config/cat_ticker_index.rs index 27f0b529..f2df9f51 100644 --- a/greenfloor-engine/src/config/cat_ticker_index.rs +++ b/greenfloor-engine/src/config/cat_ticker_index.rs @@ -73,6 +73,13 @@ pub fn lookup_asset_id_from_ticker( ))) } +/// Resolve a ticker or catalog symbol to an asset id, treating ambiguous tickers as a miss. +#[must_use] +pub fn resolve_asset_id_from_catalog(catalog: &[JsonValue], ticker: &str) -> Option { + let index = build_cat_ticker_index_from_cats_rows(catalog); + lookup_asset_id_from_ticker(&index, ticker).ok().flatten() +} + impl CatTickerIndex { /// True when `label` is `asset_id` (hex) or a ticker that maps to it. /// diff --git a/greenfloor-engine/src/config/markets/parse.rs b/greenfloor-engine/src/config/markets/parse.rs index d537cbc1..5d14acfd 100644 --- a/greenfloor-engine/src/config/markets/parse.rs +++ b/greenfloor-engine/src/config/markets/parse.rs @@ -32,7 +32,10 @@ pub fn parse_markets_config(raw: &Value) -> SignerResult { .ok_or_else(|| config_err("markets entries must be mappings"))?; parse_market_row(row) }) - .collect::>()?; + .collect::>>()?; + for market in &markets { + validate_enabled_market(market)?; + } Ok(MarketsConfig { markets }) } @@ -68,6 +71,31 @@ fn parse_market_row(row: &Map) -> SignerResult { }) } +fn validate_enabled_market(market: &MarketConfig) -> SignerResult<()> { + if !market.enabled { + return Ok(()); + } + if market.receive_address.trim().is_empty() { + return Err(config_err(format!( + "market {} enabled but receive_address is empty", + market.market_id + ))); + } + if market.signer_key_id.trim().is_empty() { + return Err(config_err(format!( + "market {} enabled but signer_key_id is empty", + market.market_id + ))); + } + if market.base_asset.trim().is_empty() { + return Err(config_err(format!( + "market {} enabled but base_asset is empty", + market.market_id + ))); + } + Ok(()) +} + fn parse_ladders( market_id: &str, ladders_raw: Option<&Value>, diff --git a/greenfloor-engine/src/config/mod.rs b/greenfloor-engine/src/config/mod.rs index f80de79c..2ef5b0d2 100644 --- a/greenfloor-engine/src/config/mod.rs +++ b/greenfloor-engine/src/config/mod.rs @@ -13,7 +13,8 @@ pub(crate) mod yaml_file; pub use cat_ticker_index::{ build_cat_ticker_index, build_cat_ticker_index_from_cats_rows, build_cat_ticker_index_lenient, - empty_cat_ticker_index, lookup_asset_id_from_ticker, normalize_label, CatTickerIndex, + empty_cat_ticker_index, lookup_asset_id_from_ticker, normalize_label, + resolve_asset_id_from_catalog, CatTickerIndex, }; pub use cats_catalog::{load_cats_catalog, write_cats_catalog}; pub use keys_registry::SignerKeyEntry; diff --git a/greenfloor-engine/src/config/program.rs b/greenfloor-engine/src/config/program.rs index 99f1f207..aede20cf 100644 --- a/greenfloor-engine/src/config/program.rs +++ b/greenfloor-engine/src/config/program.rs @@ -28,6 +28,7 @@ pub struct ManagerProgramConfig { pub coin_ops_max_daily_fee_budget_mojos: i64, pub coin_ops_split_fee_mojos: i64, pub coin_ops_combine_fee_mojos: i64, + pub coin_ops_combine_input_coin_cap: i64, pub runtime_offer_bootstrap_wait_timeout_seconds: u64, pub runtime_market_slot_count: u64, pub runtime_offer_parallelism_enabled: bool, @@ -148,6 +149,7 @@ impl Default for ManagerProgramConfig { coin_ops_max_daily_fee_budget_mojos: 0, coin_ops_split_fee_mojos: 0, coin_ops_combine_fee_mojos: 0, + coin_ops_combine_input_coin_cap: 5, runtime_offer_bootstrap_wait_timeout_seconds: 120, runtime_market_slot_count: 0, runtime_offer_parallelism_enabled: false, diff --git a/greenfloor-engine/src/config/program_parse/mod.rs b/greenfloor-engine/src/config/program_parse/mod.rs index 2e4345ef..cc25e8a8 100644 --- a/greenfloor-engine/src/config/program_parse/mod.rs +++ b/greenfloor-engine/src/config/program_parse/mod.rs @@ -64,6 +64,7 @@ pub fn parse_program_config(raw: &Value) -> SignerResult { coin_ops_max_daily_fee_budget_mojos: coin_ops.coin_ops_max_daily_fee_budget_mojos, coin_ops_split_fee_mojos: coin_ops.coin_ops_split_fee_mojos, coin_ops_combine_fee_mojos: coin_ops.coin_ops_combine_fee_mojos, + coin_ops_combine_input_coin_cap: coin_ops.coin_ops_combine_input_coin_cap, runtime_offer_bootstrap_wait_timeout_seconds: runtime .runtime_offer_bootstrap_wait_timeout_seconds, runtime_market_slot_count: runtime.runtime_market_slot_count, diff --git a/greenfloor-engine/src/config/program_parse/sections.rs b/greenfloor-engine/src/config/program_parse/sections.rs index dc255821..d6b42de9 100644 --- a/greenfloor-engine/src/config/program_parse/sections.rs +++ b/greenfloor-engine/src/config/program_parse/sections.rs @@ -98,6 +98,7 @@ pub(super) struct CoinOpsFields { pub coin_ops_max_daily_fee_budget_mojos: i64, pub coin_ops_split_fee_mojos: i64, pub coin_ops_combine_fee_mojos: i64, + pub coin_ops_combine_input_coin_cap: i64, } pub(super) fn parse_coin_ops_config( @@ -109,7 +110,7 @@ pub(super) fn parse_coin_ops_config( } let coin_ops_minimum_fee_mojos = u64::try_from(raw_fee) .map_err(|_| config_err("coin_ops.minimum_fee_mojos must fit in u64"))?; - Ok(CoinOpsFields { + let fields = CoinOpsFields { coin_ops_minimum_fee_mojos, coin_ops_max_operations_per_run: coin_ops_i64_field( coin_ops, @@ -123,7 +124,16 @@ pub(super) fn parse_coin_ops_config( )?, coin_ops_split_fee_mojos: coin_ops_i64_field(coin_ops, "split_fee_mojos", 0)?, coin_ops_combine_fee_mojos: coin_ops_i64_field(coin_ops, "combine_fee_mojos", 0)?, - }) + coin_ops_combine_input_coin_cap: coin_ops_i64_field(coin_ops, "combine_input_coin_cap", 5)? + .max(2), + }; + if fields.coin_ops_split_fee_mojos != 0 || fields.coin_ops_combine_fee_mojos != 0 { + return Err(config_err( + "coin_ops.split_fee_mojos and coin_ops.combine_fee_mojos must be 0; \ + vault mixed-split does not support fees", + )); + } + Ok(fields) } #[allow(clippy::struct_field_names)] diff --git a/greenfloor-engine/src/cycle/market.rs b/greenfloor-engine/src/cycle/market.rs index f3247eb0..7dc6a3bd 100644 --- a/greenfloor-engine/src/cycle/market.rs +++ b/greenfloor-engine/src/cycle/market.rs @@ -33,20 +33,21 @@ pub fn market_cycle_phases() -> &'static [MarketCyclePhase] { MarketCyclePhase::Reconcile, MarketCyclePhase::SoftExpire, MarketCyclePhase::Inventory, - MarketCyclePhase::Strategy, MarketCyclePhase::Cancel, + MarketCyclePhase::Strategy, MarketCyclePhase::CoinOps, ] } /// Phases run in-process after reconcile completes (reconcile is handled separately). +/// Cancel runs before strategy so a triggered cancel policy cannot race new posts. #[must_use] pub fn post_reconcile_market_cycle_phases() -> &'static [MarketCyclePhase] { &[ MarketCyclePhase::SoftExpire, MarketCyclePhase::Inventory, - MarketCyclePhase::Strategy, MarketCyclePhase::Cancel, + MarketCyclePhase::Strategy, MarketCyclePhase::CoinOps, ] } @@ -61,6 +62,8 @@ pub struct MarketCycleResultState { pub cancel_executed: i64, pub immediate_requeue_requested: bool, pub immediate_requeue_signals: Vec, + /// Strategy execution failed; coin-ops must not run on stale assumptions. + pub strategy_failed: bool, } impl MarketCycleResultState { diff --git a/greenfloor-engine/src/cycle/mod.rs b/greenfloor-engine/src/cycle/mod.rs index 22632198..d751eec5 100644 --- a/greenfloor-engine/src/cycle/mod.rs +++ b/greenfloor-engine/src/cycle/mod.rs @@ -16,6 +16,7 @@ pub mod retry; pub mod strategy; pub mod strategy_action; +pub use crate::adapters::is_dexie_offer_missing_error_text; pub use cancel::{ abs_move_bps, cancel_move_threshold_bps, evaluate_cancel_policy_decision, CancelPolicyDecision, }; @@ -42,15 +43,15 @@ pub use notifications::{ }; pub use orchestration::{ classify_dexie_stale_offer_status, collect_stale_sweep_candidates, dedupe_sorted_market_ids, - enqueue_immediate_requeue, is_dexie_offer_missing_error_text, record_stale_sweep_check, - select_market_batch, should_try_cat_inventory_fallback, should_use_market_slot_dispatch, - MarketBatchSelection, OfferStateRow, StaleSweepCandidate, StaleSweepHit, StaleSweepProgress, + enqueue_immediate_requeue, record_stale_sweep_check, select_market_batch, + should_try_cat_inventory_fallback, should_use_market_slot_dispatch, MarketBatchSelection, + OfferStateRow, StaleSweepCandidate, StaleSweepHit, StaleSweepProgress, DEFAULT_DISABLED_MARKET_LOG_INTERVAL_SECONDS, MIN_DISABLED_MARKET_LOG_INTERVAL_SECONDS, }; pub use reconcile::{ resolve_missing_watched_offer_transition, resolve_watched_offer_transition_from_signals, - unchanged_offer_transition, unsupported_venue_offer_transition, CycleOfferTransition, - ReconcileState, + unchanged_offer_transition, unreturned_row_priority, unsupported_venue_offer_transition, + CycleOfferTransition, ReconcileState, ReconcileStateError, }; pub use reseed::{ plan_reseed_actions_from_gap, reseed_skip_reason_labels, ReseedGapPlan, ReseedSkipReason, diff --git a/greenfloor-engine/src/cycle/orchestration.rs b/greenfloor-engine/src/cycle/orchestration.rs index a4942cff..3bdd5332 100644 --- a/greenfloor-engine/src/cycle/orchestration.rs +++ b/greenfloor-engine/src/cycle/orchestration.rs @@ -239,16 +239,7 @@ pub fn classify_dexie_stale_offer_status(status: i64) -> Option<&'static str> { } } -#[must_use] -pub fn is_dexie_offer_missing_error_text(error_text: &str) -> bool { - let normalized = error_text.trim().to_ascii_lowercase(); - if normalized.is_empty() { - return false; - } - (normalized.contains("dexie_get_offer_error") && normalized.contains("404")) - || normalized.contains("dexie_http_error:404") - || (normalized.contains("http error 404") && normalized.contains("not found")) -} +pub use crate::adapters::is_dexie_offer_missing_error_text; #[must_use] pub fn record_stale_sweep_check( diff --git a/greenfloor-engine/src/cycle/reconcile/cancel_submitted_policy/mod.rs b/greenfloor-engine/src/cycle/reconcile/cancel_submitted_policy/mod.rs index 38e0157d..b08634f1 100644 --- a/greenfloor-engine/src/cycle/reconcile/cancel_submitted_policy/mod.rs +++ b/greenfloor-engine/src/cycle/reconcile/cancel_submitted_policy/mod.rs @@ -77,7 +77,10 @@ pub(crate) fn allowed_cancel_target_offer_ids( let defer_targets: std::collections::HashSet<&str> = db_rows .iter() .filter_map(|row| { - if !ReconcileState::parse(&row.state).is_ok_and(|state| state.is_cancel_submitted()) { + if !row + .reconcile_state() + .is_ok_and(|state| state.is_cancel_submitted()) + { return None; } if row diff --git a/greenfloor-engine/src/cycle/reconcile/mod.rs b/greenfloor-engine/src/cycle/reconcile/mod.rs index d8323276..30b561b5 100644 --- a/greenfloor-engine/src/cycle/reconcile/mod.rs +++ b/greenfloor-engine/src/cycle/reconcile/mod.rs @@ -27,7 +27,7 @@ pub use coinset_signals::{ signals_from_ws_offer_status, CoinsetSignalSummary, CoinsetTxSignals, MakerHit, }; pub(crate) use metadata::{REASON_POTENTIAL_TAKE_SEEN, REASON_TAKE_CONFIRMED_ON_TX_BLOCK}; -pub use state::{ReconcileState, ReconcileStateError}; +pub use state::{unreturned_row_priority, ReconcileState, ReconcileStateError}; pub(crate) use state::{ BINDING_MAKER_QUERY_STATES, LADDER_CAPACITY_QUERY_STATES, STATE_CANCELLED, STATE_MAKER_CLAIMED, }; diff --git a/greenfloor-engine/src/cycle/reconcile/state.rs b/greenfloor-engine/src/cycle/reconcile/state.rs index e1340004..a67d91e8 100644 --- a/greenfloor-engine/src/cycle/reconcile/state.rs +++ b/greenfloor-engine/src/cycle/reconcile/state.rs @@ -170,6 +170,27 @@ impl ReconcileState { pub fn binds_unique_maker_coin(&self) -> bool { self.is_watched_for_reconcile() || matches!(self, Self::MakerClaimed) } + + /// Sort key for coins-balance unreturned makers: active listings, then expired, then the rest. + #[must_use] + pub fn unreturned_maker_priority(&self) -> u8 { + match self { + Self::Lifecycle( + OfferLifecycleState::Open + | OfferLifecycleState::RefreshDue + | OfferLifecycleState::MempoolObserved, + ) + | Self::PendingVisibility => 0, + Self::Lifecycle(OfferLifecycleState::Expired) => 1, + _ => 2, + } + } +} + +/// Prefer open/active rows when multiple `offer_state` rows share a maker coin id. +#[must_use] +pub fn unreturned_row_priority(state: Option<&ReconcileState>) -> u8 { + state.map_or(2, ReconcileState::unreturned_maker_priority) } /// Persistable states loaded for ladder capacity (includes mempool for timed filter). diff --git a/greenfloor-engine/src/daemon/coinset_spendable.rs b/greenfloor-engine/src/daemon/coinset_spendable.rs index 4d9e57f1..e85669d7 100644 --- a/greenfloor-engine/src/daemon/coinset_spendable.rs +++ b/greenfloor-engine/src/daemon/coinset_spendable.rs @@ -59,7 +59,11 @@ pub async fn coinset_spendable_profiles_for_signer( return Ok(profiles); } for asset_id in asset_ids { - let profile = profiles.get_mut(asset_id).expect("profile"); + let profile = profiles.get_mut(asset_id).ok_or_else(|| { + crate::error::SignerError::Other(format!( + "spendable profile missing for asset {asset_id}" + )) + })?; let coins = list_wallet_unspent_coins_for_signer(network, signer, receive_address, asset_id) .await?; diff --git a/greenfloor-engine/src/daemon/cycle_entry.rs b/greenfloor-engine/src/daemon/cycle_entry.rs index e01e6b30..81497a4a 100644 --- a/greenfloor-engine/src/daemon/cycle_entry.rs +++ b/greenfloor-engine/src/daemon/cycle_entry.rs @@ -239,7 +239,7 @@ pub async fn run_daemon_cycle_once( LogContext::DAEMON_CYCLE.audit(store, DAEMON_CYCLE_SUMMARY, &summary_payload, None) })?; - let exit_code = compute_cycle_exit_code(&plan, &metrics); + let exit_code = compute_cycle_exit_code(&plan, &metrics, preamble.cycle_error_count); trace_daemon_cycle_completed(exit_code, &summary, plan.selected_market_ids.len()); Ok(DaemonCycleOnceResponse { diff --git a/greenfloor-engine/src/daemon/inventory_phase.rs b/greenfloor-engine/src/daemon/inventory_phase.rs index a297fa9f..6b69c1c2 100644 --- a/greenfloor-engine/src/daemon/inventory_phase.rs +++ b/greenfloor-engine/src/daemon/inventory_phase.rs @@ -45,6 +45,15 @@ pub fn assert_inventory_asset_resolution_matches_config( Ok(()) } +/// Outcome of the inventory bucket scan for coin-ops planning. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InventoryScanOutcome { + /// Successful scan or freshness cache hit. + Fresh(BTreeMap), + /// Signer soft-skip: inventory is unknown, not empty. Coin-ops must not treat this as zero. + Unknown, +} + /// Scan spendable inventory into ladder bucket counts, skipping HTTP when WS freshness allows. /// /// # Errors @@ -56,7 +65,7 @@ pub async fn run_inventory_phase( resources: &DaemonCycleResources, market: &MarketConfig, state: &mut MarketCycleResultState, -) -> SignerResult> { +) -> SignerResult { let ladder_sizes: Vec = market .ladders .get("sell") @@ -65,7 +74,7 @@ pub async fn run_inventory_phase( .filter(|size| *size > 0) .collect(); if ladder_sizes.is_empty() { - return Ok(BTreeMap::default()); + return Ok(InventoryScanOutcome::Fresh(BTreeMap::default())); } let watched_coin_ids = store.list_watched_coin_ids_for_market(&market.market_id)?; @@ -93,7 +102,7 @@ pub async fn run_inventory_phase( }), Some(&market.market_id), )?; - return Ok(cached); + return Ok(InventoryScanOutcome::Fresh(cached)); } } @@ -139,7 +148,7 @@ pub async fn run_inventory_phase( }), Some(&market.market_id), )?; - Ok(bucket_counts) + Ok(InventoryScanOutcome::Fresh(bucket_counts)) } Err(err) if is_signer_execution_soft_skip(&err) => { LogContext::MARKET_CYCLE.dual_audit( @@ -150,11 +159,11 @@ pub async fn run_inventory_phase( &json!({ "market_id": market.market_id, "source": signer_execution_skip_reason(&err), - "bucket_counts": {}, + "inventory": "unknown", }), Some(&market.market_id), )?; - Ok(BTreeMap::default()) + Ok(InventoryScanOutcome::Unknown) } Err(err) => { state.record_phase_error(); @@ -289,7 +298,7 @@ mod tests { let returned = run_inventory_phase(&store, &resources, &market, &mut state) .await .expect("fresh skip"); - assert_eq!(returned, buckets); + assert_eq!(returned, InventoryScanOutcome::Fresh(buckets.clone())); let audits = store .list_recent_audit_events(Some(&[INVENTORY_BUCKET_SCAN]), Some("m1"), 5) .expect("audits"); @@ -310,7 +319,8 @@ mod tests { .await .expect("stale path"); assert_ne!( - after_stale, buckets, + after_stale, + InventoryScanOutcome::Fresh(buckets), "stale path must not return the prior fresh-skip cache" ); } diff --git a/greenfloor-engine/src/daemon/loop_harness.rs b/greenfloor-engine/src/daemon/loop_harness.rs index 16fa050b..9b2ad45a 100644 --- a/greenfloor-engine/src/daemon/loop_harness.rs +++ b/greenfloor-engine/src/daemon/loop_harness.rs @@ -50,6 +50,7 @@ mod tests { use crate::minimal_program_template::{write_minimal_program, MinimalProgramParams}; use crate::operator_log::{CONFIG_RELOADED, DAEMON_CYCLE_SUMMARY}; use crate::storage::SqliteStore; + use crate::test_env::EnvRestoreGuard; struct LoopFixture { _dir: tempfile::TempDir, @@ -150,8 +151,10 @@ mod tests { #[tokio::test] async fn loop_runs_configured_cycle_count_and_returns_last_exit_code() { + let _env = EnvRestoreGuard::set(&[("GREENFLOOR_XCH_PRICE_USD", "42.5")]); let (_server, fixture) = fixture_with_dexie_offers().await; let exit_code = fixture.run(DaemonLoopTestHarness::with_cycles(2)).await; + // No signer / no mempool poll: inventory is unknown, Dexie is mocked, cycles succeed. assert_eq!(exit_code, 0); let store = SqliteStore::open(&fixture.db_path).expect("open db"); assert_eq!(audit_event_count(&store, DAEMON_CYCLE_SUMMARY), 2); @@ -159,6 +162,7 @@ mod tests { #[tokio::test] async fn loop_returns_non_zero_when_last_cycle_fails() { + let _env = EnvRestoreGuard::set(&[("GREENFLOOR_XCH_PRICE_USD", "42.5")]); let (_server, fixture) = fixture_with_dexie_offers().await; let exit_code = fixture .run(DaemonLoopTestHarness { @@ -176,6 +180,7 @@ mod tests { #[tokio::test] async fn loop_clears_reload_marker_during_cycle() { + let _env = EnvRestoreGuard::set(&[("GREENFLOOR_XCH_PRICE_USD", "42.5")]); let (_server, fixture) = fixture_with_dexie_offers().await; std::fs::write( reload_marker_path(&fixture.state_dir), diff --git a/greenfloor-engine/src/daemon/market_cycle.rs b/greenfloor-engine/src/daemon/market_cycle.rs index d37a702f..0ec858d0 100644 --- a/greenfloor-engine/src/daemon/market_cycle.rs +++ b/greenfloor-engine/src/daemon/market_cycle.rs @@ -11,7 +11,7 @@ use crate::storage::CycleWriteStore; use super::cancel_phase::run_market_cancel_phase; use super::coin_ops_phase::run_coin_ops_phase; use super::cycle_store::run_logged_market_phase; -use super::inventory_phase::run_inventory_phase; +use super::inventory_phase::{run_inventory_phase, InventoryScanOutcome}; use super::market_context::MarketCycleContext; use super::market_gate::enforce_market_key_allowlist; use super::soft_expire_phase::run_soft_expire_phase; @@ -36,7 +36,8 @@ async fn execute_post_reconcile_phases( market: &MarketConfig, cycle_state: &mut MarketCycleResultState, ) -> SignerResult<()> { - // Soft-expire marks + reclaims surplus; strategy ensure_size fills ladder gaps. + // Soft-expire marks + reclaims surplus; cancel (when triggered) before strategy + // so new posts cannot race a strong-move pull. Coin-ops uses strategy counts. // Uses CycleWriteStore sync slices (no lock held across Coinset/Dexie awaits). run_logged_market_phase( market.market_id.as_str(), @@ -45,7 +46,7 @@ async fn execute_post_reconcile_phases( ) .await?; - let bucket_counts = locked_logged_phase!( + let inventory = locked_logged_phase!( market.market_id.as_str(), "inventory", write_store, @@ -53,6 +54,11 @@ async fn execute_post_reconcile_phases( ) .await?; + locked_logged_phase!(market.market_id.as_str(), "cancel", write_store, |store| { + run_market_cancel_phase(&store, ctx, market, cycle_state) + }) + .await?; + let strategy = run_logged_market_phase( market.market_id.as_str(), "strategy", @@ -60,10 +66,12 @@ async fn execute_post_reconcile_phases( ) .await?; - locked_logged_phase!(market.market_id.as_str(), "cancel", write_store, |store| { - run_market_cancel_phase(&store, ctx, market, cycle_state) - }) - .await?; + let InventoryScanOutcome::Fresh(bucket_counts) = inventory else { + return Ok(()); + }; + if cycle_state.strategy_failed { + return Ok(()); + } locked_logged_phase!( market.market_id.as_str(), @@ -155,8 +163,8 @@ mod tests { &[ MarketCyclePhase::SoftExpire, MarketCyclePhase::Inventory, - MarketCyclePhase::Strategy, MarketCyclePhase::Cancel, + MarketCyclePhase::Strategy, MarketCyclePhase::CoinOps, ] ); diff --git a/greenfloor-engine/src/daemon/offer_dispatch/managed_post.rs b/greenfloor-engine/src/daemon/offer_dispatch/managed_post.rs index 2b7d21f5..d0f9565c 100644 --- a/greenfloor-engine/src/daemon/offer_dispatch/managed_post.rs +++ b/greenfloor-engine/src/daemon/offer_dispatch/managed_post.rs @@ -12,7 +12,7 @@ use crate::cycle::PlannedAction; use crate::daemon::cycle_paths::DaemonCyclePaths; use crate::daemon::market_context::MarketCycleContext; use crate::error::SignerResult; -use crate::offer::operator::{ensure_size_n_offer, BuildAndPostOfferRequestParts}; +use crate::offer::operator::{ensure_size_n_offer, BuildAndPostOfferRequest}; use crate::offer::request::normalize_offer_side; use crate::storage::CycleWriteStore; @@ -53,9 +53,9 @@ fn daemon_ensure_parts( post_ctx: &ManagedPostContext, market: &MarketConfig, action: &PlannedAction, -) -> SignerResult { +) -> SignerResult { let size_base_units = crate::config::parse_non_negative_u64(action.size, "action.size")?; - Ok(BuildAndPostOfferRequestParts::for_ensure_size( + Ok(BuildAndPostOfferRequest::for_ensure_size( &post_ctx.paths.as_operator_paths(), &post_ctx.program, post_ctx.operator_network.clone(), diff --git a/greenfloor-engine/src/daemon/offer_dispatch/parallel.rs b/greenfloor-engine/src/daemon/offer_dispatch/parallel.rs index 39a11dd8..ee2a3562 100644 --- a/greenfloor-engine/src/daemon/offer_dispatch/parallel.rs +++ b/greenfloor-engine/src/daemon/offer_dispatch/parallel.rs @@ -224,8 +224,20 @@ pub async fn execute_actions_parallel( return result; } + let fee_amount_mojos = crate::coinset::get_conservative_fee_estimate_for_signer( + signer_config, + &ctx.resources.network, + 1_000_000, + Some(1), + ) + .await + .ok() + .flatten() + .and_then(|fee| i64::try_from(fee).ok()) + .unwrap_or(0); let reservation_ctx = - parallel_reservation_context(&ctx.resources.asset_resolver()?, market, 0).await?; + parallel_reservation_context(&ctx.resources.asset_resolver()?, market, fee_amount_mojos) + .await?; let spendable_profiles = resolve_parallel_spendable_profiles(ctx, market, &reservation_ctx).await?; diff --git a/greenfloor-engine/src/daemon/offer_dispatch/tests/classify_tests.rs b/greenfloor-engine/src/daemon/offer_dispatch/tests/classify_tests.rs index 5d54cf4f..a8722a2b 100644 --- a/greenfloor-engine/src/daemon/offer_dispatch/tests/classify_tests.rs +++ b/greenfloor-engine/src/daemon/offer_dispatch/tests/classify_tests.rs @@ -86,10 +86,10 @@ fn classify_parallel_dispatch_success_returns_output() { #[test] fn classify_parallel_dispatch_transient_error_falls_back() { - let err = SignerError::Other("ReservationContentionError: busy".to_string()); + let err = SignerError::ReservationContention("busy".to_string()); match classify_parallel_dispatch(Err(err)) { ParallelDispatchDecision::FallbackTransient(message) => { - assert!(message.to_string().contains("ReservationContentionError")); + assert!(message.to_string().contains("busy")); } _ => panic!("expected transient fallback"), } @@ -113,7 +113,7 @@ async fn record_parallel_fallback_audit_persists_event() { let dir = tempdir().expect("tempdir"); let db_path = dir.path().join("greenfloor.sqlite"); let store = CycleWriteStore::open(&db_path).expect("open"); - let err = SignerError::Other("ReservationContentionError: simulated".to_string()); + let err = SignerError::ReservationContention("simulated".to_string()); record_parallel_fallback_audit(&store, "m1", &err).expect("audit"); let events = lock_shared_store_for_test(&store) .list_recent_audit_events(Some(&["offer_parallel_fallback"]), Some("m1"), 5) diff --git a/greenfloor-engine/src/daemon/run_once/summary.rs b/greenfloor-engine/src/daemon/run_once/summary.rs index c95ad4c0..6c926919 100644 --- a/greenfloor-engine/src/daemon/run_once/summary.rs +++ b/greenfloor-engine/src/daemon/run_once/summary.rs @@ -48,10 +48,17 @@ pub fn elapsed_ms(started: Instant) -> u64 { } #[must_use] -pub fn compute_cycle_exit_code(plan: &CyclePlan, metrics: &MarketDispatchMetrics) -> i32 { +pub fn compute_cycle_exit_code( + plan: &CyclePlan, + metrics: &MarketDispatchMetrics, + preamble_error_count: u64, +) -> i32 { let attempted = plan.selected_market_ids.len(); if attempted > 0 && metrics.markets_processed == 0 { return 1; } + if preamble_error_count + metrics.cycle_error_count > 0 { + return 1; + } 0 } diff --git a/greenfloor-engine/src/daemon/run_once/tests.rs b/greenfloor-engine/src/daemon/run_once/tests.rs index febc05f6..5ce47a7b 100644 --- a/greenfloor-engine/src/daemon/run_once/tests.rs +++ b/greenfloor-engine/src/daemon/run_once/tests.rs @@ -25,7 +25,31 @@ fn compute_cycle_exit_code_non_zero_when_all_markets_fail() { cycle_error_count: 1, ..MarketDispatchMetrics::default() }; - assert_eq!(compute_cycle_exit_code(&plan, &metrics), 1); + assert_eq!(compute_cycle_exit_code(&plan, &metrics, 0), 1); +} + +#[test] +fn compute_cycle_exit_code_non_zero_on_partial_cycle_errors() { + let plan = CyclePlan { + enabled_market_ids: vec!["m1".to_string()], + selected_market_ids: vec!["m1".to_string()], + consumed_immediate_requeues: Vec::new(), + dispatch_state: DaemonDispatchState::default(), + stale_open_sweep: StaleSweepProgress::default(), + configured_market_slot_count: 1, + runtime_dry_run: false, + db_path: PathBuf::from("/tmp/db.sqlite"), + previous_xch_price_usd: None, + dexie_base_url: String::new(), + splash_base_url: String::new(), + test_controls: DaemonCycleTestControls::default(), + }; + let metrics = MarketDispatchMetrics { + markets_processed: 1, + cycle_error_count: 1, + ..MarketDispatchMetrics::default() + }; + assert_eq!(compute_cycle_exit_code(&plan, &metrics, 0), 1); } #[test] diff --git a/greenfloor-engine/src/daemon/strategy_phase.rs b/greenfloor-engine/src/daemon/strategy_phase.rs index 510f347f..ff3989be 100644 --- a/greenfloor-engine/src/daemon/strategy_phase.rs +++ b/greenfloor-engine/src/daemon/strategy_phase.rs @@ -66,6 +66,7 @@ pub async fn run_strategy_phase( } Err(err) => { state.record_phase_error(); + state.strategy_failed = true; write_store.sync(|store| { LogContext::MARKET_CYCLE.dual_audit( store, diff --git a/greenfloor-engine/src/daemon/watchlist/mod.rs b/greenfloor-engine/src/daemon/watchlist/mod.rs index f1220343..121adcf1 100644 --- a/greenfloor-engine/src/daemon/watchlist/mod.rs +++ b/greenfloor-engine/src/daemon/watchlist/mod.rs @@ -38,7 +38,10 @@ pub use time::RESEED_MEMPOOL_MAX_AGE_SECONDS; pub fn watchlist_offer_ids(store: &SqliteStore, market_id: &str) -> SignerResult> { let mut offer_ids = HashSet::default(); for row in store.list_offer_states(Some(market_id), 500)? { - if ReconcileState::parse(&row.state).is_ok_and(|state| state.is_watched_for_reconcile()) { + if row + .reconcile_state() + .is_ok_and(|state| state.is_watched_for_reconcile()) + { offer_ids.insert(row.offer_id); } } diff --git a/greenfloor-engine/src/error/mod.rs b/greenfloor-engine/src/error/mod.rs index c668cde3..df360475 100644 --- a/greenfloor-engine/src/error/mod.rs +++ b/greenfloor-engine/src/error/mod.rs @@ -1,5 +1,9 @@ use thiserror::Error; +mod transport; + +pub use transport::TransportError; + #[derive(Debug, Error)] pub enum SignerError { #[error("vault custody snapshot unavailable")] @@ -35,11 +39,11 @@ pub enum SignerError { #[error("kms error: {0}")] Kms(String), - #[error("coinset error: {0}")] - Coinset(String), + #[error(transparent)] + Transport(#[from] TransportError), - #[error("driver error: {0}")] - Driver(String), + #[error(transparent)] + Reconcile(#[from] crate::cycle::ReconcileStateError), #[error("unparseable cat lineage: {0}")] UnparseableCatLineage(String), @@ -239,13 +243,6 @@ pub enum SignerError { Other(String), } -fn is_parallel_dispatch_transient_class(exception_class: &str) -> bool { - matches!( - exception_class.trim(), - "ReservationContentionError" | "ManagedUpstreamTransientError" | "TimeoutError" - ) -} - fn is_transient_managed_upstream_error_text(error_text: &str) -> bool { const MARKERS: &[&str] = &[ "timed out", @@ -276,6 +273,27 @@ fn mixed_split_selected_coins_not_spendable_message(message: &str) -> bool { } impl SignerError { + /// Coinset HTTP/RPC transport failure. + #[must_use] + pub fn coinset(message: impl Into) -> Self { + Self::Transport(TransportError::Coinset(message.into())) + } + + /// Generic HTTP transport failure (`layer` names the client, e.g. `http` or `dexie`). + #[must_use] + pub fn http(layer: &'static str, message: impl Into) -> Self { + Self::Transport(TransportError::Http { + layer, + message: message.into(), + }) + } + + /// chia-wallet-sdk driver failure. + #[must_use] + pub fn driver(message: impl Into) -> Self { + Self::Transport(TransportError::Driver(message.into())) + } + #[must_use] pub fn is_mixed_split_selected_coins_not_spendable(&self) -> bool { matches!(self, Self::MixedSplitSelectedCoinsNotSpendable) @@ -307,12 +325,12 @@ impl SignerError { Self::ReservationContention(_) | Self::ManagedUpstreamTransient(_) | Self::DatabaseLocked => true, + Self::Transport(TransportError::Http { message, .. }) => { + is_transient_managed_upstream_error_text(message) + } Self::Other(message) => { let message = message.as_str(); message.contains("database is locked") - || is_parallel_dispatch_transient_class( - message.split(':').next().unwrap_or(message).trim(), - ) || is_transient_managed_upstream_error_text(message) } _ => false, @@ -324,7 +342,7 @@ pub type SignerResult = Result; #[must_use] pub fn driver_error(err: &chia_sdk_driver::DriverError) -> SignerError { - SignerError::Driver(err.to_string()) + SignerError::driver(err.to_string()) } impl From for SignerError { @@ -335,13 +353,13 @@ impl From for SignerError { impl From for SignerError { fn from(err: reqwest::Error) -> Self { - SignerError::Coinset(err.to_string()) + SignerError::http("http", err.to_string()) } } #[cfg(test)] mod tests { - use super::SignerError; + use super::{SignerError, TransportError}; #[test] fn signer_error_display_messages_are_stable() { @@ -378,6 +396,15 @@ mod tests { SignerError::ResolvedAssetsCollideForNonXchPair, "signer_asset_resolution_failed:resolved_assets_collide_for_non_xch_pair", ), + (SignerError::coinset("down"), "coinset error: down"), + ( + SignerError::http("dexie", "timeout"), + "http error (dexie): timeout", + ), + ( + SignerError::driver("invalid mod hash"), + "driver error: invalid mod hash", + ), ]; for (err, expected) in cases { assert_eq!(err.to_string(), expected); @@ -394,16 +421,11 @@ mod tests { } #[test] - fn parallel_dispatch_transient_matches_upstream_and_contention_classes() { - assert!(SignerError::Other("TimeoutError: timed out".to_string()) + fn parallel_dispatch_transient_matches_typed_contention_and_upstream() { + assert!(SignerError::ManagedUpstreamTransient("timeout".to_string()) .is_parallel_dispatch_transient()); assert!( - SignerError::Other("ManagedUpstreamTransientError: timeout".to_string()) - .is_parallel_dispatch_transient() - ); - assert!( - SignerError::Other("ReservationContentionError: busy".to_string()) - .is_parallel_dispatch_transient() + SignerError::ReservationContention("busy".to_string()).is_parallel_dispatch_transient() ); assert!( !SignerError::Other("PermanentOfferBuildFailure: bad puzzle".to_string()) @@ -429,9 +451,7 @@ mod tests { #[test] fn parallel_dispatch_transient_rejects_non_transient_variants() { - assert!( - !SignerError::Driver("invalid mod hash".to_string()).is_parallel_dispatch_transient() - ); + assert!(!SignerError::driver("invalid mod hash").is_parallel_dispatch_transient()); assert!(!SignerError::InsufficientCatCoins.is_parallel_dispatch_transient()); } @@ -457,7 +477,10 @@ mod tests { use chia_sdk_driver::DriverError; let mapped = driver_error(&DriverError::InvalidModHash); - assert!(matches!(mapped, SignerError::Driver(_))); + assert!(matches!( + mapped, + SignerError::Transport(TransportError::Driver(_)) + )); assert!(mapped.to_string().contains("invalid mod hash")); let from_impl: SignerError = DriverError::InvalidModHash.into(); diff --git a/greenfloor-engine/src/error/transport.rs b/greenfloor-engine/src/error/transport.rs new file mode 100644 index 00000000..52df3851 --- /dev/null +++ b/greenfloor-engine/src/error/transport.rs @@ -0,0 +1,15 @@ +use thiserror::Error; + +/// HTTP, Coinset, and wallet-sdk driver failures. +#[derive(Debug, Error)] +pub enum TransportError { + #[error("coinset error: {0}")] + Coinset(String), + #[error("http error ({layer}): {message}")] + Http { + layer: &'static str, + message: String, + }, + #[error("driver error: {0}")] + Driver(String), +} diff --git a/greenfloor-engine/src/hex/mod.rs b/greenfloor-engine/src/hex/mod.rs index 202e6f92..af7e64b0 100644 --- a/greenfloor-engine/src/hex/mod.rs +++ b/greenfloor-engine/src/hex/mod.rs @@ -3,11 +3,26 @@ mod bytes; mod clvm; -use crate::coinset::is_canonical_xch_asset; - pub use bytes::{fixed_bytes, hex_to_bytes, hex_to_bytes32, parse_coin_ids}; pub use clvm::{bytes32_to_hex, hex_to_tree_hash, tree_hash_nil, tree_hash_to_hex}; +/// Canonical XCH / TXCH asset identifiers. +/// +/// Empty/whitespace is **not** XCH. Use [`is_xch_like_asset`] at signer payload +/// boundaries where empty means native XCH. +#[must_use] +pub fn is_canonical_xch_asset(asset_id: &str) -> bool { + matches!( + asset_id.trim().to_ascii_lowercase().as_str(), + "xch" | "txch" | "1" + ) +} + +#[must_use] +pub fn is_xch_like_asset(asset_id: &str) -> bool { + asset_id.trim().is_empty() || is_canonical_xch_asset(asset_id) +} + const CANONICAL_XCH_MOJOS: i64 = 1_000_000_000_000; /// On-chain mojos per one CAT config/display unit. Fractional units are valid /// (e.g. `10.5` CAT = `10_500` mojos). @@ -144,4 +159,13 @@ mod tests { 1_000 ); } + + #[test] + fn recognizes_xch_like_assets() { + assert!(super::is_xch_like_asset("xch")); + assert!(super::is_xch_like_asset("TXCH")); + assert!(super::is_xch_like_asset("")); + assert!(!super::is_canonical_xch_asset("")); + assert!(!super::is_xch_like_asset(&"aa".repeat(32))); + } } diff --git a/greenfloor-engine/src/manager_cli/cats.rs b/greenfloor-engine/src/manager_cli/cats.rs index 64a4813b..d6cd3ea8 100644 --- a/greenfloor-engine/src/manager_cli/cats.rs +++ b/greenfloor-engine/src/manager_cli/cats.rs @@ -50,10 +50,11 @@ async fn lookup_dexie_cat_row( if !use_dexie_lookup { return Ok(None); } + let program = crate::config::load_program_config(&ctx.program_config)?; let dexie_base = resolve_dexie_base_url( network, ctx.dexie_base_url.as_deref(), - "https://api.dexie.space", + &program.dexie_api_base, )?; let dexie = DexieClient::new(dexie_base); let mut dexie_row = None; diff --git a/greenfloor-engine/src/manager_cli/cats_catalog.rs b/greenfloor-engine/src/manager_cli/cats_catalog.rs index b5fc6e38..6911e488 100644 --- a/greenfloor-engine/src/manager_cli/cats_catalog.rs +++ b/greenfloor-engine/src/manager_cli/cats_catalog.rs @@ -2,15 +2,9 @@ use serde_json::{json, Value as JsonValue}; -use crate::config::{build_cat_ticker_index_from_cats_rows, lookup_asset_id_from_ticker}; use crate::hex::normalize_hex_id; -pub use crate::config::{load_cats_catalog, write_cats_catalog}; - -pub fn resolve_asset_id_from_catalog(catalog: &[JsonValue], ticker: &str) -> Option { - let index = build_cat_ticker_index_from_cats_rows(catalog); - lookup_asset_id_from_ticker(&index, ticker).ok().flatten() -} +pub use crate::config::{load_cats_catalog, resolve_asset_id_from_catalog, write_cats_catalog}; pub fn derive_cat_metadata_from_dexie_row(row: Option<&JsonValue>) -> JsonValue { let Some(row) = row else { diff --git a/greenfloor-engine/src/manager_cli/coin_op_loop/combine.rs b/greenfloor-engine/src/manager_cli/coin_op_loop/combine.rs index 626c8203..6c5041ac 100644 --- a/greenfloor-engine/src/manager_cli/coin_op_loop/combine.rs +++ b/greenfloor-engine/src/manager_cli/coin_op_loop/combine.rs @@ -2,9 +2,9 @@ use crate::async_boundary::ManagerCommandFuture; use serde_json::json; use crate::coin_ops::evaluate_coin_combine_gate; +use crate::coin_ops::resolve_combine_count; use crate::error::{SignerError, SignerResult}; use crate::manager_cli::context::ManagerContext; -use crate::manager_cli::ladder::resolve_combine_count; use crate::offer::pricing::combine_threshold_count; use super::combine_iteration::{run_combine_iteration, CombineIterationParams}; diff --git a/greenfloor-engine/src/manager_cli/coin_op_loop/loop_context.rs b/greenfloor-engine/src/manager_cli/coin_op_loop/loop_context.rs index 74ddc634..075272df 100644 --- a/greenfloor-engine/src/manager_cli/coin_op_loop/loop_context.rs +++ b/greenfloor-engine/src/manager_cli/coin_op_loop/loop_context.rs @@ -1,10 +1,10 @@ //! Shared coin-op loop preparation for split and combine CLI commands. use crate::coin_ops::execution::CoinOpExecContext; +use crate::coin_ops::sell_ladder_entry_for_size; use crate::config::LadderEntry; use crate::error::SignerResult; use crate::manager_cli::context::ManagerContext; -use crate::manager_cli::ladder::sell_ladder_entry_for_size; use super::loop_common::validate_until_ready_mode; use super::until_ready::UntilReadyWaitMode; diff --git a/greenfloor-engine/src/manager_cli/coin_op_loop/split.rs b/greenfloor-engine/src/manager_cli/coin_op_loop/split.rs index 326447e9..bd55c0b6 100644 --- a/greenfloor-engine/src/manager_cli/coin_op_loop/split.rs +++ b/greenfloor-engine/src/manager_cli/coin_op_loop/split.rs @@ -5,9 +5,9 @@ use crate::coin_ops::evaluate_coin_split_gate; #[cfg(test)] use crate::coin_ops::execution::CoinOpTestOverrides; use crate::coin_ops::{coin_op_non_negative_u64, i64_to_usize}; +use crate::coin_ops::{resolve_split_targets, split_required_count}; use crate::error::{SignerError, SignerResult}; use crate::manager_cli::context::ManagerContext; -use crate::manager_cli::ladder::{resolve_split_targets, split_required_count}; use super::loop_common::finish_coin_op_command; use super::loop_context::{prepare_coin_op_loop_common, CoinOpLoopPrep}; diff --git a/greenfloor-engine/src/manager_cli/coins_balance.rs b/greenfloor-engine/src/manager_cli/coins_balance.rs index 4ec5064b..bb081138 100644 --- a/greenfloor-engine/src/manager_cli/coins_balance.rs +++ b/greenfloor-engine/src/manager_cli/coins_balance.rs @@ -1,50 +1,19 @@ //! `coins-balance`: vault-controlled CAT total (receive + known unreturned makers). -use std::collections::HashSet; - use serde_json::json; +use crate::coin_ops::vault_controlled_balance; use crate::coinset::{ client_for_signer_on_network, list_wallet_unspent_coins_for_signer, LiveCoinset, - OfferCoinsetBackend, }; use crate::config::{ load_gated_operator_market, GatedOperatorMarketLoadRequest, OperatorMarketCommand, }; -use crate::cycle::{OfferLifecycleState, ReconcileState}; use crate::error::{SignerError, SignerResult}; -use crate::hex::{hex_to_bytes32, normalize_hex_id}; use crate::manager_cli::asset_resolve::resolve_market_inventory_asset_id; use crate::manager_cli::context::ManagerContext; use crate::storage::{resolve_state_db_path, SqliteStore}; -#[must_use] -fn vault_controlled_total(receive_amount: u64, unreturned_amount: u64) -> u64 { - receive_amount.saturating_add(unreturned_amount) -} - -/// Prefer open/active rows when multiple `offer_state` rows share a maker coin id. -#[must_use] -fn unreturned_row_priority(state: Option<&ReconcileState>) -> u8 { - match state { - Some( - ReconcileState::Lifecycle( - OfferLifecycleState::Open - | OfferLifecycleState::RefreshDue - | OfferLifecycleState::MempoolObserved, - ) - | ReconcileState::PendingVisibility, - ) => 0, - Some(ReconcileState::Lifecycle(OfferLifecycleState::Expired)) => 1, - _ => 2, - } -} - -#[must_use] -fn cat_matches_asset_filter(cat_asset_id: &str, filter_asset_id: &str) -> bool { - normalize_hex_id(cat_asset_id) == normalize_hex_id(filter_asset_id) -} - /// Vault-controlled balance for one asset: receive inventory + known unreturned makers. /// /// # Errors @@ -89,70 +58,45 @@ pub async fn run_coins_balance( let db_path = resolve_state_db_path(&loaded.program.home_dir, mgr.state_db_override()); let store = SqliteStore::open(&db_path)?; - let mut makers: Vec<_> = store - .list_unreturned_presplit_makers(Some(&market.market_id))? - .into_iter() - .map(|row| { - let state = ReconcileState::parse(&row.state).ok(); - (row, state) - }) - .collect(); - makers.sort_by(|(a, a_state), (b, b_state)| { - unreturned_row_priority(a_state.as_ref()) - .cmp(&unreturned_row_priority(b_state.as_ref())) - .then_with(|| a.offer_id.cmp(&b.offer_id)) - }); - let coinset = client_for_signer_on_network(&loaded.signer, &loaded.operator_network)?; let backend = LiveCoinset(&coinset); + let balance = vault_controlled_balance( + &store, + &backend, + &market.market_id, + &list_asset_id, + receive_amount, + ) + .await?; - let mut seen_coins = HashSet::new(); - let mut unreturned_amount = 0u64; - let mut unreturned_coins = Vec::new(); - for (row, state) in makers { - let coin_id = normalize_hex_id(&row.cancel_input_coin_id); - if !seen_coins.insert(coin_id.clone()) { - continue; - } - let Ok(bytes) = hex_to_bytes32(&coin_id) else { - continue; - }; - let amount = match backend.fetch_offer_input_cat(bytes).await { - Ok(cat) => { - let maker_asset = hex::encode(cat.info.asset_id); - if !cat_matches_asset_filter(&maker_asset, &list_asset_id) { - continue; - } - cat.coin.amount - } - Err(SignerError::PresplitCoinNotFound) => continue, - Err(err) => return Err(err), - }; - unreturned_amount = unreturned_amount.saturating_add(amount); - unreturned_coins.push(json!({ - "coin_id": coin_id, - "amount": amount, - "fixed_delegated_puzzle_hash": normalize_hex_id(&row.fixed_delegated_puzzle_hash), - "offer_id": row.offer_id, - "state": row.state, - "size_base_units": row.size_base_units, - "reclaimable": state.as_ref().is_some_and(ReconcileState::is_ops_reclaimable), - })); - } + let unreturned_coins: Vec<_> = balance + .unreturned_coins + .iter() + .map(|coin| { + json!({ + "coin_id": coin.coin_id, + "amount": coin.amount, + "fixed_delegated_puzzle_hash": coin.fixed_delegated_puzzle_hash, + "offer_id": coin.offer_id, + "state": coin.state, + "size_base_units": coin.size_base_units, + "reclaimable": coin.reclaimable, + }) + }) + .collect(); - let vault_controlled_amount = vault_controlled_total(receive_amount, unreturned_amount); let payload = json!({ "op": "coins-balance", "network": loaded.operator_network, "market_id": market.market_id, "asset": list_asset_id, "receive_address": receive_address, - "receive_amount": receive_amount, - "receive_units": crate::coin_ops::cat_units_display_from_mojos(receive_amount), - "unreturned_amount": unreturned_amount, - "unreturned_units": crate::coin_ops::cat_units_display_from_mojos(unreturned_amount), - "vault_controlled_amount": vault_controlled_amount, - "vault_controlled_units": crate::coin_ops::cat_units_display_from_mojos(vault_controlled_amount), + "receive_amount": balance.receive_amount, + "receive_units": crate::coin_ops::cat_units_display_from_mojos(balance.receive_amount), + "unreturned_amount": balance.unreturned_amount, + "unreturned_units": crate::coin_ops::cat_units_display_from_mojos(balance.unreturned_amount), + "vault_controlled_amount": balance.vault_controlled_amount, + "vault_controlled_units": crate::coin_ops::cat_units_display_from_mojos(balance.vault_controlled_amount), "unreturned_coins": unreturned_coins, "note": "Open makers are listed with reclaimable=false; reclaim idle/expired via offers-reclaim-presplit.", }); @@ -162,8 +106,7 @@ pub async fn run_coins_balance( #[cfg(test)] mod tests { - use super::{cat_matches_asset_filter, unreturned_row_priority, vault_controlled_total}; - use crate::cycle::{OfferLifecycleState, ReconcileState}; + use crate::cycle::{unreturned_row_priority, OfferLifecycleState, ReconcileState}; fn priority_for(raw: &str) -> u8 { unreturned_row_priority(ReconcileState::parse(raw).ok().as_ref()) @@ -216,21 +159,6 @@ mod tests { assert_eq!(unreturned_row_priority(None), 2); } - #[test] - fn vault_controlled_sums_receive_and_unreturned() { - assert_eq!(vault_controlled_total(1_000, 2_000), 3_000); - assert_eq!(vault_controlled_total(u64::MAX, 1), u64::MAX); - } - - #[test] - fn unreturned_makers_filter_by_asset_id() { - let asset_a = "aa".repeat(32); - let asset_b = "bb".repeat(32); - assert!(cat_matches_asset_filter(&asset_a, &asset_a)); - assert!(cat_matches_asset_filter(&format!("0x{asset_a}"), &asset_a)); - assert!(!cat_matches_asset_filter(&asset_a, &asset_b)); - } - #[tokio::test] async fn run_coins_balance_empty_receive_and_no_unreturned() { use crate::manager_cli::test_support::{pop_json, ManagerContextBuilder}; diff --git a/greenfloor-engine/src/manager_cli/combine_market_cat_dust/combine_test_support/mod.rs b/greenfloor-engine/src/manager_cli/combine_market_cat_dust/combine_test_support/mod.rs index d5c54926..2251ab03 100644 --- a/greenfloor-engine/src/manager_cli/combine_market_cat_dust/combine_test_support/mod.rs +++ b/greenfloor-engine/src/manager_cli/combine_market_cat_dust/combine_test_support/mod.rs @@ -8,7 +8,7 @@ pub(super) use execute::{ pub(super) use executor::{test_combine_batch_executor, test_combine_batch_executor_with_asset}; pub(super) use sim::{dust_plan_from_scan_without_lineage, register_lineage_mocks_for_scan_coins}; -use super::jobs::CatDustJob; +use crate::coin_ops::CatDustJob; use crate::coinset::{resolve_coinset_endpoint, ResolvedCoinsetEndpoint}; pub(super) const RECEIVE_ADDRESS: &str = diff --git a/greenfloor-engine/src/manager_cli/combine_market_cat_dust/mod.rs b/greenfloor-engine/src/manager_cli/combine_market_cat_dust/mod.rs index aa29d9e3..8c9b2e4a 100644 --- a/greenfloor-engine/src/manager_cli/combine_market_cat_dust/mod.rs +++ b/greenfloor-engine/src/manager_cli/combine_market_cat_dust/mod.rs @@ -6,14 +6,13 @@ mod combine_test_support; mod execute; #[cfg(test)] mod execute_test; -mod jobs; #[cfg(test)] mod lineage_e2e_test; mod report; #[cfg(test)] mod report_test; -use jobs::{build_enabled_cat_jobs, CatDustJob}; +use crate::coin_ops::{build_enabled_cat_jobs, CatDustJob}; use report::{ finalize_job_report, list_failed_job_report, signer_blocked_job_report, CombineRunMode, }; diff --git a/greenfloor-engine/src/manager_cli/combine_market_cat_dust/report.rs b/greenfloor-engine/src/manager_cli/combine_market_cat_dust/report.rs index 1fa4df60..e6dd4bc3 100644 --- a/greenfloor-engine/src/manager_cli/combine_market_cat_dust/report.rs +++ b/greenfloor-engine/src/manager_cli/combine_market_cat_dust/report.rs @@ -1,7 +1,7 @@ use serde_json::{json, Value}; use super::batches::{preview_batches_report, DustBatchRunSelection}; -use super::jobs::CatDustJob; +use crate::coin_ops::CatDustJob; use crate::coinset::CoinSpentVerifyConfig; use crate::coinset::ResolvedCoinsetEndpoint; use crate::config::{ManagerProgramConfig, SignerConfig}; diff --git a/greenfloor-engine/src/manager_cli/commands/run/build_offer/mod.rs b/greenfloor-engine/src/manager_cli/commands/run/build_offer/mod.rs index 53e9afd7..6a0662dd 100644 --- a/greenfloor-engine/src/manager_cli/commands/run/build_offer/mod.rs +++ b/greenfloor-engine/src/manager_cli/commands/run/build_offer/mod.rs @@ -2,7 +2,7 @@ use crate::error::SignerResult; use crate::manager_cli::context::ManagerContext; use crate::manager_cli::util::require_market_selector; use crate::offer::operator::{ - build_and_post_offer, BuildAndPostOfferRequestParts, BuildAndPostRunOptions, + build_and_post_offer, BuildAndPostOfferRequest, BuildAndPostRunOptions, BuildAndPostVenueOptions, }; @@ -33,34 +33,32 @@ pub(crate) fn build_and_post_request( }; require_market_selector(market_id.as_deref(), pair.as_deref())?; - Ok( - crate::offer::operator::BuildAndPostOfferRequest::from_parts( - BuildAndPostOfferRequestParts { - program_path: ctx.program_config.clone(), - markets_path: ctx.markets_config.clone(), - testnet_markets_path: ctx.testnet_markets_path().map(std::path::Path::to_path_buf), - cats_path: Some(ctx.cats_config.clone()), - network: network.clone(), - market_id: market_id.clone(), - pair: pair.clone(), - size_base_units: *size_base_units, - repeat: *repeat, - publish_venue: venue.clone(), - dexie_base_url: dexie_base_url.clone().or(ctx.dexie_base_url.clone()), - splash_base_url: splash_base_url.clone(), - venue: BuildAndPostVenueOptions { - drop_only: !allow_take, - claim_rewards: *claim_rewards, - }, - run: BuildAndPostRunOptions { - dry_run: *dry_run, - persist_results: true, - }, - action_side: None, - maker_reuse: None, - }, - ), - ) + Ok(BuildAndPostOfferRequest { + program_path: ctx.program_config.clone(), + markets_path: ctx.markets_config.clone(), + testnet_markets_path: ctx.testnet_markets_path().map(std::path::Path::to_path_buf), + cats_path: Some(ctx.cats_config.clone()), + network: network.clone(), + market_id: market_id.clone(), + pair: pair.clone(), + size_base_units: *size_base_units, + repeat: *repeat, + publish_venue: venue.clone(), + dexie_base_url: dexie_base_url.clone().or(ctx.dexie_base_url.clone()), + splash_base_url: splash_base_url.clone(), + venue: BuildAndPostVenueOptions { + drop_only: !allow_take, + claim_rewards: *claim_rewards, + }, + run: BuildAndPostRunOptions { + dry_run: *dry_run, + persist_results: true, + }, + action_side: None, + maker_reuse: None, + #[cfg(test)] + test_overrides: crate::offer::operator::BuildOfferTestOverrides::default(), + }) } pub async fn run_command(command: ManagerCommands, ctx: &ManagerContext) -> SignerResult { diff --git a/greenfloor-engine/src/manager_cli/mod.rs b/greenfloor-engine/src/manager_cli/mod.rs index 753c2eca..9cbd5a77 100644 --- a/greenfloor-engine/src/manager_cli/mod.rs +++ b/greenfloor-engine/src/manager_cli/mod.rs @@ -12,7 +12,6 @@ mod dispatch; mod flag_groups; mod json; mod keys; -mod ladder; mod maintenance; mod offers; mod paths; diff --git a/greenfloor-engine/src/manager_cli/offers.rs b/greenfloor-engine/src/manager_cli/offers.rs index e4945ec4..3322bbb6 100644 --- a/greenfloor-engine/src/manager_cli/offers.rs +++ b/greenfloor-engine/src/manager_cli/offers.rs @@ -145,7 +145,7 @@ pub async fn run_offers_cancel_command( ) -> SignerResult { let bundle = load_program_bundle_gated(&ctx.program_config)?; let program = bundle.program; - let db_path = resolve_state_db_path(&program.home_dir, None); + let db_path = resolve_state_db_path(&program.home_dir, ctx.state_db_override()); let venue = args .venue .as_deref() diff --git a/greenfloor-engine/src/manager_cli/setup/doctor.rs b/greenfloor-engine/src/manager_cli/setup/doctor.rs index 436f7454..0c934c31 100644 --- a/greenfloor-engine/src/manager_cli/setup/doctor.rs +++ b/greenfloor-engine/src/manager_cli/setup/doctor.rs @@ -22,7 +22,23 @@ pub fn run_doctor(ctx: &ManagerContext) -> SignerResult { let state_db = ctx.state_db_override(); let testnet_markets_path = ctx.testnet_markets_path(); let program = load_program_config(program_path)?; - let markets = load_markets_config_with_overlay(markets_path, testnet_markets_path)?; + let markets = match load_markets_config_with_overlay(markets_path, testnet_markets_path) { + Ok(markets) => markets, + Err(err) => { + let db_path = resolve_state_db_path(&program.home_dir, state_db); + ctx.emit_json(&json!({ + "ok": false, + "program_config": program_path.display().to_string(), + "markets_config": markets_path.display().to_string(), + "state_db": db_path.display().to_string(), + "enabled_markets": 0, + "resolved_key_ids": Vec::::new(), + "warnings": Vec::::new(), + "problems": [format!("markets_config_error:{err}")], + }))?; + return Ok(2); + } + }; let mut problems = Vec::new(); let mut warnings = Vec::new(); let enabled_markets: Vec<_> = markets.markets.iter().filter(|m| m.enabled).collect(); @@ -132,7 +148,7 @@ mod tests { assert!(problems.iter().any(|problem| { problem .as_str() - .is_some_and(|text| text.contains("missing signer_key_id")) + .is_some_and(|text| text.contains("signer_key_id")) })); } diff --git a/greenfloor-engine/src/manager_cli/setup/validate.rs b/greenfloor-engine/src/manager_cli/setup/validate.rs index c58c719f..1d6f8c3b 100644 --- a/greenfloor-engine/src/manager_cli/setup/validate.rs +++ b/greenfloor-engine/src/manager_cli/setup/validate.rs @@ -1,4 +1,6 @@ -use crate::config::{load_markets_config_with_overlay, load_program_config}; +use crate::config::{ + build_cat_ticker_index, load_markets_config_with_overlay, load_program_config, +}; use crate::error::SignerResult; use crate::manager_cli::context::ManagerContext; @@ -9,6 +11,13 @@ pub fn validate_config(ctx: &ManagerContext, program_only: bool) -> SignerResult } let _markets = load_markets_config_with_overlay(&ctx.markets_config, ctx.testnet_markets_path())?; + if ctx.cats_config.exists() { + let _ = build_cat_ticker_index( + &ctx.cats_config, + &ctx.markets_config, + ctx.testnet_markets_path(), + )?; + } Ok(()) } @@ -26,6 +35,7 @@ pub fn run_config_validate(ctx: &ManagerContext, program_only: bool) -> SignerRe "ok": true, "program_config": program_path, "markets_config": ctx.markets_config.display().to_string(), + "cats_config": ctx.cats_config.display().to_string(), }))?; Ok(0) } diff --git a/greenfloor-engine/src/manager_cli/tests.rs b/greenfloor-engine/src/manager_cli/tests.rs index 6da79b59..8e21fdce 100644 --- a/greenfloor-engine/src/manager_cli/tests.rs +++ b/greenfloor-engine/src/manager_cli/tests.rs @@ -74,6 +74,7 @@ markets: base_symbol: A1 quote_asset: xch receive_address: xch1test + signer_key_id: key-1 pricing: min_price_quote_per_base: 0.0031 max_price_quote_per_base: 0.0038 @@ -121,6 +122,7 @@ markets: base_symbol: A1 quote_asset: xch receive_address: xch1test + signer_key_id: key-1 pricing: min_price_quote_per_base: 0.0031 max_price_quote_per_base: 0.0038 @@ -147,6 +149,7 @@ markets: base_symbol: A1 quote_asset: xch receive_address: xch1a + signer_key_id: key-1 pricing: { "side": "sell" } - id: m2 enabled: true @@ -154,6 +157,7 @@ markets: base_symbol: A1 quote_asset: xch receive_address: xch1b + signer_key_id: key-1 pricing: { "side": "sell" } "#, ) diff --git a/greenfloor-engine/src/manager_cli/vault_asset_trace.rs b/greenfloor-engine/src/manager_cli/vault_asset_trace.rs index c23c99aa..affa96f0 100644 --- a/greenfloor-engine/src/manager_cli/vault_asset_trace.rs +++ b/greenfloor-engine/src/manager_cli/vault_asset_trace.rs @@ -1,13 +1,11 @@ use serde::Serialize; use crate::cli_util::optional_str; -use crate::coinset::{puzzle_hash_hex_for_receive_address, resolve_coinset_endpoint}; +use crate::coinset::resolve_coinset_endpoint; use crate::config::{ load_markets_config_with_overlay, load_program_bundle_gated, operator_ticker_index_from_paths, - CatTickerIndex, MarketConfig, }; use crate::error::{SignerError, SignerResult}; -use crate::hex::normalize_hex_id; use crate::manager_cli::commands::ManagerCommands; use crate::manager_cli::context::ManagerContext; use crate::manager_cli::vault_scan::{ @@ -16,34 +14,9 @@ use crate::manager_cli::vault_scan::{ use crate::offer::OfferAssetResolver; use crate::offer::VaultTraceAssetKind; use crate::vault_coinset_scan::asset_trace::AssetTraceResult; -use crate::vault_coinset_scan::types::AssetTypeFilter; -use crate::vault_coinset_scan::{build_asset_trace, EmptyBatchStop, MemberDiscovery, ScanResult}; - -impl VaultTraceAssetKind { - #[must_use] - fn json_label(self) -> &'static str { - match self { - Self::Xch => "xch", - Self::Cat => "cat", - } - } - - #[must_use] - fn scan_asset_type(self) -> AssetTypeFilter { - match self { - Self::Xch => AssetTypeFilter::Xch, - Self::Cat => AssetTypeFilter::Cat, - } - } - - #[must_use] - fn scan_cat_asset_id(self, asset_id: &str) -> Option<&str> { - match self { - Self::Cat => Some(asset_id), - Self::Xch => None, - } - } -} +use crate::vault_coinset_scan::{ + build_asset_trace, cat_receive_hint_puzzle_hashes, MemberDiscovery, ScanResult, +}; pub struct VaultAssetTraceRequest<'a> { pub mgr: &'a ManagerContext, @@ -104,83 +77,7 @@ pub(crate) fn trace_payload( .map_err(|err| SignerError::Other(err.to_string())) } -/// Build the vault-asset-trace discovery plan. -/// -/// XCH: member-nonce walk only (default max 100). CAT: market receive-address hints by -/// default; optional `--max-nonce` adds an orphan member walk with always-on empty-batch stop. -fn vault_trace_member_discovery( - kind: VaultTraceAssetKind, - max_nonce: Option, - cat_hint_puzzle_hashes: Vec, -) -> SignerResult { - match kind { - VaultTraceAssetKind::Xch => Ok(MemberDiscovery::nonces(max_nonce.unwrap_or(100))), - VaultTraceAssetKind::Cat => match max_nonce { - None => { - if cat_hint_puzzle_hashes.is_empty() { - return Err(SignerError::Other( - "vault-asset-trace CAT path needs a market receive_address for the asset, \ - or pass --max-nonce N to scan vault member nonces" - .to_string(), - )); - } - Ok(MemberDiscovery::Hints { - puzzle_hashes: cat_hint_puzzle_hashes, - }) - } - Some(max_nonce) => Ok(MemberDiscovery::HintsThenNonces { - puzzle_hashes: cat_hint_puzzle_hashes, - max_nonce, - empty_batch_stop: EmptyBatchStop::Always, - }), - }, - } -} - -fn market_matches_cat_asset( - ticker_index: &CatTickerIndex, - market: &MarketConfig, - resolved_asset_id: &str, - requested_asset: &str, -) -> bool { - let requested = requested_asset.trim().to_ascii_lowercase(); - for label in [market.base_asset.as_str(), market.base_symbol.as_str()] { - if label.trim().to_ascii_lowercase() == requested { - return true; - } - if ticker_index.label_refers_to_asset(label, resolved_asset_id) { - return true; - } - } - false -} - -/// Collect unique receive p2 hashes for markets whose base matches the CAT asset. -fn cat_receive_hint_puzzle_hashes( - markets: &[MarketConfig], - ticker_index: &CatTickerIndex, - resolved_asset_id: &str, - requested_asset: &str, -) -> SignerResult> { - let resolved = normalize_hex_id(resolved_asset_id); - let mut hashes = Vec::new(); - let mut seen = std::collections::HashSet::new(); - for market in markets { - if !market_matches_cat_asset(ticker_index, market, &resolved, requested_asset) - || market.receive_address.trim().is_empty() - { - continue; - } - let hash = normalize_hex_id(&puzzle_hash_hex_for_receive_address( - &market.receive_address, - )?); - if !hash.is_empty() && seen.insert(hash.clone()) { - hashes.push(hash); - } - } - Ok(hashes) -} - +/// Run vault-asset-trace: XCH walks member nonces; CAT uses market receive-address hints. pub async fn run_vault_asset_trace(request: VaultAssetTraceRequest<'_>) -> SignerResult { let mgr = request.mgr; let bundle = load_program_bundle_gated(&mgr.program_config)?; @@ -216,8 +113,11 @@ pub async fn run_vault_asset_trace(request: VaultAssetTraceRequest<'_>) -> Signe } else { Vec::new() }; - let discovery = - vault_trace_member_discovery(resolved_asset.kind, request.max_nonce, cat_hints)?; + let discovery = MemberDiscovery::for_vault_asset_trace( + resolved_asset.kind.scan_asset_type(), + request.max_nonce, + cat_hints, + )?; let mut scan_params = manager_vault_scan_params( mgr, @@ -279,7 +179,6 @@ mod tests { use crate::manager_cli::vault_scan_sim::sim_dust_scan_result; use crate::vault_coinset_scan::build_asset_trace; use serde_json::json; - use std::collections::{HashMap, HashSet}; #[test] fn trace_payload_from_sim_scan_matches_manager_contract() { @@ -340,114 +239,4 @@ mod tests { .iter() .all(|chain| chain.get("reception_coin_id").is_none())); } - - #[test] - fn xch_discovery_is_nonce_walk_without_hints() { - let plan = vault_trace_member_discovery( - VaultTraceAssetKind::Xch, - None, - vec!["should-be-ignored".to_string()], - ) - .expect("xch plan"); - assert!(matches!( - plan, - MemberDiscovery::Nonces { max_nonce: 100, .. } - )); - assert!(plan.hint_puzzle_hashes().is_empty()); - } - - #[test] - fn cat_discovery_defaults_to_hints_only() { - let hashes = vec!["aa".repeat(32)]; - let plan = vault_trace_member_discovery(VaultTraceAssetKind::Cat, None, hashes.clone()) - .expect("cat hints"); - assert_eq!( - plan, - MemberDiscovery::Hints { - puzzle_hashes: hashes - } - ); - } - - #[test] - fn cat_discovery_without_hints_or_nonce_errors() { - let err = vault_trace_member_discovery(VaultTraceAssetKind::Cat, None, Vec::new()) - .expect_err("needs hints or max-nonce"); - assert!(err.to_string().contains("receive_address")); - } - - #[test] - fn cat_discovery_with_max_nonce_uses_hints_then_nonces() { - let hashes = vec!["aa".repeat(32)]; - let plan = vault_trace_member_discovery(VaultTraceAssetKind::Cat, Some(7), hashes.clone()) - .expect("cat plan"); - assert_eq!( - plan, - MemberDiscovery::HintsThenNonces { - puzzle_hashes: hashes, - max_nonce: 7, - empty_batch_stop: EmptyBatchStop::Always, - } - ); - } - - #[test] - fn market_matches_cat_asset_via_base_symbol() { - let asset_id = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; - let mut by_ticker = HashMap::new(); - by_ticker.insert("byc".to_string(), HashSet::from([asset_id.to_string()])); - let index = CatTickerIndex { - by_ticker, - symbols_by_asset_id: std::collections::BTreeMap::default(), - }; - let market = MarketConfig { - market_id: "m".to_string(), - enabled: true, - unique_maker_coins: true, - base_asset: "unrelated".to_string(), - base_symbol: "BYC".to_string(), - quote_asset: "xch".to_string(), - quote_asset_type: "volatile".to_string(), - receive_address: String::new(), - signer_key_id: "k".to_string(), - mode: "one_sided".to_string(), - pricing: crate::config::MarketPricing::default(), - cancel_move_threshold_bps: None, - ladders: HashMap::new(), - }; - assert!(market_matches_cat_asset(&index, &market, asset_id, "other")); - } - - #[test] - fn cat_receive_hints_match_ticker_market_when_operator_passes_asset_id() { - let asset_id = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; - let mut by_ticker = HashMap::new(); - by_ticker.insert("byc".to_string(), HashSet::from([asset_id.to_string()])); - let index = CatTickerIndex { - by_ticker, - symbols_by_asset_id: std::collections::BTreeMap::default(), - }; - let receive = "xch1a0t57qn6uhe7tzjlxlhwy2qgmuxvvft8gnfzmg5detg0q9f3yc3s2apz0h".to_string(); - let expected = - normalize_hex_id(&puzzle_hash_hex_for_receive_address(&receive).expect("receive p2")); - let market = MarketConfig { - market_id: "byc-xch".to_string(), - enabled: true, - unique_maker_coins: true, - base_asset: "BYC".to_string(), - base_symbol: "BYC".to_string(), - quote_asset: "xch".to_string(), - quote_asset_type: "volatile".to_string(), - receive_address: receive, - signer_key_id: "k".to_string(), - mode: "one_sided".to_string(), - pricing: crate::config::MarketPricing::default(), - cancel_move_threshold_bps: None, - ladders: HashMap::new(), - }; - - let hashes = - cat_receive_hint_puzzle_hashes(&[market], &index, asset_id, asset_id).expect("hints"); - assert_eq!(hashes, vec![expected]); - } } diff --git a/greenfloor-engine/src/offer/action.rs b/greenfloor-engine/src/offer/action.rs index cf479414..4f9dab24 100644 --- a/greenfloor-engine/src/offer/action.rs +++ b/greenfloor-engine/src/offer/action.rs @@ -53,7 +53,7 @@ pub struct BuildOfferForActionResult { pub expires_at_unix: u64, pub offer_amount: u64, pub request_amount: u64, - pub execution_mode: String, + pub execution_mode: crate::offer::OfferExecutionMode, #[serde(skip_serializing_if = "Option::is_none")] pub create_result: Option, } @@ -165,7 +165,7 @@ pub async fn build_signer_offer_for_action( expires_at_unix, offer_amount: leg.offer_amount_mojos, request_amount: leg.request_amount_mojos, - execution_mode: create_result.execution_mode.to_string(), + execution_mode: create_result.execution_mode, create_result: Some(create_result), }) } diff --git a/greenfloor-engine/src/offer/assets.rs b/greenfloor-engine/src/offer/assets.rs index 0dfdf108..bf38633b 100644 --- a/greenfloor-engine/src/offer/assets.rs +++ b/greenfloor-engine/src/offer/assets.rs @@ -6,6 +6,7 @@ use crate::coinset::{is_xch_like_asset, lookup_asset_by_symbol}; use crate::config::{lookup_asset_id_from_ticker, resolve_quote_asset_for_offer, CatTickerIndex}; use crate::config::{MarketConfig, SignerConfig}; use crate::error::{SignerError, SignerResult}; +use crate::vault_coinset_scan::types::AssetTypeFilter; /// Resolved on-chain asset ids for a configured market row (offer build / reservations). #[derive(Debug, Clone, PartialEq, Eq)] @@ -23,6 +24,35 @@ pub enum VaultTraceAssetKind { Cat, } +impl VaultTraceAssetKind { + /// JSON `asset_kind` label for vault-asset-trace payloads. + #[must_use] + pub fn json_label(self) -> &'static str { + match self { + Self::Xch => "xch", + Self::Cat => "cat", + } + } + + /// Coinset scan asset-type filter for this kind. + #[must_use] + pub fn scan_asset_type(self) -> AssetTypeFilter { + match self { + Self::Xch => AssetTypeFilter::Xch, + Self::Cat => AssetTypeFilter::Cat, + } + } + + /// CAT asset id to pass into a vault scan, or `None` for XCH. + #[must_use] + pub fn scan_cat_asset_id(self, asset_id: &str) -> Option<&str> { + match self { + Self::Cat => Some(asset_id), + Self::Xch => None, + } + } +} + /// Resolved asset for `vault-asset-trace`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ResolvedVaultTraceAsset { diff --git a/greenfloor-engine/src/offer/build.rs b/greenfloor-engine/src/offer/build.rs index 8c7b65aa..ca7f78d0 100644 --- a/greenfloor-engine/src/offer/build.rs +++ b/greenfloor-engine/src/offer/build.rs @@ -70,7 +70,6 @@ pub(crate) async fn build_vault_cat_offer_with_spend( ) .await } - OfferPlan::RequiresSplitFlag => Err(crate::error::SignerError::OfferInputRequiresPresplit), OfferPlan::SplitAndOffer { selection, offer_nonce, diff --git a/greenfloor-engine/src/offer/cancel_input.rs b/greenfloor-engine/src/offer/cancel_input.rs index 3fe063cf..f3fccbe5 100644 --- a/greenfloor-engine/src/offer/cancel_input.rs +++ b/greenfloor-engine/src/offer/cancel_input.rs @@ -7,7 +7,9 @@ use crate::offer::presplit::{ offer_maker_cat_from_coin_input, presplit_binding_from_coin_input, resolve_member_fixed_conditions_hash_for_binding, PresplitBindingLookup, }; -use crate::offer::types::{OfferCancelFields, OfferExecutionMode, StoredOfferCancelMetadata}; +use crate::offer::types::{ + OfferCancelFields, OfferExecutionMode, PostedOfferShape, StoredOfferCancelMetadata, +}; use crate::vault::members::p2_conditions_or_singleton_puzzle_hash; use crate::vault::spend::VaultSpendContext; use chia_protocol::{Bytes32, Coin, SpendBundle}; @@ -74,7 +76,7 @@ pub(crate) fn stored_presplit_fields( metadata: Option<&StoredOfferCancelMetadata>, ) -> Option<&OfferCancelFields> { let metadata = metadata?; - if !metadata.is_presplit_like() { + if !PostedOfferShape::from_metadata(metadata).is_presplit() { return None; } let hash = metadata diff --git a/greenfloor-engine/src/offer/codec.rs b/greenfloor-engine/src/offer/codec.rs index 23bef458..140d6e0f 100644 --- a/greenfloor-engine/src/offer/codec.rs +++ b/greenfloor-engine/src/offer/codec.rs @@ -7,7 +7,7 @@ use chia_traits::Streamable; use clvm_traits::FromClvm; use clvmr::{serde::node_from_bytes, Allocator, NodePtr}; -use crate::error::{SignerError, SignerResult}; +use crate::error::{SignerError, SignerResult, TransportError}; use crate::hex::normalize_hex_id; type RequestedXchPayments = Vec<(Vec, Vec<(Vec, u64)>)>; @@ -43,13 +43,13 @@ fn parse_expires_at_seconds_from_coin_spend( ) -> SignerResult> { let mut allocator = Allocator::new(); let puzzle = node_from_bytes(&mut allocator, coin_spend.puzzle_reveal.as_ref()) - .map_err(|err| SignerError::Driver(err.to_string()))?; + .map_err(|err| SignerError::driver(err.to_string()))?; let solution = node_from_bytes(&mut allocator, coin_spend.solution.as_ref()) - .map_err(|err| SignerError::Driver(err.to_string()))?; + .map_err(|err| SignerError::driver(err.to_string()))?; let output = run_puzzle(&mut allocator, puzzle, solution) - .map_err(|err| SignerError::Driver(err.to_string()))?; + .map_err(|err| SignerError::driver(err.to_string()))?; let conditions = Conditions::::from_clvm(&allocator, output) - .map_err(|err| SignerError::Driver(err.to_string()))?; + .map_err(|err| SignerError::driver(err.to_string()))?; for condition in conditions.iter() { if let Some(seconds) = expires_at_seconds_from_condition(condition) { return Ok(Some(seconds)); @@ -63,13 +63,13 @@ fn coin_spend_has_expiration_condition( ) -> SignerResult { let mut allocator = Allocator::new(); let puzzle = node_from_bytes(&mut allocator, coin_spend.puzzle_reveal.as_ref()) - .map_err(|err| SignerError::Driver(err.to_string()))?; + .map_err(|err| SignerError::driver(err.to_string()))?; let solution = node_from_bytes(&mut allocator, coin_spend.solution.as_ref()) - .map_err(|err| SignerError::Driver(err.to_string()))?; + .map_err(|err| SignerError::driver(err.to_string()))?; let output = run_puzzle(&mut allocator, puzzle, solution) - .map_err(|err| SignerError::Driver(err.to_string()))?; + .map_err(|err| SignerError::driver(err.to_string()))?; let conditions = Conditions::::from_clvm(&allocator, output) - .map_err(|err| SignerError::Driver(err.to_string()))?; + .map_err(|err| SignerError::driver(err.to_string()))?; for condition in conditions.iter() { if condition_has_offer_expiration(condition) { return Ok(true); @@ -175,7 +175,9 @@ fn dexie_verify_error_code(err: SignerError) -> String { "wallet_sdk_offer_duplicate_spent_coin_ids".to_string() } SignerError::OfferMissingExpiration => "wallet_sdk_offer_missing_expiration".to_string(), - SignerError::Driver(msg) => format!("wallet_sdk_offer_validate_failed:driver:{msg}"), + SignerError::Transport(TransportError::Driver(msg)) => { + format!("wallet_sdk_offer_validate_failed:driver:{msg}") + } SignerError::Other(msg) => format!("wallet_sdk_offer_validate_failed:other:{msg}"), err => format!("wallet_sdk_offer_validate_failed:{err}"), } diff --git a/greenfloor-engine/src/offer/invariants.rs b/greenfloor-engine/src/offer/invariants.rs index a4049da2..5d7f97b1 100644 --- a/greenfloor-engine/src/offer/invariants.rs +++ b/greenfloor-engine/src/offer/invariants.rs @@ -24,13 +24,13 @@ pub fn assert_presplit_offer_fast_forward_eligible( for coin_spend in &spend_bundle.coin_spends { let mut allocator = Allocator::new(); let puzzle = node_from_bytes(&mut allocator, coin_spend.puzzle_reveal.as_ref()) - .map_err(|err| SignerError::Driver(err.to_string()))?; + .map_err(|err| SignerError::driver(err.to_string()))?; let solution = node_from_bytes(&mut allocator, coin_spend.solution.as_ref()) - .map_err(|err| SignerError::Driver(err.to_string()))?; + .map_err(|err| SignerError::driver(err.to_string()))?; let output = run_puzzle(&mut allocator, puzzle, solution) - .map_err(|err| SignerError::Driver(err.to_string()))?; + .map_err(|err| SignerError::driver(err.to_string()))?; let conditions = Conditions::::from_clvm(&allocator, output) - .map_err(|err| SignerError::Driver(err.to_string()))?; + .map_err(|err| SignerError::driver(err.to_string()))?; for condition in conditions.iter() { match condition { Condition::AggSigMe(_) diff --git a/greenfloor-engine/src/offer/lifecycle/cancel/broadcast/tests.rs b/greenfloor-engine/src/offer/lifecycle/cancel/broadcast/tests.rs index e09cdc4c..58d23f2f 100644 --- a/greenfloor-engine/src/offer/lifecycle/cancel/broadcast/tests.rs +++ b/greenfloor-engine/src/offer/lifecycle/cancel/broadcast/tests.rs @@ -126,7 +126,7 @@ fn tracked_broadcast_failure_defaults_prior_state_to_open() { let offer_id = target.offer_id().to_string(); let cancel_tx = "ef".repeat(32); store - .upsert_offer_state(&offer_id, "m1", "pending", None) + .upsert_offer_state(&offer_id, "m1", "open", None) .expect("seed"); store .prepare_offer_cancel_submitted(&offer_id, "m1", &cancel_tx, None) diff --git a/greenfloor-engine/src/offer/lifecycle/cancel/build.rs b/greenfloor-engine/src/offer/lifecycle/cancel/build.rs index 31fe761d..df0f49cc 100644 --- a/greenfloor-engine/src/offer/lifecycle/cancel/build.rs +++ b/greenfloor-engine/src/offer/lifecycle/cancel/build.rs @@ -3,7 +3,7 @@ use crate::coinset::{client_for_signer_on_network, spend_bundle_operation_id, Li use crate::config::SignerConfig; use crate::error::{SignerError, SignerResult}; use crate::offer::cancel_input::metadata_sufficient_for_coinset_cancel; -use crate::offer::dexie_payload::DexieOfferPayload; +use crate::offer::lifecycle::reconcile_prep::fetch_dexie_offer_file_text; use crate::offer::reclaim::{ build_offer_cancel_spend_bundle, build_offer_cancel_spend_bundle_from_metadata, }; @@ -33,18 +33,6 @@ fn missing_cancel_input_error() -> SignerError { ) } -async fn fetch_dexie_offer_file_text(dexie: &DexieClient, offer_id: &str) -> SignerResult { - let response = dexie.get_offer(offer_id).await?; - if response.is_explicit_failure() { - return Err(SignerError::OfferCancelOfferFileNotFound); - } - let payload = DexieOfferPayload::new(response.into_value()); - payload - .offer_file_text() - .map(str::to_string) - .ok_or(SignerError::OfferCancelOfferFileMissing) -} - /// Local text → metadata-sufficient (no blob) → optional Dexie offer-file fallback. async fn resolve_cancel_input<'a>( offer_id: &str, diff --git a/greenfloor-engine/src/offer/lifecycle/cancel_context.rs b/greenfloor-engine/src/offer/lifecycle/cancel_context.rs index 697f5b4a..28a94627 100644 --- a/greenfloor-engine/src/offer/lifecycle/cancel_context.rs +++ b/greenfloor-engine/src/offer/lifecycle/cancel_context.rs @@ -149,7 +149,8 @@ pub fn preload_cancel_submitted_contexts( let cancel_rows: Vec<&OfferStateListRow> = rows .iter() .filter(|row| { - ReconcileState::parse(&row.state).is_ok_and(|state| state.is_cancel_submitted()) + row.reconcile_state() + .is_ok_and(|state| state.is_cancel_submitted()) }) .collect(); if cancel_rows.is_empty() { @@ -190,7 +191,10 @@ pub fn cancel_submitted_context_for_offer( let Some(row) = rows.into_iter().next() else { return Ok(None); }; - if !ReconcileState::parse(&row.state).is_ok_and(|state| state.is_cancel_submitted()) { + if !row + .reconcile_state() + .is_ok_and(|state| state.is_cancel_submitted()) + { return Ok(None); } let tx_signals = match row diff --git a/greenfloor-engine/src/offer/lifecycle/cancel_eligibility.rs b/greenfloor-engine/src/offer/lifecycle/cancel_eligibility.rs index dbeab5dc..753ad750 100644 --- a/greenfloor-engine/src/offer/lifecycle/cancel_eligibility.rs +++ b/greenfloor-engine/src/offer/lifecycle/cancel_eligibility.rs @@ -2,7 +2,6 @@ use std::collections::{HashMap, HashSet}; -use crate::cycle::ReconcileState; use crate::error::SignerResult; use crate::offer::dexie_payload::DEXIE_STATUS_OPEN; use crate::storage::{OfferStateListRow, SqliteStore}; @@ -16,7 +15,8 @@ pub fn dexie_status_open_for_cancel(status: i64) -> bool { /// Whether a persisted offer row is eligible for cancel selection. #[must_use] pub fn row_cancel_eligible(row: &OfferStateListRow) -> bool { - ReconcileState::parse(&row.state).is_ok_and(|state| state.is_cancel_eligible()) + row.reconcile_state() + .is_ok_and(|state| state.is_cancel_eligible()) } /// Filter cancel-eligible rows into target offer ids. diff --git a/greenfloor-engine/src/offer/lifecycle/market_reconcile/augment.rs b/greenfloor-engine/src/offer/lifecycle/market_reconcile/augment.rs index d6a5a5c3..35610928 100644 --- a/greenfloor-engine/src/offer/lifecycle/market_reconcile/augment.rs +++ b/greenfloor-engine/src/offer/lifecycle/market_reconcile/augment.rs @@ -14,7 +14,7 @@ use super::super::dexie_index::index_list_offers_by_local_ids; use super::super::reconcile_prep::{ ensure_watches_from_dexie_payload, fetch_dexie_offer, DexieFetchMode, DexieOfferFetch, }; -use super::super::{persist_missing_watched_offer, ReconcilePersistOptions}; +use super::super::{ReconcilePersistOptions, WatchedOfferReconciler}; use super::transition::{note_reconcile_transition_side_effects, ReconcileMarketCycleMetrics}; pub struct AugmentedDexieOffers { @@ -33,16 +33,15 @@ fn apply_missing_watched_offer( let current_state = state_by_offer_id .get(watched_offer_id) .map_or("open", String::as_str); - let transition = persist_missing_watched_offer( - store, + let options = ReconcilePersistOptions { + action: "reconcile_coins_and_offers", + venue: Some(crate::config::Venue::Dexie), + dexie_error: Some(error_text), + }; + let transition = WatchedOfferReconciler::new(store, &options).apply_missing( market_id, watched_offer_id, current_state, - &ReconcilePersistOptions { - action: "reconcile_coins_and_offers", - venue: Some(crate::config::Venue::Dexie), - dexie_error: Some(error_text), - }, )?; note_reconcile_transition_side_effects( &transition, diff --git a/greenfloor-engine/src/offer/lifecycle/market_reconcile/cycle.rs b/greenfloor-engine/src/offer/lifecycle/market_reconcile/cycle.rs index 78dd032d..e87fe42a 100644 --- a/greenfloor-engine/src/offer/lifecycle/market_reconcile/cycle.rs +++ b/greenfloor-engine/src/offer/lifecycle/market_reconcile/cycle.rs @@ -14,7 +14,7 @@ use crate::storage::SqliteStore; use super::super::dexie_index::{build_dexie_size_by_offer_id, dexie_status_index}; use super::super::reconcile_prep::{fetch_and_ensure_watches, prepare_market_reconcile_local}; -use super::super::{apply_cancel_submitted_rows, ReconcilePersistOptions}; +use super::super::{ReconcilePersistOptions, WatchedOfferReconciler}; use super::augment::{augment_dexie_offers_for_watchlist, dexie_watch_error_dual_audit}; use super::transition::{apply_dexie_lifecycle_transitions, ReconcileMarketCycleMetrics}; @@ -62,16 +62,13 @@ pub async fn run_reconcile_market_cycle( // One scan: cancel-submitted rows, local metadata heal, Dexie roles, state map. let local = prepare_market_reconcile_local(store, market_id)?; - apply_cancel_submitted_rows( - store, - &local.cancel_submitted_rows, - &ReconcilePersistOptions { - action: "cancel_submitted_orphan_reconcile", - venue: None, - dexie_error: None, - }, - Utc::now(), - )?; + let cancel_options = ReconcilePersistOptions { + action: "cancel_submitted_orphan_reconcile", + venue: None, + dexie_error: None, + }; + WatchedOfferReconciler::new(store, &cancel_options) + .apply_cancel_submitted(&local.cancel_submitted_rows, Utc::now())?; if !local.dexie.needs_dexie_http() { return Ok(ReconcileMarketCycleResult::idle(metrics)); } diff --git a/greenfloor-engine/src/offer/lifecycle/market_reconcile/transition.rs b/greenfloor-engine/src/offer/lifecycle/market_reconcile/transition.rs index c73c35b4..d449a679 100644 --- a/greenfloor-engine/src/offer/lifecycle/market_reconcile/transition.rs +++ b/greenfloor-engine/src/offer/lifecycle/market_reconcile/transition.rs @@ -9,8 +9,8 @@ use crate::error::SignerResult; use crate::storage::SqliteStore; use super::super::{ - apply_watched_offer_from_dexie_payload, preload_cancel_submitted_contexts, - ReconcilePersistOptions, WatchedOfferTransitionEnv, + preload_cancel_submitted_contexts, ReconcilePersistOptions, WatchedOfferReconciler, + WatchedOfferTransitionEnv, }; /// Outcomes from market reconcile apply (errors + requeue hints for the daemon cycle). @@ -64,20 +64,14 @@ pub(crate) fn apply_dexie_lifecycle_transitions( dexie_error: None, }; let env = WatchedOfferTransitionEnv::at_now(Some(&cancel_submitted_by_offer)); + let reconciler = WatchedOfferReconciler::new(store, &options); for (local_offer_id, raw) in by_local_id { let current_state = state_by_offer_id .get(local_offer_id) .map_or("open", String::as_str); - let (transition, _) = apply_watched_offer_from_dexie_payload( - store, - market_id, - local_offer_id, - current_state, - raw, - env, - &options, - )?; + let (transition, _) = + reconciler.apply_dexie_payload(market_id, local_offer_id, current_state, raw, env)?; note_reconcile_transition_side_effects( &transition, local_offer_id, diff --git a/greenfloor-engine/src/offer/lifecycle/mod.rs b/greenfloor-engine/src/offer/lifecycle/mod.rs index f28394a5..29f77877 100644 --- a/greenfloor-engine/src/offer/lifecycle/mod.rs +++ b/greenfloor-engine/src/offer/lifecycle/mod.rs @@ -53,11 +53,7 @@ pub use reconcile_watched_offers::{ reconcile_offers_batch, reconcile_offers_cli, ReconcileBatchItem, ReconcileBatchResult, ReconcileCliResult, }; -pub use signal_apply::{ - apply_cancel_submitted_rows, apply_signals_to_row, apply_watched_offer_from_dexie_payload, - apply_watched_offer_signals, persist_missing_watched_offer, - persist_resolved_watched_transition, -}; +pub use signal_apply::WatchedOfferReconciler; pub use status_cli::{ offers_status_cli, OfferStatusAuditEvent, OfferStatusRow, OffersStatusCliResult, }; diff --git a/greenfloor-engine/src/offer/lifecycle/persist.rs b/greenfloor-engine/src/offer/lifecycle/persist.rs index d21a0c7b..49c207b1 100644 --- a/greenfloor-engine/src/offer/lifecycle/persist.rs +++ b/greenfloor-engine/src/offer/lifecycle/persist.rs @@ -29,20 +29,20 @@ pub fn persist_offer_lifecycle_transition( ) -> SignerResult<()> { if transition.new_state.is_terminal() { store.immediate_transaction("offer_lifecycle_terminal", |store| { - store.upsert_offer_state( + store.upsert_offer_reconcile_state( offer_id, market_id, - &transition.new_state.as_str(), + &transition.new_state, last_seen_status, )?; store.clear_offer_coin_watches(offer_id)?; Ok(()) })?; } else { - store.upsert_offer_state( + store.upsert_offer_reconcile_state( offer_id, market_id, - &transition.new_state.as_str(), + &transition.new_state, last_seen_status, )?; } diff --git a/greenfloor-engine/src/offer/lifecycle/reconcile_prep/dexie_fetch.rs b/greenfloor-engine/src/offer/lifecycle/reconcile_prep/dexie_fetch.rs index 13e44a59..7c48aa2e 100644 --- a/greenfloor-engine/src/offer/lifecycle/reconcile_prep/dexie_fetch.rs +++ b/greenfloor-engine/src/offer/lifecycle/reconcile_prep/dexie_fetch.rs @@ -2,8 +2,10 @@ use serde_json::Value; +use crate::adapters::is_dexie_offer_missing_error_text; use crate::adapters::DexieClient; -use crate::cycle::is_dexie_offer_missing_error_text; +use crate::error::{SignerError, SignerResult}; +use crate::offer::dexie_payload::DexieOfferPayload; use super::super::dexie_index::offer_matches_local_id; use super::super::transition::missing_offer_error_from_payload; @@ -78,6 +80,43 @@ pub async fn fetch_dexie_offer( } } +impl DexieOfferFetch { + /// Offer-file bech32 from a found Dexie payload (`offer_file` / nested offer text). + #[must_use] + pub fn offer_file_text(&self) -> Option { + match self { + Self::Found(value) => DexieOfferPayload::new(value.clone()) + .offer_file_text() + .map(str::to_string), + Self::Missing(_) | Self::Mismatch | Self::LookupError(_) => None, + } + } + + /// Whether publish visibility polling can treat this lookup as "offer exists". + #[must_use] + pub fn visibility_ready(&self) -> bool { + matches!(self, Self::Found(_)) + } +} + +/// Fetch Dexie offer-file text for cancel fallback (one parse path with [`fetch_dexie_offer`]). +pub async fn fetch_dexie_offer_file_text( + dexie: &DexieClient, + offer_id: &str, +) -> SignerResult { + let fetched = fetch_dexie_offer(dexie, offer_id, DexieFetchMode::LifecycleLoose).await; + if let Some(text) = fetched.offer_file_text() { + return Ok(text); + } + match fetched { + DexieOfferFetch::Found(_) => Err(SignerError::OfferCancelOfferFileMissing), + DexieOfferFetch::Missing(_) | DexieOfferFetch::Mismatch => { + Err(SignerError::OfferCancelOfferFileNotFound) + } + DexieOfferFetch::LookupError(err) => Err(SignerError::Other(err)), + } +} + #[cfg(test)] mod tests { use super::*; @@ -137,4 +176,14 @@ mod tests { DexieOfferFetch::Found(_) )); } + + #[test] + fn found_fetch_exposes_offer_file_text_and_visibility() { + let offer_text = "offer1qq"; + let fetch = DexieOfferFetch::Found(json!({"offer": offer_text, "id": "ab"})); + assert!(fetch.visibility_ready()); + assert_eq!(fetch.offer_file_text().as_deref(), Some(offer_text)); + assert!(!DexieOfferFetch::Mismatch.visibility_ready()); + assert!(DexieOfferFetch::Mismatch.offer_file_text().is_none()); + } } diff --git a/greenfloor-engine/src/offer/lifecycle/reconcile_prep/fetch_apply.rs b/greenfloor-engine/src/offer/lifecycle/reconcile_prep/fetch_apply.rs deleted file mode 100644 index 0d760685..00000000 --- a/greenfloor-engine/src/offer/lifecycle/reconcile_prep/fetch_apply.rs +++ /dev/null @@ -1,61 +0,0 @@ -//! Fetch a watched Dexie offer and persist the resolved lifecycle transition. - -use crate::adapters::DexieClient; -use crate::cycle::CycleOfferTransition; -use crate::error::SignerResult; -use crate::storage::SqliteStore; - -use super::super::persist::ReconcilePersistOptions; -use super::super::signal_apply::persist_resolved_watched_transition; -use super::super::transition::{ - resolve_watched_offer_transition_from_dexie_fetch, WatchedOfferTransitionEnv, -}; - -/// Resolve a watched offer via Dexie `get_offer` and persist the transition. -/// -/// Composes [`resolve_watched_offer_transition_from_dexie_fetch`] with the shared -/// persist helpers used by CLI batch reconcile and daemon augment. -/// -/// # Errors -/// -/// Returns an error if Dexie lookup, transition resolve, or `SQLite` persist fails. -pub async fn fetch_and_apply_watched_offer( - store: &SqliteStore, - dexie: &DexieClient, - market_id: &str, - offer_id: &str, - current_state: &str, - env: WatchedOfferTransitionEnv<'_>, - options: &ReconcilePersistOptions<'_>, -) -> SignerResult<(CycleOfferTransition, Option)> { - let (transition, status, dexie_error) = resolve_watched_offer_transition_from_dexie_fetch( - store, - dexie, - offer_id, - current_state, - env, - ) - .await?; - let persist_options = if let Some(error_text) = dexie_error.as_deref() { - ReconcilePersistOptions { - action: options.action, - venue: options.venue, - dexie_error: Some(error_text), - } - } else { - ReconcilePersistOptions { - action: options.action, - venue: options.venue, - dexie_error: options.dexie_error, - } - }; - persist_resolved_watched_transition( - store, - market_id, - offer_id, - &transition, - status, - &persist_options, - )?; - Ok((transition, status)) -} diff --git a/greenfloor-engine/src/offer/lifecycle/reconcile_prep/mod.rs b/greenfloor-engine/src/offer/lifecycle/reconcile_prep/mod.rs index 3008ee21..0d72a894 100644 --- a/greenfloor-engine/src/offer/lifecycle/reconcile_prep/mod.rs +++ b/greenfloor-engine/src/offer/lifecycle/reconcile_prep/mod.rs @@ -2,14 +2,15 @@ //! //! Local metadata heal, Dexie watch roles, and metrics-agnostic Dexie fetch live here. //! Daemon market reconcile wraps watch heal with cycle metrics; CLI batch reconcile uses -//! [`fetch_and_apply_watched_offer`] without heal or cancel-orphan prep. +//! [`crate::offer::lifecycle::WatchedOfferReconciler::fetch_and_apply`] without heal or +//! cancel-orphan prep. mod dexie_fetch; -mod fetch_apply; mod watch_plan; -pub use dexie_fetch::{fetch_dexie_offer, DexieFetchMode, DexieOfferFetch}; -pub use fetch_apply::fetch_and_apply_watched_offer; +pub use dexie_fetch::{ + fetch_dexie_offer, fetch_dexie_offer_file_text, DexieFetchMode, DexieOfferFetch, +}; pub use watch_plan::{ ensure_watches_from_dexie_payload, fetch_and_ensure_watches, prepare_market_reconcile_local, }; diff --git a/greenfloor-engine/src/offer/lifecycle/reconcile_prep/watch_plan.rs b/greenfloor-engine/src/offer/lifecycle/reconcile_prep/watch_plan.rs index 14d21442..8b07eb94 100644 --- a/greenfloor-engine/src/offer/lifecycle/reconcile_prep/watch_plan.rs +++ b/greenfloor-engine/src/offer/lifecycle/reconcile_prep/watch_plan.rs @@ -11,8 +11,8 @@ use crate::adapters::DexieClient; use crate::coinset::extract_maker_watch_keys_from_offer_text; use crate::cycle::ReconcileState; use crate::error::SignerResult; -use crate::hex::normalize_hex_id; use crate::offer::dexie_payload::{extract_coin_ids_from_offer_payload, DexieOfferPayload}; +use crate::offer::maker_shape::MakerWatchSeed; use crate::storage::OfferStateListRow; use crate::storage::SqliteStore; @@ -64,35 +64,11 @@ fn heal_watches_from_local_metadata( let Some(meta) = store.offer_cancel_metadata_for_id(offer_id)? else { return Ok(false); }; - let mut coins = Vec::new(); - let mut p2s = Vec::new(); - if let Some(coin) = meta - .fields - .input_coin_id - .as_deref() - .map(normalize_hex_id) - .filter(|value| value.len() == 64) - { - coins.push(coin); + let seed = MakerWatchSeed::from_metadata(&meta); + if !seed.coin_ids.is_empty() || !seed.p2s.is_empty() { + store.ensure_offer_coin_watches(offer_id, market_id, &seed.coin_ids, &seed.p2s)?; } - // Direct maker_puzzle_hash is shared vault inventory — never per-offer p2. - // Presplit-like rows (incl. legacy NULL mode + fixed_delegated) may seed CONDITIONS p2. - if meta.is_presplit_like() { - if let Some(p2) = meta - .fields - .maker_puzzle_hash - .as_deref() - .map(normalize_hex_id) - .filter(|value| value.len() == 64) - { - p2s.push(p2); - } - } - if coins.is_empty() && p2s.is_empty() { - return Ok(false); - } - store.ensure_offer_coin_watches(offer_id, market_id, &coins, &p2s)?; - Ok(true) + store.offer_has_coin_watches(offer_id) } fn classify_dexie_role( @@ -127,7 +103,7 @@ pub fn prepare_market_reconcile_local( let rows = store.list_offer_states(Some(clean_market), 5000)?; let mut local = MarketReconcileLocal::default(); for row in rows { - let Ok(state) = ReconcileState::parse(&row.state) else { + let Ok(state) = row.reconcile_state() else { continue; }; if matches!(state, ReconcileState::CancelSubmitted) { @@ -179,10 +155,10 @@ pub fn ensure_watches_from_dexie_payload( raw: &Value, ) -> SignerResult<()> { let (coin_ids, payload_p2s) = maker_watch_keys_from_dexie_payload(raw); - // Seed p2 watches only for presplit-like rows (incl. legacy NULL + fixed_delegated). - // Direct cancellable inputs share vault inventory puzzle hashes (ADR 0019). let p2s = match store.offer_cancel_metadata_for_id(offer_id)? { - Some(meta) if meta.is_presplit_like() => payload_p2s, + Some(meta) if crate::offer::PostedOfferShape::from_metadata(&meta).is_presplit() => { + payload_p2s + } _ => Vec::new(), }; if !coin_ids.is_empty() || !p2s.is_empty() { @@ -464,7 +440,7 @@ mod tests { #[test] fn classify_past_grace_cancel_submitted_resets_to_open() { - use crate::offer::lifecycle::{apply_cancel_submitted_rows, ReconcilePersistOptions}; + use crate::offer::lifecycle::{ReconcilePersistOptions, WatchedOfferReconciler}; let dir = tempdir().expect("tempdir"); let store = SqliteStore::open(&dir.path().join("state.db")).expect("open"); @@ -478,17 +454,14 @@ mod tests { assert!(local.dexie.authoritative.is_empty()); assert!(local.dexie.heal_only.is_empty()); assert_eq!(local.cancel_submitted_rows.len(), 1); - apply_cancel_submitted_rows( - &store, - &local.cancel_submitted_rows, - &ReconcilePersistOptions { - action: "cancel_submitted_orphan_reconcile", - venue: None, - dexie_error: None, - }, - chrono::Utc::now(), - ) - .expect("apply"); + let options = ReconcilePersistOptions { + action: "cancel_submitted_orphan_reconcile", + venue: None, + dexie_error: None, + }; + WatchedOfferReconciler::new(&store, &options) + .apply_cancel_submitted(&local.cancel_submitted_rows, chrono::Utc::now()) + .expect("apply"); let rows = store .list_offer_states_for_ids(std::slice::from_ref(&offer_id)) .expect("rows"); @@ -497,7 +470,7 @@ mod tests { #[test] fn classify_within_grace_preserves_cancel_submitted() { - use crate::offer::lifecycle::{apply_cancel_submitted_rows, ReconcilePersistOptions}; + use crate::offer::lifecycle::{ReconcilePersistOptions, WatchedOfferReconciler}; let dir = tempdir().expect("tempdir"); let store = SqliteStore::open(&dir.path().join("state.db")).expect("open"); @@ -510,17 +483,14 @@ mod tests { assert!(local.dexie.authoritative.is_empty()); assert!(local.dexie.heal_only.is_empty()); assert_eq!(local.cancel_submitted_rows.len(), 1); - apply_cancel_submitted_rows( - &store, - &local.cancel_submitted_rows, - &ReconcilePersistOptions { - action: "cancel_submitted_orphan_reconcile", - venue: None, - dexie_error: None, - }, - chrono::Utc::now(), - ) - .expect("apply"); + let options = ReconcilePersistOptions { + action: "cancel_submitted_orphan_reconcile", + venue: None, + dexie_error: None, + }; + WatchedOfferReconciler::new(&store, &options) + .apply_cancel_submitted(&local.cancel_submitted_rows, chrono::Utc::now()) + .expect("apply"); let rows = store .list_offer_states_for_ids(std::slice::from_ref(&offer_id)) .expect("rows"); diff --git a/greenfloor-engine/src/offer/lifecycle/reconcile_watched_offers.rs b/greenfloor-engine/src/offer/lifecycle/reconcile_watched_offers.rs index f7ccb1cf..af09657c 100644 --- a/greenfloor-engine/src/offer/lifecycle/reconcile_watched_offers.rs +++ b/greenfloor-engine/src/offer/lifecycle/reconcile_watched_offers.rs @@ -11,7 +11,7 @@ use crate::storage::SqliteStore; use super::cancel_context::preload_cancel_submitted_contexts; use super::persist::ReconcilePersistOptions; -use super::reconcile_prep::fetch_and_apply_watched_offer; +use super::signal_apply::WatchedOfferReconciler; use super::transition::WatchedOfferTransitionEnv; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -105,17 +105,17 @@ pub async fn reconcile_offers_batch( let mut changed_count = 0u64; let persist_options = batch_persist_options(venue); + let reconciler = WatchedOfferReconciler::new(&store, &persist_options); for row in rows { - let (transition, last_seen_status) = fetch_and_apply_watched_offer( - &store, - &dexie, - &row.market_id, - &row.offer_id, - &row.state, - WatchedOfferTransitionEnv::new(now, Some(&cancel_submitted_by_offer)), - &persist_options, - ) - .await?; + let (transition, last_seen_status) = reconciler + .fetch_and_apply( + &dexie, + &row.market_id, + &row.offer_id, + &row.state, + WatchedOfferTransitionEnv::new(now, Some(&cancel_submitted_by_offer)), + ) + .await?; if transition.changed { changed_count += 1; diff --git a/greenfloor-engine/src/offer/lifecycle/signal_apply.rs b/greenfloor-engine/src/offer/lifecycle/signal_apply.rs index fcbf7873..17835b6e 100644 --- a/greenfloor-engine/src/offer/lifecycle/signal_apply.rs +++ b/greenfloor-engine/src/offer/lifecycle/signal_apply.rs @@ -1,10 +1,11 @@ -//! Shared resolve+persist for watched-offer Coinset signals. +//! Shared resolve+persist for watched-offer Coinset, Dexie, and cancel-submitted signals. use std::collections::HashMap; use chrono::{DateTime, Utc}; use serde_json::Value; +use crate::adapters::DexieClient; use crate::cycle::reconcile::{ resolve_watched_offer_transition_from_signals, CancelSubmittedContext, CoinsetTxSignals, }; @@ -17,15 +18,12 @@ use super::cancel_context::{ preload_cancel_submitted_contexts, }; use super::persist::{persist_offer_lifecycle_transition, ReconcilePersistOptions}; -use super::transition::{transition_from_offer_body, WatchedOfferTransitionEnv}; +use super::transition::{ + resolve_watched_offer_transition_from_dexie_fetch, transition_from_offer_body, + WatchedOfferTransitionEnv, +}; -/// Persist an already-resolved watched-offer transition when it changes state or -/// carries a venue status touch. -/// -/// # Errors -/// -/// Returns an error if `SQLite` persist fails. -pub fn persist_resolved_watched_transition( +fn persist_resolved_watched_transition( store: &SqliteStore, market_id: &str, offer_id: &str, @@ -46,157 +44,209 @@ pub fn persist_resolved_watched_transition( Ok(()) } -/// Resolve watched-offer transition from signals and persist when needed. -/// -/// Merges `confirmed_tx_ids` with the tracked cancel tx via -/// [`chain_confirmed_tx_ids_for_transition`] so WS and Dexie share one promotion path. -/// Persists when state changes **or** `last_seen_status` is present (Dexie status touch). -/// -/// # Errors -/// -/// Returns an error if reconcile or `SQLite` persist fails. -#[allow(clippy::too_many_arguments)] -pub fn apply_watched_offer_signals( - store: &SqliteStore, - market_id: &str, - offer_id: &str, - current_state: &str, - status: Option, - signals: CoinsetTxSignals, - cancel_submitted: Option<&CancelSubmittedContext>, - options: &ReconcilePersistOptions<'_>, - last_seen_status: Option, - now: DateTime, -) -> SignerResult { - let chain_confirmed = - chain_confirmed_tx_ids_for_transition(store, cancel_submitted, &signals.confirmed_tx_ids)?; - let transition = resolve_watched_offer_transition_from_signals( - current_state, - status, - signals, - &chain_confirmed, - cancel_submitted, - now, - ) - .map_err(|err| crate::error::SignerError::Other(err.to_string()))?; - persist_resolved_watched_transition( - store, - market_id, - offer_id, - &transition, - last_seen_status, - options, - )?; - Ok(transition) +/// Owns watched-offer signal ingestion, transition resolve, and persist (CLI, daemon, WS). +pub struct WatchedOfferReconciler<'a> { + store: &'a SqliteStore, + options: &'a ReconcilePersistOptions<'a>, } -/// Resolve + persist lifecycle from an already-fetched Dexie offer payload. -/// -/// Composes the canonical [`transition_from_offer_body`] resolve spine with persist. -/// Shared by daemon market-cycle reconcile and CLI batch reconcile. -/// -/// # Errors -/// -/// Returns an error if signal extraction or `SQLite` persist fails. -pub fn apply_watched_offer_from_dexie_payload( - store: &SqliteStore, - market_id: &str, - offer_id: &str, - current_state: &str, - offer_payload: &Value, - env: WatchedOfferTransitionEnv<'_>, - options: &ReconcilePersistOptions<'_>, -) -> SignerResult<(CycleOfferTransition, Option)> { - let (transition, status) = - transition_from_offer_body(store, offer_id, current_state, offer_payload, env)?; - persist_resolved_watched_transition(store, market_id, offer_id, &transition, status, options)?; - Ok((transition, status)) -} +impl<'a> WatchedOfferReconciler<'a> { + #[must_use] + pub fn new(store: &'a SqliteStore, options: &'a ReconcilePersistOptions<'a>) -> Self { + Self { store, options } + } -/// Resolve + persist a missing Dexie watched offer (404 / not-found). -/// -/// Shared by CLI batch reconcile and daemon watchlist augment. -/// Callers put the Dexie error text on `options.dexie_error`. -/// -/// # Errors -/// -/// Returns an error if transition resolve or `SQLite` persist fails. -pub fn persist_missing_watched_offer( - store: &SqliteStore, - market_id: &str, - offer_id: &str, - current_state: &str, - options: &ReconcilePersistOptions<'_>, -) -> SignerResult { - let transition = resolve_missing_watched_offer_transition(current_state) - .map_err(|err| crate::error::SignerError::Other(err.to_string()))?; - persist_resolved_watched_transition(store, market_id, offer_id, &transition, None, options)?; - Ok(transition) -} + /// Apply Coinset signals to one persisted offer row. + /// + /// # Errors + /// + /// Returns an error if reconcile or persist fails. + pub fn apply_row( + &self, + row: &OfferStateListRow, + status: Option, + signals: CoinsetTxSignals, + cancel_by_offer: Option<&HashMap>, + now: DateTime, + ) -> SignerResult { + let cancel_submitted = cancel_submitted_context_for_offer( + self.store, + &row.offer_id, + &row.state, + cancel_by_offer, + )?; + let transition = self.apply_signals( + &row.market_id, + &row.offer_id, + &row.state, + status, + signals, + cancel_submitted.as_ref(), + None, + now, + )?; + Ok(transition.changed) + } -/// Apply signals to one offer-state row (shared WS / cancel-submitted seam). -/// -/// # Errors -/// -/// Returns an error if `SQLite` or reconcile persist fails. -pub fn apply_signals_to_row( - store: &SqliteStore, - row: &OfferStateListRow, - status: Option, - signals: CoinsetTxSignals, - cancel_by_offer: Option<&HashMap>, - options: &ReconcilePersistOptions<'_>, - now: DateTime, -) -> SignerResult { - let cancel_submitted = - cancel_submitted_context_for_offer(store, &row.offer_id, &row.state, cancel_by_offer)?; - let transition = apply_watched_offer_signals( - store, - &row.market_id, - &row.offer_id, - &row.state, - status, - signals, - cancel_submitted.as_ref(), - options, - None, - now, - )?; - Ok(transition.changed) -} + /// Apply empty-signal cancel-submitted policy to rows (orphan unwedge / cancel-tx promote). + /// + /// Past orphan grace, unconfirmed cancels reset to `open`. Within grace, non-attributable + /// noise still preserves `cancel_submitted`. Callers that already ingested confirmed cancel + /// txs rely on preloaded context seeing `tx_block_confirmed_at` for promotion. + /// + /// # Errors + /// + /// Returns an error if reconcile or persist fails. + pub fn apply_cancel_submitted( + &self, + rows: &[OfferStateListRow], + now: DateTime, + ) -> SignerResult { + if rows.is_empty() { + return Ok(0); + } + let cancel_by_offer = preload_cancel_submitted_contexts(self.store, rows)?; + let mut changed = 0_u64; + for row in rows { + if self.apply_row( + row, + None, + CoinsetTxSignals::default(), + Some(&cancel_by_offer), + now, + )? { + changed += 1; + } + } + Ok(changed) + } -/// Apply empty-signal cancel-submitted policy to rows (orphan unwedge / cancel-tx promote). -/// -/// Past orphan grace, unconfirmed cancels reset to `open`. Within grace, non-attributable -/// noise still preserves `cancel_submitted`. Callers that already ingested confirmed cancel -/// txs rely on preloaded context seeing `tx_block_confirmed_at` for promotion. -/// -/// # Errors -/// -/// Returns an error if `SQLite` or reconcile persist fails. -pub fn apply_cancel_submitted_rows( - store: &SqliteStore, - rows: &[OfferStateListRow], - options: &ReconcilePersistOptions<'_>, - now: DateTime, -) -> SignerResult { - if rows.is_empty() { - return Ok(0); + /// Persist an already-resolved transition. + /// + /// # Errors + /// + /// Returns an error if persist fails. + pub fn persist_transition( + &self, + market_id: &str, + offer_id: &str, + transition: &CycleOfferTransition, + last_seen_status: Option, + ) -> SignerResult<()> { + persist_resolved_watched_transition( + self.store, + market_id, + offer_id, + transition, + last_seen_status, + self.options, + ) } - let cancel_by_offer = preload_cancel_submitted_contexts(store, rows)?; - let mut changed = 0_u64; - for row in rows { - if apply_signals_to_row( - store, - row, - None, - CoinsetTxSignals::default(), - Some(&cancel_by_offer), - options, - now, - )? { - changed += 1; + + /// Resolve + persist lifecycle from an already-fetched Dexie offer payload. + /// + /// # Errors + /// + /// Returns an error if signal extraction or `SQLite` persist fails. + pub fn apply_dexie_payload( + &self, + market_id: &str, + offer_id: &str, + current_state: &str, + offer_payload: &Value, + env: WatchedOfferTransitionEnv<'_>, + ) -> SignerResult<(CycleOfferTransition, Option)> { + let (transition, status) = + transition_from_offer_body(self.store, offer_id, current_state, offer_payload, env)?; + self.persist_transition(market_id, offer_id, &transition, status)?; + Ok((transition, status)) + } + + /// Resolve + persist a missing Dexie watched offer (404 / not-found). + /// + /// Callers put the Dexie error text on `options.dexie_error`. + /// + /// # Errors + /// + /// Returns an error if transition resolve or `SQLite` persist fails. + pub fn apply_missing( + &self, + market_id: &str, + offer_id: &str, + current_state: &str, + ) -> SignerResult { + let transition = resolve_missing_watched_offer_transition(current_state)?; + self.persist_transition(market_id, offer_id, &transition, None)?; + Ok(transition) + } + + /// Fetch a watched Dexie offer and persist the resolved lifecycle transition. + /// + /// # Errors + /// + /// Returns an error if Dexie lookup, transition resolve, or `SQLite` persist fails. + pub async fn fetch_and_apply( + &self, + dexie: &DexieClient, + market_id: &str, + offer_id: &str, + current_state: &str, + env: WatchedOfferTransitionEnv<'_>, + ) -> SignerResult<(CycleOfferTransition, Option)> { + let (transition, status, dexie_error) = resolve_watched_offer_transition_from_dexie_fetch( + self.store, + dexie, + offer_id, + current_state, + env, + ) + .await?; + if let Some(error_text) = dexie_error.as_deref() { + let persist_options = ReconcilePersistOptions { + action: self.options.action, + venue: self.options.venue, + dexie_error: Some(error_text), + }; + persist_resolved_watched_transition( + self.store, + market_id, + offer_id, + &transition, + status, + &persist_options, + )?; + } else { + self.persist_transition(market_id, offer_id, &transition, status)?; } + Ok((transition, status)) + } + + #[allow(clippy::too_many_arguments)] + fn apply_signals( + &self, + market_id: &str, + offer_id: &str, + current_state: &str, + status: Option, + signals: CoinsetTxSignals, + cancel_submitted: Option<&CancelSubmittedContext>, + last_seen_status: Option, + now: DateTime, + ) -> SignerResult { + let chain_confirmed = chain_confirmed_tx_ids_for_transition( + self.store, + cancel_submitted, + &signals.confirmed_tx_ids, + )?; + let transition = resolve_watched_offer_transition_from_signals( + current_state, + status, + signals, + &chain_confirmed, + cancel_submitted, + now, + )?; + self.persist_transition(market_id, offer_id, &transition, last_seen_status)?; + Ok(transition) } - Ok(changed) } diff --git a/greenfloor-engine/src/offer/lifecycle/transition.rs b/greenfloor-engine/src/offer/lifecycle/transition.rs index 7b60dfde..4f937b84 100644 --- a/greenfloor-engine/src/offer/lifecycle/transition.rs +++ b/greenfloor-engine/src/offer/lifecycle/transition.rs @@ -112,8 +112,7 @@ pub fn missing_offer_error_from_payload(payload: &Value) -> Option { } fn missing_watched_offer_transition(current_state: &str) -> SignerResult { - resolve_missing_watched_offer_transition(current_state) - .map_err(|err| crate::error::SignerError::Other(err.to_string())) + resolve_missing_watched_offer_transition(current_state).map_err(Into::into) } /// Resolve lifecycle + Dexie status from an offer body (list row or `get_offer.offer`). @@ -147,8 +146,7 @@ pub(crate) fn transition_from_offer_body( &chain_confirmed_tx_ids, cancel_submitted.as_ref(), env.now, - ) - .map_err(|err| crate::error::SignerError::Other(err.to_string()))?; + )?; Ok((transition, status)) } @@ -179,8 +177,7 @@ pub async fn resolve_watched_offer_transition_from_dexie_fetch( } DexieOfferFetch::LookupError(err) => { let transition = - unchanged_offer_transition(current_state, format!("dexie_lookup_error:{err}")) - .map_err(|parse_err| crate::error::SignerError::Other(parse_err.to_string()))?; + unchanged_offer_transition(current_state, format!("dexie_lookup_error:{err}"))?; Ok((transition, None, None)) } } diff --git a/greenfloor-engine/src/offer/lifecycle/ws_apply/mod.rs b/greenfloor-engine/src/offer/lifecycle/ws_apply/mod.rs index e6b2f894..d595a174 100644 --- a/greenfloor-engine/src/offer/lifecycle/ws_apply/mod.rs +++ b/greenfloor-engine/src/offer/lifecycle/ws_apply/mod.rs @@ -10,7 +10,7 @@ use crate::storage::{SqliteStore, TxSignalIngress}; use super::cancel_context::preload_cancel_submitted_contexts; use super::persist::ReconcilePersistOptions; -use super::signal_apply::{apply_cancel_submitted_rows, apply_signals_to_row}; +use super::signal_apply::WatchedOfferReconciler; #[cfg(test)] mod tests; @@ -84,13 +84,12 @@ pub fn apply_ws_offer_event( let Some(row) = rows.first() else { return Ok(WsOfferApply::NotTracked); }; - apply_signals_to_row( - store, + let options = ws_persist_options(); + WatchedOfferReconciler::new(store, &options).apply_row( row, status, signals, None, - &ws_persist_options(), Utc::now(), )?; Ok(WsOfferApply::Applied { @@ -117,7 +116,8 @@ pub fn promote_cancel_submitted_for_confirmed_txs( let rows = store.list_offer_states_for_cancel_submitted_tx_ids(confirmed_tx_ids)?; // Do not wrap in a parent transaction: terminal persist uses // immediate_transaction (clear watches + upsert) and cannot nest. - apply_cancel_submitted_rows(store, &rows, &ws_persist_options(), Utc::now())?; + let options = ws_persist_options(); + WatchedOfferReconciler::new(store, &options).apply_cancel_submitted(&rows, Utc::now())?; let mut market_ids: Vec = rows.into_iter().map(|row| row.market_id).collect(); market_ids.sort(); market_ids.dedup(); @@ -156,18 +156,11 @@ pub fn apply_watch_hits_batch( let rows: Vec<_> = coin_hits.iter().map(|hit| hit.row.clone()).collect(); let cancel_by_offer = preload_cancel_submitted_contexts(store, &rows)?; let options = ws_persist_options(); + let reconciler = WatchedOfferReconciler::new(store, &options); let now = Utc::now(); for hit in coin_hits { let signals = signals_for_watch_hit(frame_confirmed, confirmed_tx_ids); - apply_signals_to_row( - store, - &hit.row, - None, - signals, - Some(&cancel_by_offer), - &options, - now, - )?; + reconciler.apply_row(&hit.row, None, signals, Some(&cancel_by_offer), now)?; } Ok(market_ids) } diff --git a/greenfloor-engine/src/offer/maker_shape.rs b/greenfloor-engine/src/offer/maker_shape.rs new file mode 100644 index 00000000..d7b6d46f --- /dev/null +++ b/greenfloor-engine/src/offer/maker_shape.rs @@ -0,0 +1,96 @@ +//! Maker watch-seed policy (ADR 0019). Shape classification lives on [`PostedOfferShape`]. + +use crate::hex::normalize_hex_id; +use crate::offer::types::{ + OfferCancelFields, OfferExecutionMode, PostedOfferShape, StoredOfferCancelMetadata, +}; + +/// Coin-id and p2 watches to seed for a posted maker (ADR 0019). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct MakerWatchSeed { + pub coin_ids: Vec, + pub p2s: Vec, +} + +impl MakerWatchSeed { + /// Watches from stored cancel metadata (heal path). + #[must_use] + pub fn from_metadata(meta: &StoredOfferCancelMetadata) -> Self { + let mut coin_ids = Vec::new(); + if let Some(coin) = meta + .fields + .input_coin_id + .as_deref() + .map(normalize_hex_id) + .filter(|value| value.len() == 64) + { + coin_ids.push(coin); + } + let p2s = if PostedOfferShape::from_metadata(meta).is_presplit() { + meta.fields + .maker_puzzle_hash + .clone() + .into_iter() + .filter(|value| !value.trim().is_empty()) + .collect() + } else { + Vec::new() + }; + Self { coin_ids, p2s } + } + + /// Watches at offer post persist time. + #[must_use] + pub fn from_post_fields( + execution_mode: Option, + cancel_fields: &OfferCancelFields, + extra_coin_ids: impl IntoIterator, + ) -> Self { + let mut coin_ids: Vec = extra_coin_ids.into_iter().collect(); + if let Some(input) = cancel_fields.input_coin_id.clone() { + coin_ids.push(input); + } + coin_ids.sort(); + coin_ids.dedup(); + let p2s = if PostedOfferShape::from_execution( + execution_mode, + cancel_fields.fixed_delegated_puzzle_hash.as_deref(), + ) + .is_presplit() + { + cancel_fields + .maker_puzzle_hash + .clone() + .into_iter() + .collect() + } else { + Vec::new() + }; + Self { coin_ids, p2s } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn direct_execution_never_seeds_p2() { + let fields = OfferCancelFields::from_direct_build("aa".repeat(32), "bb".repeat(32)); + let seed = MakerWatchSeed::from_post_fields(Some(OfferExecutionMode::Direct), &fields, []); + assert!(seed.p2s.is_empty()); + assert_eq!(seed.coin_ids, vec!["aa".repeat(32)]); + } + + #[test] + fn presplit_execution_seeds_maker_p2() { + let fields = OfferCancelFields::from_presplit_build( + "aa".repeat(32), + "cc".repeat(32), + "dd".repeat(32), + ); + let seed = + MakerWatchSeed::from_post_fields(Some(OfferExecutionMode::PresplitNew), &fields, []); + assert_eq!(seed.p2s, vec!["dd".repeat(32)]); + } +} diff --git a/greenfloor-engine/src/offer/mod.rs b/greenfloor-engine/src/offer/mod.rs index bddcbb01..1a181b29 100644 --- a/greenfloor-engine/src/offer/mod.rs +++ b/greenfloor-engine/src/offer/mod.rs @@ -13,6 +13,7 @@ pub mod codec; pub mod dexie_payload; pub mod invariants; pub mod lifecycle; +pub mod maker_shape; pub mod operator; pub mod plan; pub mod presplit; @@ -46,6 +47,7 @@ pub use codec::{ from_input_spend_bundle_xch_bytes, validate_offer_structure, validate_offer_text, verify_offer_for_dexie, }; +pub use maker_shape::MakerWatchSeed; pub use pricing::quote_mojos_for_base_size; pub use publish::{ expected_publish_asset_fields, post_offer_phase_dexie, ExpectedPublishAssetFields, @@ -61,5 +63,5 @@ pub use request::{ }; pub use types::{ effective_maker_reuse, CreateOfferRequest, CreateOfferResult, OfferCancelFields, - OfferExecutionMode, PresplitMakerReuse, StoredOfferCancelMetadata, + OfferExecutionMode, PostedOfferShape, PresplitMakerReuse, StoredOfferCancelMetadata, }; diff --git a/greenfloor-engine/src/offer/operator/build_and_post/create.rs b/greenfloor-engine/src/offer/operator/build_and_post/create.rs index 356942e7..2feda58c 100644 --- a/greenfloor-engine/src/offer/operator/build_and_post/create.rs +++ b/greenfloor-engine/src/offer/operator/build_and_post/create.rs @@ -45,7 +45,7 @@ pub(super) async fn create_offer( expires_at_unix: 4_000_000_000, offer_amount: size_base_units, request_amount: 1, - execution_mode: "signer_test_stub".to_string(), + execution_mode: crate::offer::OfferExecutionMode::Direct, create_result: None, }); } diff --git a/greenfloor-engine/src/offer/operator/build_and_post/iteration.rs b/greenfloor-engine/src/offer/operator/build_and_post/iteration.rs index 0241519c..e1767f08 100644 --- a/greenfloor-engine/src/offer/operator/build_and_post/iteration.rs +++ b/greenfloor-engine/src/offer/operator/build_and_post/iteration.rs @@ -90,7 +90,7 @@ async fn create_offer_for_post( error: "signer_offer_text_unavailable".to_string(), started, create_phase_ms: Some(create_phase_ms), - execution_mode: Some(created.execution_mode.clone()), + execution_mode: Some(created.execution_mode), bootstrap: None, }))); } @@ -152,7 +152,7 @@ async fn publish_created_offer( let persist_record = offer_post_persist_record( &publish, side, - &created.execution_mode, + Some(created.execution_mode), ctx, request.size_base_units, Some(created.expires_at_unix), @@ -161,7 +161,7 @@ async fn publish_created_offer( let publish_success = publish.success; let result_payload = finalize_publish_payload( publish, - &created.execution_mode, + created.execution_mode, timing_payload( started, Some(create_phase_ms), diff --git a/greenfloor-engine/src/offer/operator/build_and_post/mod.rs b/greenfloor-engine/src/offer/operator/build_and_post/mod.rs index 93e98a8c..d24c53bd 100644 --- a/greenfloor-engine/src/offer/operator/build_and_post/mod.rs +++ b/greenfloor-engine/src/offer/operator/build_and_post/mod.rs @@ -94,27 +94,6 @@ pub struct BuildAndPostOfferRequest { pub test_overrides: crate::offer::operator::BuildOfferTestOverrides, } -/// Shared fields for constructing a [`BuildAndPostOfferRequest`] from CLI or daemon callers. -#[derive(Debug, Clone)] -pub struct BuildAndPostOfferRequestParts { - pub program_path: PathBuf, - pub markets_path: PathBuf, - pub testnet_markets_path: Option, - pub cats_path: Option, - pub network: String, - pub market_id: Option, - pub pair: Option, - pub size_base_units: u64, - pub repeat: u32, - pub publish_venue: Option, - pub dexie_base_url: Option, - pub splash_base_url: Option, - pub venue: BuildAndPostVenueOptions, - pub run: BuildAndPostRunOptions, - pub action_side: Option, - pub maker_reuse: Option, -} - /// Program/markets config paths for managed ensure / build-and-post callers. #[derive(Debug, Clone)] pub struct OperatorConfigPaths { @@ -123,8 +102,8 @@ pub struct OperatorConfigPaths { pub testnet_markets_path: Option, } -impl BuildAndPostOfferRequestParts { - /// Shared parts for daemon/ensure size-N posts (`drop_only`, single repeat). +impl BuildAndPostOfferRequest { + /// Daemon / ensure-size post request (drop-only, single repeat). #[must_use] pub fn for_ensure_size( paths: &OperatorConfigPaths, @@ -157,30 +136,6 @@ impl BuildAndPostOfferRequestParts { }, action_side: Some(normalize_offer_side(&side.into()).to_string()), maker_reuse: None, - } - } -} - -impl BuildAndPostOfferRequest { - #[must_use] - pub fn from_parts(parts: BuildAndPostOfferRequestParts) -> Self { - Self { - program_path: parts.program_path, - markets_path: parts.markets_path, - testnet_markets_path: parts.testnet_markets_path, - cats_path: parts.cats_path, - network: parts.network, - market_id: parts.market_id, - pair: parts.pair, - size_base_units: parts.size_base_units, - repeat: parts.repeat, - publish_venue: parts.publish_venue, - dexie_base_url: parts.dexie_base_url, - splash_base_url: parts.splash_base_url, - venue: parts.venue, - run: parts.run, - action_side: parts.action_side, - maker_reuse: parts.maker_reuse, #[cfg(test)] test_overrides: crate::offer::operator::BuildOfferTestOverrides::default(), } @@ -462,6 +417,41 @@ pub(crate) async fn build_and_post_offer_with_persist_artifacts( finish_build_and_post(&request, ctx, persist, session, None).await } +/// Pin, persist, and flush on a cycle write store (daemon / `ensure_size`). +pub(crate) async fn build_and_post_offer_on_cycle_store( + request: BuildAndPostOfferRequest, + write_store: &crate::storage::CycleWriteStore, + unique_maker_coins: bool, +) -> SignerResult { + let market_id = request + .market_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + .ok_or_else(|| SignerError::Other("post requires market_id".to_string()))?; + let mut session = write_store.sync(|store| { + UniqueMakerPinSession::begin( + store, + market_id, + request.run.dry_run, + unique_maker_coins, + request.maker_reuse.as_ref(), + ) + })?; + let persist_store = write_store.clone(); + let mut persist = move |record: &OfferPostPersistRecord| { + persist_store.sync(|store| upsert_offer_post_record(store, record)) + }; + let (response, artifacts) = + build_and_post_offer_with_persist_artifacts(request, Some(&mut persist), &mut session) + .await?; + if let Some(artifacts) = artifacts { + let store = write_store.lock()?; + flush_build_and_post_persist(&store, &artifacts)?; + } + Ok(response) +} + #[cfg(test)] pub(crate) fn empty_persist_artifacts_for_test() -> BuildAndPostPersistArtifacts { BuildAndPostPersistArtifacts { diff --git a/greenfloor-engine/src/offer/operator/build_and_post/publish.rs b/greenfloor-engine/src/offer/operator/build_and_post/publish.rs index 60f59dae..474d9da5 100644 --- a/greenfloor-engine/src/offer/operator/build_and_post/publish.rs +++ b/greenfloor-engine/src/offer/operator/build_and_post/publish.rs @@ -65,7 +65,7 @@ pub(super) async fn publish_offer( pub(super) fn finalize_publish_payload( publish: PublishResult, - execution_mode: &str, + execution_mode: OfferExecutionMode, timing_ms: Value, dexie_base_url: Option<&str>, ) -> Value { @@ -89,7 +89,7 @@ pub(super) fn finalize_publish_payload( pub(super) fn offer_post_persist_record( publish: &PublishResult, side: &str, - execution_mode: &str, + execution_mode: Option, ctx: &ResolvedBuildAndPostContext, size_base_units: u64, listing_expires_at: Option, @@ -104,35 +104,24 @@ pub(super) fn offer_post_persist_record( .unwrap_or_default(); let execution_mode = create_result .map(|result| result.execution_mode) - .or_else(|| OfferExecutionMode::parse_db(execution_mode)); + .or(execution_mode); let offer_nonce = create_result .map(|result| result.offer_nonce.clone()) .filter(|value| !value.trim().is_empty()); - let mut watched_coin_ids = create_result - .map(|result| result.selected_coin_ids.clone()) - .unwrap_or_default(); - if let Some(presplit) = create_result.and_then(|result| result.presplit_coin_id.clone()) { - watched_coin_ids.push(presplit); - } - if let Some(input) = cancel_fields.input_coin_id.clone() { - watched_coin_ids.push(input); - } - watched_coin_ids.sort(); - watched_coin_ids.dedup(); - // Per-offer p2 watches: presplit-like CONDITIONS only. Direct maker_puzzle_hash - // is shared vault inventory (ADR 0019) — InventoryP2Index owns those. - let watched_p2s = if crate::offer::types::StoredOfferCancelMetadata::is_presplit_like_parts( - execution_mode, - cancel_fields.fixed_delegated_puzzle_hash.as_deref(), - ) { - cancel_fields - .maker_puzzle_hash - .clone() - .into_iter() - .collect() - } else { - Vec::new() + let extra_coin_ids = { + let mut ids = create_result + .map(|result| result.selected_coin_ids.clone()) + .unwrap_or_default(); + if let Some(presplit) = create_result.and_then(|result| result.presplit_coin_id.clone()) { + ids.push(presplit); + } + ids }; + let seed = crate::offer::MakerWatchSeed::from_post_fields( + execution_mode, + &cancel_fields, + extra_coin_ids, + ); Some(OfferPostPersistRecord { offer_id, market_id: ctx.gated.market_row.market_id.clone(), @@ -144,8 +133,8 @@ pub(super) fn offer_post_persist_record( created_extra: json!({}), cancel_fields, execution_mode, - watched_coin_ids, - watched_p2s, + watched_coin_ids: seed.coin_ids, + watched_p2s: seed.p2s, listing_expires_at, offer_nonce, }) @@ -180,7 +169,7 @@ mod tests { }; let payload = finalize_publish_payload( publish, - "direct", + OfferExecutionMode::Direct, json!({"total_ms": 12}), Some("https://api.dexie.space"), ); diff --git a/greenfloor-engine/src/offer/operator/build_and_post/tests.rs b/greenfloor-engine/src/offer/operator/build_and_post/tests.rs index 1754fee1..aa125b9b 100644 --- a/greenfloor-engine/src/offer/operator/build_and_post/tests.rs +++ b/greenfloor-engine/src/offer/operator/build_and_post/tests.rs @@ -132,18 +132,44 @@ fn offer_post_persist_record_requires_success_and_offer_id() { offer_id: Some("offer-1".to_string()), body: json!({"success": false}), }; - assert!(offer_post_persist_record(&failed, "sell", "direct", &ctx, 1, None, None).is_none()); + assert!(offer_post_persist_record( + &failed, + "sell", + Some(OfferExecutionMode::Direct), + &ctx, + 1, + None, + None + ) + .is_none()); let success = PublishResult { success: true, offer_id: Some("offer-1".to_string()), body: json!({"success": true, "id": "offer-1"}), }; - let record = offer_post_persist_record(&success, "sell", "direct", &ctx, 10, None, None) - .expect("record"); + let record = offer_post_persist_record( + &success, + "sell", + Some(OfferExecutionMode::Direct), + &ctx, + 10, + None, + None, + ) + .expect("record"); assert_eq!(record.offer_id, "offer-1"); assert_eq!(record.market_id, "m1"); +} +#[test] +fn offer_post_persist_record_seeds_presplit_watches() { + let ctx = sample_resolved_build_and_post_context(); + let success = PublishResult { + success: true, + offer_id: Some("offer-1".to_string()), + body: json!({"success": true, "id": "offer-1"}), + }; let selected = "aa".repeat(32); let presplit_coin = "bb".repeat(32); let input_coin = "cc".repeat(32); @@ -163,9 +189,16 @@ fn offer_post_persist_record_requires_success_and_offer_id() { p2.clone(), ), }; - let presplit = - offer_post_persist_record(&success, "sell", "direct", &ctx, 10, None, Some(&create)) - .expect("presplit record"); + let presplit = offer_post_persist_record( + &success, + "sell", + Some(OfferExecutionMode::Direct), + &ctx, + 10, + None, + Some(&create), + ) + .expect("presplit record"); assert_eq!( presplit.execution_mode, Some(OfferExecutionMode::PresplitNew) @@ -179,7 +212,16 @@ fn offer_post_persist_record_requires_success_and_offer_id() { vec![selected, presplit_coin, input_coin] ); assert_eq!(presplit.watched_p2s, vec![p2]); +} +#[test] +fn offer_post_persist_record_skips_direct_p2_watch() { + let ctx = sample_resolved_build_and_post_context(); + let success = PublishResult { + success: true, + offer_id: Some("offer-1".to_string()), + body: json!({"success": true, "id": "offer-1"}), + }; let direct_coin = "ee".repeat(32); let direct_p2 = "ff".repeat(32); let direct_create = CreateOfferResult { @@ -196,7 +238,7 @@ fn offer_post_persist_record_requires_success_and_offer_id() { let direct = offer_post_persist_record( &success, "sell", - "direct", + Some(OfferExecutionMode::Direct), &ctx, 10, None, @@ -451,7 +493,7 @@ fn post_failure_venue_result_marks_publish_failure() { error: "dexie_http_error:500".to_string(), started: Instant::now(), create_phase_ms: Some(12), - execution_mode: Some("direct".to_string()), + execution_mode: Some(OfferExecutionMode::Direct), bootstrap: None, }; let venue = failure.to_venue_result("dexie"); @@ -491,7 +533,7 @@ async fn dry_run_returns_preview_payload_in_process() { ) .expect("write markets fixture"); - let response = super::build_and_post_offer(super::BuildAndPostOfferRequest { + let mut request = super::BuildAndPostOfferRequest { program_path: program, markets_path: markets, testnet_markets_path: None, @@ -514,13 +556,15 @@ async fn dry_run_returns_preview_payload_in_process() { }, action_side: None, maker_reuse: None, - test_overrides: crate::offer::operator::BuildOfferTestOverrides { - offer_text: Some("offer1dryrunpreviewstub".to_string()), - ..Default::default() - }, - }) - .await - .expect("build and post dry run"); + test_overrides: crate::offer::operator::BuildOfferTestOverrides::default(), + }; + request.test_overrides = crate::offer::operator::BuildOfferTestOverrides { + offer_text: Some("offer1dryrunpreviewstub".to_string()), + ..Default::default() + }; + let response = super::build_and_post_offer(request) + .await + .expect("build and post dry run"); assert_eq!(response.exit_code, 0); assert_eq!(response.payload.get("dry_run"), Some(&json!(true))); diff --git a/greenfloor-engine/src/offer/operator/build_and_post/types.rs b/greenfloor-engine/src/offer/operator/build_and_post/types.rs index 1c75b99b..0c29dd3c 100644 --- a/greenfloor-engine/src/offer/operator/build_and_post/types.rs +++ b/greenfloor-engine/src/offer/operator/build_and_post/types.rs @@ -31,7 +31,7 @@ pub(super) struct PostFailure { pub error: String, pub started: Instant, pub create_phase_ms: Option, - pub execution_mode: Option, + pub execution_mode: Option, pub bootstrap: Option, } diff --git a/greenfloor-engine/src/offer/operator/ensure_size/mod.rs b/greenfloor-engine/src/offer/operator/ensure_size/mod.rs index 35640b85..d1b9028a 100644 --- a/greenfloor-engine/src/offer/operator/ensure_size/mod.rs +++ b/greenfloor-engine/src/offer/operator/ensure_size/mod.rs @@ -16,14 +16,12 @@ use crate::offer::presplit::PresplitOfferBinding; use crate::offer::reclaim::reclaim_presplit_maker_coin; use crate::offer::request::effective_offer_side; use crate::offer::types::{effective_maker_reuse, OfferTerms, PresplitMakerReuse}; -use crate::storage::{upsert_offer_post_record, CycleWriteStore, ReusablePresplitMakerRow}; +use crate::storage::{CycleWriteStore, ReusablePresplitMakerRow}; use crate::vault::session::resolve_vault_spend_context; use self::selection::{decide_ensure_reuse, EnsureReuseKind}; -use super::{ - build_and_post_offer_with_persist_artifacts, flush_build_and_post_persist, - BuildAndPostOfferRequest, BuildAndPostOfferRequestParts, -}; +use super::{build_and_post_offer_on_cycle_store, BuildAndPostOfferRequest}; +#[cfg(test)] use crate::offer::operator::UniqueMakerPinSession; /// Shared plan inputs for hash compare (must match create/post asset resolution). @@ -71,9 +69,9 @@ fn planned_fixed_hash( } fn with_maker_reuse( - parts: &BuildAndPostOfferRequestParts, + parts: &BuildAndPostOfferRequest, reuse: Option<&ReusablePresplitMakerRow>, -) -> BuildAndPostOfferRequestParts { +) -> BuildAndPostOfferRequest { let mut next = parts.clone(); next.maker_reuse = reuse.and_then(|row| { let candidate = PresplitMakerReuse { @@ -86,39 +84,14 @@ fn with_maker_reuse( } async fn post_offer( - parts: &BuildAndPostOfferRequestParts, + parts: &BuildAndPostOfferRequest, write_store: &CycleWriteStore, reuse: Option<&ReusablePresplitMakerRow>, unique_maker_coins: bool, ) -> SignerResult { - // Unique Direct pin lives in build_and_post (after market context resolve). - let post_request = BuildAndPostOfferRequest::from_parts(with_maker_reuse(parts, reuse)); - let market_id = post_request - .market_id - .as_deref() - .map(str::trim) - .filter(|id| !id.is_empty()) - .ok_or_else(|| SignerError::Other("ensure_size post requires market_id".to_string()))?; - let mut session = write_store.sync(|store| { - UniqueMakerPinSession::begin( - store, - market_id, - post_request.run.dry_run, - unique_maker_coins, - post_request.maker_reuse.as_ref(), - ) - })?; - let persist_store = write_store.clone(); - let mut persist = move |record: &crate::storage::OfferPostPersistRecord| { - persist_store.sync(|store| upsert_offer_post_record(store, record)) - }; - let (response, artifacts) = - build_and_post_offer_with_persist_artifacts(post_request, Some(&mut persist), &mut session) - .await?; - if let Some(artifacts) = artifacts { - let store = write_store.lock()?; - flush_build_and_post_persist(&store, &artifacts)?; - } + let post_request = with_maker_reuse(parts, reuse); + let response = + build_and_post_offer_on_cycle_store(post_request, write_store, unique_maker_coins).await?; Ok(response.exit_code == 0) } @@ -175,7 +148,7 @@ pub async fn ensure_size_n_offer( signer: SignerConfig, ticker_index: &crate::config::CatTickerIndex, market: &MarketConfig, - parts: BuildAndPostOfferRequestParts, + parts: BuildAndPostOfferRequest, ) -> SignerResult { let side = effective_offer_side(parts.action_side.as_deref()).to_string(); let size_i64 = @@ -255,7 +228,7 @@ pub async fn ensure_size_n_offer( async fn apply_reoffer( write_store: &CycleWriteStore, - parts: &BuildAndPostOfferRequestParts, + parts: &BuildAndPostOfferRequest, candidate: &ReusablePresplitMakerRow, lease: ExpiredMakerLease<'_>, unique_maker_coins: bool, @@ -283,7 +256,7 @@ async fn apply_reoffer( async fn apply_reclaim_and_post( write_store: &CycleWriteStore, signer: SignerConfig, - parts: &BuildAndPostOfferRequestParts, + parts: &BuildAndPostOfferRequest, candidate: &ReusablePresplitMakerRow, lease: ExpiredMakerLease<'_>, unique_maker_coins: bool, @@ -431,7 +404,7 @@ mod tests { markets_path: dir.path().join("markets.yaml"), testnet_markets_path: None, }; - let parts = BuildAndPostOfferRequestParts::for_ensure_size( + let parts = BuildAndPostOfferRequest::for_ensure_size( &paths, &program, "mainnet", @@ -465,9 +438,8 @@ mod tests { markets_path: dir.path().join("markets.yaml"), testnet_markets_path: None, }; - let mut parts = BuildAndPostOfferRequestParts::for_ensure_size( - &paths, &program, "mainnet", "m1", 1, "sell", - ); + let mut parts = + BuildAndPostOfferRequest::for_ensure_size(&paths, &program, "mainnet", "m1", 1, "sell"); parts.market_id = None; let err = post_offer(&parts, &write_store, None, true) .await @@ -546,9 +518,8 @@ mod tests { markets_path, testnet_markets_path: None, }; - let parts = BuildAndPostOfferRequestParts::for_ensure_size( - &paths, &program, "mainnet", "m1", 1, "sell", - ); + let parts = + BuildAndPostOfferRequest::for_ensure_size(&paths, &program, "mainnet", "m1", 1, "sell"); // Dry-run gates live pin, so begin is inactive; post still runs. let posted = post_offer(&parts, &write_store, None, true) @@ -651,7 +622,7 @@ mod tests { markets_path: dir.path().join("markets.yaml"), testnet_markets_path: None, }; - let parts = BuildAndPostOfferRequestParts::for_ensure_size( + let parts = BuildAndPostOfferRequest::for_ensure_size( &paths, &program, "mainnet", "m1", 10, "sell", ); let row = ReusablePresplitMakerRow { diff --git a/greenfloor-engine/src/offer/operator/mod.rs b/greenfloor-engine/src/offer/operator/mod.rs index c1e64c9c..22dee012 100644 --- a/greenfloor-engine/src/offer/operator/mod.rs +++ b/greenfloor-engine/src/offer/operator/mod.rs @@ -8,15 +8,14 @@ mod signer_denomination; mod test_overrides; mod unique_maker; +pub(crate) use build_and_post::build_and_post_offer_on_cycle_store; #[cfg(test)] pub(crate) use build_and_post::empty_persist_artifacts_for_test; +#[cfg(test)] +pub(crate) use build_and_post::flush_build_and_post_persist; pub use build_and_post::{ - build_and_post_offer, BuildAndPostOfferRequest, BuildAndPostOfferRequestParts, - BuildAndPostOfferResponse, BuildAndPostRunOptions, BuildAndPostVenueOptions, - OperatorConfigPaths, -}; -pub(crate) use build_and_post::{ - build_and_post_offer_with_persist_artifacts, flush_build_and_post_persist, + build_and_post_offer, BuildAndPostOfferRequest, BuildAndPostOfferResponse, + BuildAndPostRunOptions, BuildAndPostVenueOptions, OperatorConfigPaths, }; pub use ensure_size::ensure_size_n_offer; pub use logging::{ diff --git a/greenfloor-engine/src/offer/operator/signer_denomination/bootstrap_execute.rs b/greenfloor-engine/src/offer/operator/signer_denomination/bootstrap_execute.rs index e4ed6baa..78a8f1e8 100644 --- a/greenfloor-engine/src/offer/operator/signer_denomination/bootstrap_execute.rs +++ b/greenfloor-engine/src/offer/operator/signer_denomination/bootstrap_execute.rs @@ -33,6 +33,7 @@ pub(crate) struct BootstrapShapeContext { pub(crate) fee_mojos: u64, pub(crate) fee_source: String, pub(crate) fee_lookup_error: Option, + pub(crate) combine_input_cap: i64, #[cfg(test)] pub(crate) test_overrides: super::test_overrides::SignerDenominationTestOverrides, } diff --git a/greenfloor-engine/src/offer/operator/signer_denomination/mod.rs b/greenfloor-engine/src/offer/operator/signer_denomination/mod.rs index 915fdea3..2c10bd92 100644 --- a/greenfloor-engine/src/offer/operator/signer_denomination/mod.rs +++ b/greenfloor-engine/src/offer/operator/signer_denomination/mod.rs @@ -195,7 +195,7 @@ pub(crate) async fn prepare_bootstrap_execution_plan( let outcome = plan_bootstrap_mixed_outputs( &ladder_entries, &spendable_coins, - resolve_combine_input_cap(), + resolve_combine_input_cap(ctx.gated.program.coin_ops_combine_input_coin_cap), &combine_context, ); if let Some(early) = bootstrap_early_phase( @@ -236,6 +236,9 @@ pub(crate) async fn prepare_bootstrap_execution_plan( fee_mojos, fee_source, fee_lookup_error, + combine_input_cap: resolve_combine_input_cap( + ctx.gated.program.coin_ops_combine_input_coin_cap, + ), #[cfg(test)] test_overrides: test_overrides::SignerDenominationTestOverrides::default(), })) diff --git a/greenfloor-engine/src/offer/operator/signer_denomination/wait.rs b/greenfloor-engine/src/offer/operator/signer_denomination/wait.rs index b64fecfb..2fe01af5 100644 --- a/greenfloor-engine/src/offer/operator/signer_denomination/wait.rs +++ b/greenfloor-engine/src/offer/operator/signer_denomination/wait.rs @@ -122,7 +122,7 @@ pub(super) async fn wait_for_bootstrap_shape_step( let outcome = plan_bootstrap_mixed_outputs( &ctx.ladder_entries, &spendable, - resolve_combine_input_cap(), + resolve_combine_input_cap(ctx.combine_input_cap), &ctx.combine_context, ); if let BootstrapWaitResolution::Complete(completed) = resolve_bootstrap_wait_poll( @@ -332,6 +332,7 @@ mod tests { fee_mojos: 0, fee_source: String::new(), fee_lookup_error: None, + combine_input_cap: 5, #[cfg(test)] test_overrides: crate::offer::operator::SignerDenominationTestOverrides::default(), }; diff --git a/greenfloor-engine/src/offer/plan.rs b/greenfloor-engine/src/offer/plan.rs index e6243446..6dd72502 100644 --- a/greenfloor-engine/src/offer/plan.rs +++ b/greenfloor-engine/src/offer/plan.rs @@ -18,7 +18,6 @@ pub(crate) enum OfferPlan { ExistingPresplit { offer_nonce: Bytes32, }, - RequiresSplitFlag, Direct { selection: SelectedCats, offer_nonce: Bytes32, @@ -103,7 +102,9 @@ pub(crate) async fn plan_vault_cat_offer( selection, offer_nonce, }, - OfferInput::Direct { .. } => OfferPlan::RequiresSplitFlag, + OfferInput::Direct { .. } => { + return Err(SignerError::OfferInputRequiresPresplit); + } OfferInput::PresplitExisting { .. } => unreachable!(), }) } @@ -206,7 +207,7 @@ mod tests { fn direct_input_requires_split_flag_when_change_without_presplit() { assert!(matches!( direct_plan_kind_for_amounts(5000, 1000, 1), - Ok(DirectPlanKind::RequiresSplitFlag) + Err(SignerError::OfferInputRequiresPresplit) )); assert!(matches!( direct_plan_kind_for_amounts(1000, 1000, 1), @@ -250,7 +251,6 @@ mod tests { enum DirectPlanKind { Direct, - RequiresSplitFlag, } fn direct_plan_kind_for_amounts( @@ -264,7 +264,7 @@ mod tests { } Ok(DirectPlanKind::Direct) } else { - Ok(DirectPlanKind::RequiresSplitFlag) + Err(SignerError::OfferInputRequiresPresplit) } } } diff --git a/greenfloor-engine/src/offer/presplit/build.rs b/greenfloor-engine/src/offer/presplit/build.rs index f928a74f..f52e56f9 100644 --- a/greenfloor-engine/src/offer/presplit/build.rs +++ b/greenfloor-engine/src/offer/presplit/build.rs @@ -13,7 +13,7 @@ fn verify_presplit_fixed_conditions( binding: &PresplitOfferBinding, ) -> SignerResult<()> { if built.fixed_conditions_tree_hash != binding.fixed_conditions_tree_hash { - return Err(SignerError::Driver( + return Err(SignerError::driver( "presplit fixed conditions hash mismatch".to_string(), )); } diff --git a/greenfloor-engine/src/offer/presplit/orphan_discover.rs b/greenfloor-engine/src/offer/presplit/orphan_discover.rs index ca071b0b..10235cec 100644 --- a/greenfloor-engine/src/offer/presplit/orphan_discover.rs +++ b/greenfloor-engine/src/offer/presplit/orphan_discover.rs @@ -10,7 +10,7 @@ use clvmr::serde::node_from_bytes; use clvmr::{Allocator, NodePtr}; use crate::coinset::{cat_child_p2_create_coin_memos, fetch_parent_coin_spend}; -use crate::error::{SignerError, SignerResult}; +use crate::error::{SignerError, SignerResult, TransportError}; use crate::hex::{hex_to_bytes32, normalize_hex_id, tree_hash_to_hex}; use crate::offer::presplit::resolve_member_fixed_conditions_hash_for_binding; use crate::vault_coinset_scan::types::CoinRow; @@ -108,10 +108,10 @@ fn candidate_fixed_hashes_in_allocator( let direct = tree_hash(allocator, conditions_ptr); let q = allocator .new_atom(&[1]) - .map_err(|err| SignerError::Driver(err.to_string()))?; + .map_err(|err| SignerError::driver(err.to_string()))?; let quoted = allocator .new_pair(q, conditions_ptr) - .map_err(|err| SignerError::Driver(err.to_string()))?; + .map_err(|err| SignerError::driver(err.to_string()))?; Ok([direct, tree_hash(allocator, quoted)]) } @@ -142,7 +142,7 @@ pub(crate) fn recover_from_parent_spend( }; let mut allocator = Allocator::new(); let memos_ptr = node_from_bytes(&mut allocator, &memos_bytes) - .map_err(|err| SignerError::Driver(err.to_string()))?; + .map_err(|err| SignerError::driver(err.to_string()))?; let Some(conditions_ptr) = second_memo_item(&allocator, memos_ptr) else { return Ok(( None, @@ -185,7 +185,7 @@ async fn recover_fixed_hash_for_row( fn soft_fail_detail(err: SignerError) -> SignerResult { match err { // Transport / API failures should fail the whole discover, not hide as per-row noise. - SignerError::Coinset(_) => Err(err), + SignerError::Transport(TransportError::Coinset(_)) => Err(err), other => Ok(other.to_string()), } } @@ -198,7 +198,7 @@ fn soft_fail_detail(err: SignerError) -> SignerResult { /// /// # Errors /// -/// Returns [`SignerError::Coinset`] when Coinset access fails for a candidate. +/// Returns [`crate::error::TransportError::Coinset`] when Coinset access fails for a candidate. pub async fn discover_orphan_presplit_candidates( client: &CoinsetClient, launcher_id: Bytes32, @@ -317,9 +317,12 @@ mod tests { #[test] fn soft_fail_detail_propagates_coinset_transport() { - let err = soft_fail_detail(SignerError::Coinset("down".to_string())).expect_err("coinset"); - assert!(matches!(err, SignerError::Coinset(_))); - let detail = soft_fail_detail(SignerError::Driver("bad puzzle".to_string())).expect("soft"); + let err = soft_fail_detail(SignerError::coinset("down".to_string())).expect_err("coinset"); + assert!(matches!( + err, + SignerError::Transport(TransportError::Coinset(_)) + )); + let detail = soft_fail_detail(SignerError::driver("bad puzzle".to_string())).expect("soft"); assert!(detail.contains("bad puzzle")); } diff --git a/greenfloor-engine/src/offer/presplit/split.rs b/greenfloor-engine/src/offer/presplit/split.rs index 0ef4a0e7..98493bb4 100644 --- a/greenfloor-engine/src/offer/presplit/split.rs +++ b/greenfloor-engine/src/offer/presplit/split.rs @@ -108,7 +108,7 @@ where let delegated = ctx.delegated_spend(conditions).map_err(SignerError::from)?; let nonce = vault_ctx .infer_nonce_for_p2_hash(source_cat.info.p2_puzzle_hash) - .ok_or(SignerError::Driver( + .ok_or(SignerError::driver( "failed to infer vault nonce for cat p2 puzzle hash".to_string(), ))?; let inner_spend = build_vault_cat_inner_spend( diff --git a/greenfloor-engine/src/offer/publish/dexie/mod.rs b/greenfloor-engine/src/offer/publish/dexie/mod.rs index ed24f5e1..63d9bb51 100644 --- a/greenfloor-engine/src/offer/publish/dexie/mod.rs +++ b/greenfloor-engine/src/offer/publish/dexie/mod.rs @@ -3,6 +3,7 @@ use serde_json::json; use crate::adapters::{DexieClient, DexieResponse}; use crate::cycle::{dexie_invalid_offer_retry_sleep, dexie_invalid_offer_should_retry}; use crate::error::SignerResult; +use crate::offer::lifecycle::reconcile_prep::{fetch_dexie_offer, DexieFetchMode, DexieOfferFetch}; use super::{dexie_offer_asset_expectation_error, ExpectedPublishAssetFields}; @@ -97,40 +98,30 @@ pub(super) async fn poll_dexie_offer_visibility_once( offer_id: &str, expected: &ExpectedPublishAssetFields, ) -> OfferVisibilityPoll { - let payload = match dexie.get_offer(offer_id).await { - Ok(payload) => payload, - Err(err) => { - return OfferVisibilityPoll::Retry(format!("dexie_get_offer_error:{err}")); - } - }; - if payload.is_explicit_failure() { - let error = if payload.error_text().is_empty() { - "dexie_offer_not_visible_after_publish".to_string() - } else { - payload.error_text().to_string() + let fetched = fetch_dexie_offer(dexie, offer_id, DexieFetchMode::HealStrict).await; + if !fetched.visibility_ready() { + return match fetched { + DexieOfferFetch::Missing(err) => OfferVisibilityPoll::Retry(err), + DexieOfferFetch::Mismatch => { + OfferVisibilityPoll::Retry("dexie_offer_visibility_payload_mismatch".to_string()) + } + DexieOfferFetch::LookupError(err) => { + OfferVisibilityPoll::Retry(format!("dexie_get_offer_error:{err}")) + } + DexieOfferFetch::Found(_) => unreachable!("visibility_ready is true for Found"), }; - return OfferVisibilityPoll::Retry(error); } - let offer_payload = payload.offer_payload(); - let visible_id = offer_payload - .and_then(serde_json::Value::as_object) - .and_then(|obj| obj.get("id")) - .and_then(serde_json::Value::as_str) - .unwrap_or("") - .trim(); - if visible_id != offer_id { - return OfferVisibilityPoll::Retry("dexie_offer_visibility_payload_mismatch".to_string()); - } - if let Some(offer_obj) = offer_payload.and_then(serde_json::Value::as_object) { - if let Some(asset_error) = dexie_offer_asset_expectation_error( - offer_obj.get("offered").unwrap_or(&serde_json::Value::Null), - offer_obj - .get("requested") - .unwrap_or(&serde_json::Value::Null), - expected, - ) { - return OfferVisibilityPoll::Failed(asset_error); - } + let DexieOfferFetch::Found(offer_obj) = fetched else { + unreachable!("visibility_ready is true for Found"); + }; + if let Some(asset_error) = dexie_offer_asset_expectation_error( + offer_obj.get("offered").unwrap_or(&serde_json::Value::Null), + offer_obj + .get("requested") + .unwrap_or(&serde_json::Value::Null), + expected, + ) { + return OfferVisibilityPoll::Failed(asset_error); } OfferVisibilityPoll::Ready } diff --git a/greenfloor-engine/src/offer/publish/dexie/visibility.rs b/greenfloor-engine/src/offer/publish/dexie/visibility.rs index 39b251ab..bb367666 100644 --- a/greenfloor-engine/src/offer/publish/dexie/visibility.rs +++ b/greenfloor-engine/src/offer/publish/dexie/visibility.rs @@ -1,8 +1,8 @@ +use crate::adapters::is_dexie_offer_missing_error_text; + #[must_use] pub(super) fn is_transient_dexie_visibility_404_error(error: &str) -> bool { - let normalized = error.trim().to_ascii_lowercase(); - (normalized.contains("dexie_get_offer_error") && normalized.contains("404")) - || normalized.contains("dexie_http_error:404") + is_dexie_offer_missing_error_text(error) } #[cfg(test)] diff --git a/greenfloor-engine/src/offer/reclaim.rs b/greenfloor-engine/src/offer/reclaim.rs index ff547b3d..c4db5dc3 100644 --- a/greenfloor-engine/src/offer/reclaim.rs +++ b/greenfloor-engine/src/offer/reclaim.rs @@ -86,7 +86,7 @@ fn append_cancellable_input_reclaim( CancellableMakerInput::VaultCatDirect { cat } => { let nonce = vault_ctx .infer_nonce_for_p2_hash(cat.info.p2_puzzle_hash) - .ok_or(SignerError::Driver( + .ok_or(SignerError::driver( "failed to infer vault nonce for reclaim cat".to_string(), ))?; let inner_spend = build_vault_change_inner_spend( diff --git a/greenfloor-engine/src/offer/types.rs b/greenfloor-engine/src/offer/types.rs index 6fd71c58..3bf84f34 100644 --- a/greenfloor-engine/src/offer/types.rs +++ b/greenfloor-engine/src/offer/types.rs @@ -256,6 +256,61 @@ impl OfferExecutionMode { _ => None, } } + + /// Persist/watch/cancel shape for this assembler mode (legacy NULL mode is not this path). + #[must_use] + pub fn posted_shape(self) -> PostedOfferShape { + match self { + Self::Direct => PostedOfferShape::Direct, + Self::PresplitNew | Self::PresplitExisting => PostedOfferShape::Presplit, + } + } +} + +/// Posted maker shape used by persist, watch-seed, cancel, and reclaim. +/// +/// `OfferInput` is the operator request. [`OfferExecutionMode`] is the assembler that ran. +/// This enum is the single downstream answer to “is this presplit-like?” +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PostedOfferShape { + Direct, + Presplit, +} + +impl PostedOfferShape { + /// Derive once from persisted execution mode + optional fixed CONDITIONS hash. + #[must_use] + pub fn from_execution( + execution_mode: Option, + fixed_delegated_puzzle_hash: Option<&str>, + ) -> Self { + match execution_mode { + Some(mode) => mode.posted_shape(), + None => { + if fixed_delegated_puzzle_hash + .map(str::trim) + .is_some_and(|hash| !hash.is_empty()) + { + Self::Presplit + } else { + Self::Direct + } + } + } + } + + #[must_use] + pub fn from_metadata(meta: &StoredOfferCancelMetadata) -> Self { + Self::from_execution( + meta.execution_mode, + meta.fields.fixed_delegated_puzzle_hash.as_deref(), + ) + } + + #[must_use] + pub fn is_presplit(self) -> bool { + matches!(self, Self::Presplit) + } } /// Cancel hints persisted at offer post time (Direct and presplit execution modes). @@ -270,7 +325,7 @@ pub struct OfferCancelFields { pub fixed_delegated_puzzle_hash: Option, /// On-chain maker coin puzzle hash (CAT outer, XCH p2, or presplit CONDITIONS). /// Persisted for cancel metadata; per-offer `kind='p2'` watches are seeded only - /// when [`StoredOfferCancelMetadata::is_presplit_like`] is true. + /// when [`PostedOfferShape::from_metadata`] is presplit. pub maker_puzzle_hash: Option, } @@ -306,37 +361,6 @@ pub struct StoredOfferCancelMetadata { pub execution_mode: Option, } -impl StoredOfferCancelMetadata { - /// Whether this row is treated as a presplit offer for cancel / watch policy. - /// - /// Explicit Direct → false. Explicit Presplit → true. NULL `execution_mode` with - /// a non-empty `fixed_delegated_puzzle_hash` → true (legacy cancel rule). Direct - /// receive coins share vault inventory puzzle hashes, so only presplit-like rows - /// may seed per-offer `kind='p2'` watches (ADR 0019). - #[must_use] - pub fn is_presplit_like(&self) -> bool { - Self::is_presplit_like_parts( - self.execution_mode, - self.fields.fixed_delegated_puzzle_hash.as_deref(), - ) - } - - /// Same gate as [`Self::is_presplit_like`] without building a metadata struct. - #[must_use] - pub fn is_presplit_like_parts( - execution_mode: Option, - fixed_delegated_puzzle_hash: Option<&str>, - ) -> bool { - match execution_mode { - Some(OfferExecutionMode::Direct) => false, - Some(OfferExecutionMode::PresplitNew | OfferExecutionMode::PresplitExisting) => true, - None => fixed_delegated_puzzle_hash - .map(str::trim) - .is_some_and(|hash| !hash.is_empty()), - } - } -} - #[derive(Debug, Clone, serde::Serialize)] pub struct CreateOfferResult { pub offer: String, @@ -453,22 +477,18 @@ mod tests { } #[test] - fn is_presplit_like_matches_cancel_legacy_null_mode() { - assert!(StoredOfferCancelMetadata::is_presplit_like_parts( - Some(OfferExecutionMode::PresplitExisting), - None - )); - assert!(!StoredOfferCancelMetadata::is_presplit_like_parts( + fn posted_shape_matches_cancel_legacy_null_mode() { + assert!( + PostedOfferShape::from_execution(Some(OfferExecutionMode::PresplitExisting), None) + .is_presplit() + ); + assert!(!PostedOfferShape::from_execution( Some(OfferExecutionMode::Direct), - Some(&"aa".repeat(32)) - )); - assert!(StoredOfferCancelMetadata::is_presplit_like_parts( - None, - Some(&"aa".repeat(32)) - )); - assert!(!StoredOfferCancelMetadata::is_presplit_like_parts( - None, None - )); + Some(&*"aa".repeat(32)) + ) + .is_presplit()); + assert!(PostedOfferShape::from_execution(None, Some(&*"aa".repeat(32))).is_presplit()); + assert!(!PostedOfferShape::from_execution(None, None).is_presplit()); let legacy = StoredOfferCancelMetadata { fields: OfferCancelFields::from_presplit_build( @@ -478,12 +498,12 @@ mod tests { ), execution_mode: None, }; - assert!(legacy.is_presplit_like()); + assert!(PostedOfferShape::from_metadata(&legacy).is_presplit()); let direct = StoredOfferCancelMetadata { fields: OfferCancelFields::from_direct_build("coin".into(), "bb".repeat(32)), execution_mode: Some(OfferExecutionMode::Direct), }; - assert!(!direct.is_presplit_like()); + assert!(!PostedOfferShape::from_metadata(&direct).is_presplit()); } #[test] diff --git a/greenfloor-engine/src/storage/sqlite/mod.rs b/greenfloor-engine/src/storage/sqlite/mod.rs index 6cc3df8c..d5052de3 100644 --- a/greenfloor-engine/src/storage/sqlite/mod.rs +++ b/greenfloor-engine/src/storage/sqlite/mod.rs @@ -148,6 +148,19 @@ pub struct OfferStateListRow { pub publish_venue: Option, } +impl OfferStateListRow { + /// Parse persisted `state` once at the `SQLite` read boundary. + /// + /// # Errors + /// + /// Returns an error when `state` is not a known reconcile/lifecycle value. + pub fn reconcile_state( + &self, + ) -> Result { + crate::cycle::ReconcileState::parse(&self.state) + } +} + pub use tx_signals::TxSignalIngress; #[derive(Debug, Clone, Default)] diff --git a/greenfloor-engine/src/storage/sqlite/offer_cancel.rs b/greenfloor-engine/src/storage/sqlite/offer_cancel.rs index 928df604..46d80166 100644 --- a/greenfloor-engine/src/storage/sqlite/offer_cancel.rs +++ b/greenfloor-engine/src/storage/sqlite/offer_cancel.rs @@ -344,7 +344,7 @@ impl SqliteStore { market_id: &str, prior_state: &str, ) -> SignerResult<()> { - self.upsert_offer_state(offer_id, market_id, prior_state, None) + self.upsert_offer_state_at(offer_id, market_id, prior_state, None, &super::utcnow_iso()) } /// Persist committed `cancel_submitted` (prepare + observe cancel tx) for tests. diff --git a/greenfloor-engine/src/storage/sqlite/offer_coin_watches/mod.rs b/greenfloor-engine/src/storage/sqlite/offer_coin_watches/mod.rs index b9040b06..ba60b4c0 100644 --- a/greenfloor-engine/src/storage/sqlite/offer_coin_watches/mod.rs +++ b/greenfloor-engine/src/storage/sqlite/offer_coin_watches/mod.rs @@ -385,7 +385,7 @@ impl SqliteStore { } let sql = format!( r" - SELECT DISTINCT {state_cols}, w.kind + SELECT DISTINCT {state_cols}, w.kind AS watch_kind FROM offer_coin_watches w INNER JOIN offer_state s ON s.offer_id = w.offer_id WHERE w.coin_id IN ({placeholders}) @@ -408,7 +408,7 @@ impl SqliteStore { let state = read_offer_state_list_row(row) .map_err(|err| db_err("offer_coin_watches match state", err))?; let kind_str: String = row - .get(8) + .get("watch_kind") .map_err(|err| db_err("offer_coin_watches match kind", err))?; let Some(kind) = WatchKind::parse(&kind_str) else { tracing::warn!( diff --git a/greenfloor-engine/src/storage/sqlite/offers.rs b/greenfloor-engine/src/storage/sqlite/offers.rs index 7e7e075f..91fe7ce5 100644 --- a/greenfloor-engine/src/storage/sqlite/offers.rs +++ b/greenfloor-engine/src/storage/sqlite/offers.rs @@ -71,9 +71,12 @@ impl SqliteStore { /// Upsert offer state. /// + /// `state` must be a known [`ReconcileState`]. Use [`Self::upsert_offer_state_at`] + /// only for timestamped writes of an already-validated state string. + /// /// # Errors /// - /// Returns an error if the operation fails. + /// Returns an error if `state` is unknown or the write fails. pub fn upsert_offer_state( &self, offer_id: &str, @@ -81,7 +84,8 @@ impl SqliteStore { state: &str, last_seen_status: Option, ) -> SignerResult<()> { - self.upsert_offer_state_at(offer_id, market_id, state, last_seen_status, &utcnow_iso()) + let parsed = ReconcileState::parse(state)?; + self.upsert_offer_reconcile_state(offer_id, market_id, &parsed, last_seen_status) } /// Upsert offer state using a typed reconcile state. @@ -96,7 +100,13 @@ impl SqliteStore { state: &ReconcileState, last_seen_status: Option, ) -> SignerResult<()> { - self.upsert_offer_state(offer_id, market_id, &state.as_str(), last_seen_status) + self.upsert_offer_state_at( + offer_id, + market_id, + &state.as_str(), + last_seen_status, + &utcnow_iso(), + ) } /// Upsert offer state at an explicit timestamp. diff --git a/greenfloor-engine/src/test_support/bootstrap_shape.rs b/greenfloor-engine/src/test_support/bootstrap_shape.rs index 493363a5..2269a2b3 100644 --- a/greenfloor-engine/src/test_support/bootstrap_shape.rs +++ b/greenfloor-engine/src/test_support/bootstrap_shape.rs @@ -193,6 +193,7 @@ pub fn combine_first_shape_context( fee_mojos: 0, fee_source: String::new(), fee_lookup_error: None, + combine_input_cap, #[cfg(test)] test_overrides: SignerDenominationTestOverrides::default(), } diff --git a/greenfloor-engine/src/vault/materialize.rs b/greenfloor-engine/src/vault/materialize.rs index 6a948bd9..f8450a67 100644 --- a/greenfloor-engine/src/vault/materialize.rs +++ b/greenfloor-engine/src/vault/materialize.rs @@ -60,7 +60,7 @@ where continue; }; let chia_sdk_driver::SpendKind::Conditions(spend) = kind else { - return Err(SignerError::Driver( + return Err(SignerError::driver( "unexpected settlement spend in vault cat spend".to_string(), )); }; @@ -69,7 +69,7 @@ where .map_err(SignerError::from)?; let nonce = vault_ctx .infer_nonce_for_p2_hash(cat.info.p2_puzzle_hash) - .ok_or(SignerError::Driver( + .ok_or(SignerError::driver( "failed to infer vault nonce for cat p2 puzzle hash".to_string(), ))?; let inner_spend = build_vault_cat_inner_spend( @@ -82,7 +82,7 @@ where cat_spends.push(CatSpend::new(cat, inner_spend)); } if cat_spends.is_empty() { - return Err(SignerError::Driver( + return Err(SignerError::driver( "no cat spends produced for vault transaction".to_string(), )); } diff --git a/greenfloor-engine/src/vault/messages.rs b/greenfloor-engine/src/vault/messages.rs index 5eb84bcc..933102dd 100644 --- a/greenfloor-engine/src/vault/messages.rs +++ b/greenfloor-engine/src/vault/messages.rs @@ -18,13 +18,13 @@ pub fn extract_mode23_receive_messages( for coin_spend in ctx.iter() { let mut allocator = Allocator::new(); let puzzle = node_from_bytes(&mut allocator, coin_spend.puzzle_reveal.as_ref()) - .map_err(|err| SignerError::Driver(err.to_string()))?; + .map_err(|err| SignerError::driver(err.to_string()))?; let solution = node_from_bytes(&mut allocator, coin_spend.solution.as_ref()) - .map_err(|err| SignerError::Driver(err.to_string()))?; + .map_err(|err| SignerError::driver(err.to_string()))?; let output = run_puzzle(&mut allocator, puzzle, solution) - .map_err(|err| SignerError::Driver(err.to_string()))?; + .map_err(|err| SignerError::driver(err.to_string()))?; let conditions = Conditions::::from_clvm(&allocator, output) - .map_err(|err| SignerError::Driver(err.to_string()))?; + .map_err(|err| SignerError::driver(err.to_string()))?; for condition in conditions.iter() { if let Condition::ReceiveMessage(receive) = condition { if receive.mode == 23 { diff --git a/greenfloor-engine/src/vault_coinset_scan/asset_trace.rs b/greenfloor-engine/src/vault_coinset_scan/asset_trace.rs index 32758758..04a85aa6 100644 --- a/greenfloor-engine/src/vault_coinset_scan/asset_trace.rs +++ b/greenfloor-engine/src/vault_coinset_scan/asset_trace.rs @@ -282,8 +282,15 @@ fn build_chains(coins: &[AssetTraceCoin]) -> Vec { let mut chains = Vec::new(); let mut path = Vec::new(); + let mut visited = HashSet::new(); for reception_id in &reception_ids { - walk_chain(reception_id, &mut path, &coins_by_id, &mut chains); + walk_chain( + reception_id, + &mut path, + &coins_by_id, + &mut chains, + &mut visited, + ); } chains.sort_by(|left, right| { left.path @@ -300,8 +307,18 @@ fn walk_chain( path: &mut Vec, coins_by_id: &HashMap<&str, &AssetTraceCoin>, chains: &mut Vec, + visited: &mut HashSet, ) { + if !visited.insert(node.to_string()) { + chains.push(AssetTraceChain { + path: path.clone(), + terminal_role: AssetTraceRole::Internal, + terminal_amount_mojos: coins_by_id.get(node).map_or(0, |coin| coin.amount), + }); + return; + } let Some(coin) = coins_by_id.get(node) else { + visited.remove(node); return; }; path.push(node.to_string()); @@ -313,10 +330,11 @@ fn walk_chain( }); } else { for child in &coin.child_coin_ids { - walk_chain(child, path, coins_by_id, chains); + walk_chain(child, path, coins_by_id, chains, visited); } } path.pop(); + visited.remove(node); } #[cfg(test)] diff --git a/greenfloor-engine/src/vault_coinset_scan/hints.rs b/greenfloor-engine/src/vault_coinset_scan/hints.rs new file mode 100644 index 00000000..d3a6601b --- /dev/null +++ b/greenfloor-engine/src/vault_coinset_scan/hints.rs @@ -0,0 +1,123 @@ +//! CAT receive-address hints for vault Coinset discovery. + +use crate::coinset::puzzle_hash_hex_for_receive_address; +use crate::config::{CatTickerIndex, MarketConfig}; +use crate::error::SignerResult; +use crate::hex::normalize_hex_id; + +fn market_matches_cat_asset( + ticker_index: &CatTickerIndex, + market: &MarketConfig, + resolved_asset_id: &str, + requested_asset: &str, +) -> bool { + let requested = requested_asset.trim().to_ascii_lowercase(); + for label in [market.base_asset.as_str(), market.base_symbol.as_str()] { + if label.trim().to_ascii_lowercase() == requested { + return true; + } + if ticker_index.label_refers_to_asset(label, resolved_asset_id) { + return true; + } + } + false +} + +/// Collect unique receive p2 hashes for markets whose base matches the CAT asset. +/// +/// # Errors +/// +/// Returns an error if a matching market's receive address cannot be decoded. +pub fn cat_receive_hint_puzzle_hashes( + markets: &[MarketConfig], + ticker_index: &CatTickerIndex, + resolved_asset_id: &str, + requested_asset: &str, +) -> SignerResult> { + let resolved = normalize_hex_id(resolved_asset_id); + let mut hashes = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for market in markets { + if !market_matches_cat_asset(ticker_index, market, &resolved, requested_asset) + || market.receive_address.trim().is_empty() + { + continue; + } + let hash = normalize_hex_id(&puzzle_hash_hex_for_receive_address( + &market.receive_address, + )?); + if !hash.is_empty() && seen.insert(hash.clone()) { + hashes.push(hash); + } + } + Ok(hashes) +} + +#[cfg(test)] +mod tests { + use super::{cat_receive_hint_puzzle_hashes, market_matches_cat_asset}; + use crate::coinset::puzzle_hash_hex_for_receive_address; + use crate::config::{CatTickerIndex, MarketConfig}; + use crate::hex::normalize_hex_id; + use std::collections::{HashMap, HashSet}; + + #[test] + fn market_matches_cat_asset_via_base_symbol() { + let asset_id = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + let mut by_ticker = HashMap::new(); + by_ticker.insert("byc".to_string(), HashSet::from([asset_id.to_string()])); + let index = CatTickerIndex { + by_ticker, + symbols_by_asset_id: std::collections::BTreeMap::default(), + }; + let market = MarketConfig { + market_id: "m".to_string(), + enabled: true, + unique_maker_coins: true, + base_asset: "unrelated".to_string(), + base_symbol: "BYC".to_string(), + quote_asset: "xch".to_string(), + quote_asset_type: "volatile".to_string(), + receive_address: String::new(), + signer_key_id: "k".to_string(), + mode: "one_sided".to_string(), + pricing: crate::config::MarketPricing::default(), + cancel_move_threshold_bps: None, + ladders: HashMap::new(), + }; + assert!(market_matches_cat_asset(&index, &market, asset_id, "other")); + } + + #[test] + fn cat_receive_hints_match_ticker_market_when_operator_passes_asset_id() { + let asset_id = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + let mut by_ticker = HashMap::new(); + by_ticker.insert("byc".to_string(), HashSet::from([asset_id.to_string()])); + let index = CatTickerIndex { + by_ticker, + symbols_by_asset_id: std::collections::BTreeMap::default(), + }; + let receive = "xch1a0t57qn6uhe7tzjlxlhwy2qgmuxvvft8gnfzmg5detg0q9f3yc3s2apz0h".to_string(); + let expected = + normalize_hex_id(&puzzle_hash_hex_for_receive_address(&receive).expect("receive p2")); + let market = MarketConfig { + market_id: "byc-xch".to_string(), + enabled: true, + unique_maker_coins: true, + base_asset: "BYC".to_string(), + base_symbol: "BYC".to_string(), + quote_asset: "xch".to_string(), + quote_asset_type: "volatile".to_string(), + receive_address: receive, + signer_key_id: "k".to_string(), + mode: "one_sided".to_string(), + pricing: crate::config::MarketPricing::default(), + cancel_move_threshold_bps: None, + ladders: HashMap::new(), + }; + + let hashes = + cat_receive_hint_puzzle_hashes(&[market], &index, asset_id, asset_id).expect("hints"); + assert_eq!(hashes, vec![expected]); + } +} diff --git a/greenfloor-engine/src/vault_coinset_scan/mod.rs b/greenfloor-engine/src/vault_coinset_scan/mod.rs index 79cc659f..4af59cc4 100644 --- a/greenfloor-engine/src/vault_coinset_scan/mod.rs +++ b/greenfloor-engine/src/vault_coinset_scan/mod.rs @@ -7,6 +7,7 @@ pub mod cli; pub mod dust; #[cfg(test)] mod dust_lineage_test; +pub mod hints; pub mod launcher; pub mod metadata; pub mod request; @@ -21,6 +22,7 @@ pub use dust::{ dust_coins_from_scan, plan_dust_batches, plan_dust_from_scan_with_lineage, prove_dust_coins_lineage, DustBatchPlan, DustCoin, DustCombineBatch, DustPlan, ProvenDustCoin, }; +pub use hints::cat_receive_hint_puzzle_hashes; pub use launcher::{ cache_resolved_launcher_id, resolve_launcher_id, LauncherIdSource, ResolveLauncherIdParams, ResolvedLauncherId, diff --git a/greenfloor-engine/src/vault_coinset_scan/request.rs b/greenfloor-engine/src/vault_coinset_scan/request.rs index 7a8f363c..1250ec3b 100644 --- a/greenfloor-engine/src/vault_coinset_scan/request.rs +++ b/greenfloor-engine/src/vault_coinset_scan/request.rs @@ -1,6 +1,7 @@ use std::collections::{BTreeMap, HashSet}; use std::path::{Path, PathBuf}; +use crate::error::{SignerError, SignerResult}; use crate::hex::normalize_hex_id; use crate::vault_coinset_scan::types::AssetTypeFilter; @@ -101,6 +102,43 @@ impl MemberDiscovery { } => *empty_batch_stop, } } + + /// Discovery plan for `vault-asset-trace`: XCH walks member nonces; CAT uses receive hints + /// and optionally a nonce walk when `--max-nonce` is set. + /// + /// # Errors + /// + /// Returns an error when CAT tracing has neither receive hints nor `--max-nonce`. + pub fn for_vault_asset_trace( + asset_type: AssetTypeFilter, + max_nonce: Option, + cat_hint_puzzle_hashes: Vec, + ) -> SignerResult { + match asset_type { + AssetTypeFilter::Xch | AssetTypeFilter::All => { + Ok(Self::nonces(max_nonce.unwrap_or(100))) + } + AssetTypeFilter::Cat => match max_nonce { + None => { + if cat_hint_puzzle_hashes.is_empty() { + return Err(SignerError::Other( + "vault-asset-trace CAT path needs a market receive_address for the asset, \ + or pass --max-nonce N to scan vault member nonces" + .to_string(), + )); + } + Ok(Self::Hints { + puzzle_hashes: cat_hint_puzzle_hashes, + }) + } + Some(max_nonce) => Ok(Self::HintsThenNonces { + puzzle_hashes: cat_hint_puzzle_hashes, + max_nonce, + empty_batch_stop: EmptyBatchStop::Always, + }), + }, + } + } } #[derive(Debug, Clone)] @@ -261,4 +299,56 @@ mod tests { assert_eq!(plan.empty_batch_stop(), EmptyBatchStop::Always); assert_eq!(plan.hint_puzzle_hashes().len(), 1); } + + #[test] + fn xch_discovery_is_nonce_walk_without_hints() { + let plan = MemberDiscovery::for_vault_asset_trace( + AssetTypeFilter::Xch, + None, + vec!["should-be-ignored".to_string()], + ) + .expect("xch plan"); + assert!(matches!( + plan, + MemberDiscovery::Nonces { max_nonce: 100, .. } + )); + assert!(plan.hint_puzzle_hashes().is_empty()); + } + + #[test] + fn cat_discovery_defaults_to_hints_only() { + let hashes = vec!["aa".repeat(32)]; + let plan = + MemberDiscovery::for_vault_asset_trace(AssetTypeFilter::Cat, None, hashes.clone()) + .expect("cat hints"); + assert_eq!( + plan, + MemberDiscovery::Hints { + puzzle_hashes: hashes + } + ); + } + + #[test] + fn cat_discovery_without_hints_or_nonce_errors() { + let err = MemberDiscovery::for_vault_asset_trace(AssetTypeFilter::Cat, None, Vec::new()) + .expect_err("needs hints or max-nonce"); + assert!(err.to_string().contains("receive_address")); + } + + #[test] + fn cat_discovery_with_max_nonce_uses_hints_then_nonces() { + let hashes = vec!["aa".repeat(32)]; + let plan = + MemberDiscovery::for_vault_asset_trace(AssetTypeFilter::Cat, Some(7), hashes.clone()) + .expect("cat plan"); + assert_eq!( + plan, + MemberDiscovery::HintsThenNonces { + puzzle_hashes: hashes, + max_nonce: 7, + empty_batch_stop: EmptyBatchStop::Always, + } + ); + } } diff --git a/greenfloor-engine/tests/cat_parse_replay.rs b/greenfloor-engine/tests/cat_parse_replay.rs index 0aeee55a..112725fa 100644 --- a/greenfloor-engine/tests/cat_parse_replay.rs +++ b/greenfloor-engine/tests/cat_parse_replay.rs @@ -46,13 +46,13 @@ fn parent_spend_creates_child( ) -> SignerResult { let mut allocator = Allocator::new(); let puzzle = node_from_bytes(&mut allocator, parent_spend.puzzle_reveal.as_ref()) - .map_err(|err| greenfloor_engine::error::SignerError::Driver(err.to_string()))?; + .map_err(|err| greenfloor_engine::error::SignerError::driver(err.to_string()))?; let solution = node_from_bytes(&mut allocator, parent_spend.solution.as_ref()) - .map_err(|err| greenfloor_engine::error::SignerError::Driver(err.to_string()))?; + .map_err(|err| greenfloor_engine::error::SignerError::driver(err.to_string()))?; let output = run_puzzle(&mut allocator, puzzle, solution) - .map_err(|err| greenfloor_engine::error::SignerError::Driver(err.to_string()))?; + .map_err(|err| greenfloor_engine::error::SignerError::driver(err.to_string()))?; let conditions = Conditions::::from_clvm(&allocator, output) - .map_err(|err| greenfloor_engine::error::SignerError::Driver(err.to_string()))?; + .map_err(|err| greenfloor_engine::error::SignerError::driver(err.to_string()))?; for condition in conditions.iter() { if let Condition::CreateCoin(create) = condition { let created = Coin::new(parent_coin.coin_id(), create.puzzle_hash, create.amount); diff --git a/greenfloor-engine/tests/sqlite/offer_state.rs b/greenfloor-engine/tests/sqlite/offer_state.rs index 78d54bdb..e687bbc6 100644 --- a/greenfloor-engine/tests/sqlite/offer_state.rs +++ b/greenfloor-engine/tests/sqlite/offer_state.rs @@ -119,7 +119,7 @@ fn upsert_offer_state_null_last_seen_status() { let dir = tempfile::tempdir().expect("tempdir"); let store = open_store(&dir.path().join("gf.sqlite")); store - .upsert_offer_state("offer-2", "m1", "unknown", None) + .upsert_offer_state_at("offer-2", "m1", "unknown", None, "2020-01-01T00:00:00Z") .expect("upsert"); let rows = store.list_offer_states(None, 10).expect("list"); assert_eq!(rows.len(), 1); From 465cd3ff3083da17a0daee221556f2418739d6c6 Mon Sep 17 00:00:00 2001 From: Gene Hoffman Date: Thu, 13 Aug 2026 13:51:44 -0700 Subject: [PATCH 2/7] Split SignerError by domain and keep Dexie and HTTP outcomes typed. CAT picking, Dexie fetch, and watch seeding each have one owner, and transport failures stay structured instead of being flattened to strings and re-parsed. --- .../src/adapters/dexie/client.rs | 38 +- .../src/adapters/dexie/errors.rs | 34 -- greenfloor-engine/src/adapters/dexie/mod.rs | 2 - greenfloor-engine/src/adapters/http_json.rs | 43 +- greenfloor-engine/src/adapters/mod.rs | 4 +- greenfloor-engine/src/adapters/splash/mod.rs | 17 +- .../src/coin_ops/execution/context.rs | 6 +- .../src/coin_ops/execution/test_overrides.rs | 2 +- greenfloor-engine/src/coin_ops/scalars.rs | 8 +- .../src/coin_ops/vault_controlled.rs | 4 +- greenfloor-engine/src/coinset/cats/list.rs | 4 +- greenfloor-engine/src/coinset/cats/resolve.rs | 12 +- .../src/coinset/coin_select/mod.rs | 131 +++-- .../src/coinset/coin_select/tests.rs | 35 +- greenfloor-engine/src/coinset/mod.rs | 2 +- greenfloor-engine/src/coinset/poll.rs | 11 +- greenfloor-engine/src/coinset/presplit.rs | 21 +- greenfloor-engine/src/coinset/spent_verify.rs | 13 +- greenfloor-engine/src/coinset/vault_fetch.rs | 15 +- greenfloor-engine/src/config/program.rs | 14 +- greenfloor-engine/src/config/signer.rs | 10 +- greenfloor-engine/src/config/yaml_fields.rs | 17 +- greenfloor-engine/src/cycle/mod.rs | 1 - greenfloor-engine/src/cycle/orchestration.rs | 10 - greenfloor-engine/src/cycle/retry.rs | 36 +- greenfloor-engine/src/daemon/cli.rs | 4 +- greenfloor-engine/src/daemon/lock.rs | 10 +- .../src/daemon/offer_dispatch/parallel.rs | 13 +- .../daemon/offer_dispatch/test_overrides.rs | 10 +- .../offer_dispatch/tests/classify_tests.rs | 15 +- greenfloor-engine/src/daemon/stale_sweep.rs | 86 ++-- greenfloor-engine/src/error/coin_ops.rs | 71 +++ greenfloor-engine/src/error/config.rs | 17 + greenfloor-engine/src/error/mod.rs | 458 ++++++------------ greenfloor-engine/src/error/offer.rs | 80 +++ greenfloor-engine/src/error/persistence.rs | 26 + greenfloor-engine/src/error/transport.rs | 40 ++ greenfloor-engine/src/error/vault.rs | 70 +++ greenfloor-engine/src/kms.rs | 45 +- greenfloor-engine/src/kms/runtime.rs | 6 +- greenfloor-engine/src/kms_cli.rs | 4 +- .../src/manager_cli/coin_op_loop/list.rs | 6 +- .../batch_plan_test.rs | 6 +- .../combine_market_cat_dust/batches.rs | 20 +- .../combine_market_cat_dust/execute_test.rs | 7 +- .../combine_market_cat_dust/mod.rs | 7 +- greenfloor-engine/src/offer/action.rs | 9 +- greenfloor-engine/src/offer/assemble.rs | 21 +- greenfloor-engine/src/offer/assets.rs | 10 +- greenfloor-engine/src/offer/cancel_input.rs | 30 +- greenfloor-engine/src/offer/codec.rs | 14 +- .../lifecycle/market_reconcile/augment.rs | 73 ++- greenfloor-engine/src/offer/lifecycle/mod.rs | 5 +- .../lifecycle/reconcile_prep/dexie_fetch.rs | 247 +++++----- .../src/offer/lifecycle/reconcile_prep/mod.rs | 4 +- .../lifecycle/reconcile_prep/watch_plan.rs | 24 +- .../src/offer/lifecycle/signal_apply.rs | 196 ++++++-- .../src/offer/lifecycle/transition.rs | 101 +--- greenfloor-engine/src/offer/maker_shape.rs | 169 +++++-- .../src/offer/operator/ensure_size/mod.rs | 6 +- .../signer_denomination/bootstrap_execute.rs | 17 +- .../operator/signer_denomination/planning.rs | 4 +- .../signer_denomination/split_submit.rs | 8 +- .../operator/signer_denomination/wait.rs | 17 +- .../src/offer/operator/unique_maker.rs | 78 ++- greenfloor-engine/src/offer/plan.rs | 33 +- .../src/offer/presplit/binding.rs | 15 +- .../src/offer/presplit/cancel_binding/mod.rs | 17 +- .../offer/presplit/cancel_binding/parse.rs | 10 +- greenfloor-engine/src/offer/presplit/split.rs | 11 +- greenfloor-engine/src/offer/pricing.rs | 9 +- .../src/offer/publish/dexie/mod.rs | 157 +++--- .../src/offer/publish/dexie/tests.rs | 96 +++- .../src/offer/publish/dexie/visibility.rs | 24 - greenfloor-engine/src/offer/reclaim.rs | 4 +- greenfloor-engine/src/offer/request.rs | 32 +- greenfloor-engine/src/offer/types.rs | 10 +- greenfloor-engine/src/storage/sqlite/mod.rs | 53 +- .../test_support/simulator/coinset_backend.rs | 21 +- .../src/test_support/simulator/harness.rs | 5 +- .../simulator/offer_cancel_roundtrips.rs | 22 +- greenfloor-engine/src/vault/cat_create.rs | 18 +- greenfloor-engine/src/vault/context.rs | 22 +- greenfloor-engine/src/vault/materialize.rs | 4 +- greenfloor-engine/src/vault/members/curves.rs | 29 +- greenfloor-engine/src/vault/members/hash.rs | 4 +- greenfloor-engine/src/vault/mixed_split.rs | 35 +- greenfloor-engine/src/vault/spend.rs | 25 +- greenfloor-engine/src/vault/threshold.rs | 8 +- .../src/vault_coinset_scan/dust.rs | 10 +- greenfloor-engine/tests/config/program.rs | 9 +- 91 files changed, 1899 insertions(+), 1308 deletions(-) delete mode 100644 greenfloor-engine/src/adapters/dexie/errors.rs create mode 100644 greenfloor-engine/src/error/coin_ops.rs create mode 100644 greenfloor-engine/src/error/config.rs create mode 100644 greenfloor-engine/src/error/offer.rs create mode 100644 greenfloor-engine/src/error/persistence.rs create mode 100644 greenfloor-engine/src/error/vault.rs delete mode 100644 greenfloor-engine/src/offer/publish/dexie/visibility.rs diff --git a/greenfloor-engine/src/adapters/dexie/client.rs b/greenfloor-engine/src/adapters/dexie/client.rs index 487d5568..3bbf67fa 100644 --- a/greenfloor-engine/src/adapters/dexie/client.rs +++ b/greenfloor-engine/src/adapters/dexie/client.rs @@ -138,7 +138,7 @@ impl DexieClient { &self, path: &str, timeout_secs: u64, - network_err_tag: &str, + network_err_tag: &'static str, ) -> SignerResult { http_json::get_json( &self.http, @@ -155,7 +155,7 @@ impl DexieClient { path: &str, body: Value, timeout_secs: u64, - network_err_tag: &str, + network_err_tag: &'static str, ) -> SignerResult { http_json::post_json( &self.http, @@ -201,22 +201,34 @@ mod tests { } #[test] - fn parse_response_body_http_error_returns_success_false() { - let payload = - DexieClient::parse_response_body(StatusCode::NOT_FOUND, "missing").expect("parse"); - assert_eq!( - payload.get("success").and_then(serde_json::Value::as_bool), - Some(false) - ); - assert_eq!( - payload.get("error").and_then(|v| v.as_str()), - Some("dexie_http_error:404:missing") - ); + fn parse_response_body_http_error_is_typed_status() { + use crate::error::{SignerError, TransportError}; + + let err = + DexieClient::parse_response_body(StatusCode::NOT_FOUND, "missing").expect_err("404"); + assert!(matches!( + err, + SignerError::Transport(TransportError::HttpStatus { + layer: "dexie_http_error", + status: 404, + .. + }) + )); + assert!(err.is_http_not_found()); } #[test] fn parse_response_body_invalid_json_is_err() { + use crate::error::{SignerError, TransportError}; + let err = DexieClient::parse_response_body(StatusCode::OK, "not-json").unwrap_err(); + assert!(matches!( + err, + SignerError::Transport(TransportError::Http { + layer: "dexie_json_error", + .. + }) + )); assert!(err.to_string().contains("dexie_json_error")); } } diff --git a/greenfloor-engine/src/adapters/dexie/errors.rs b/greenfloor-engine/src/adapters/dexie/errors.rs deleted file mode 100644 index a8259975..00000000 --- a/greenfloor-engine/src/adapters/dexie/errors.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Dexie error-text classification shared by lifecycle, publish, and stale sweep. - -/// Whether Dexie error text means the offer is missing (404 / not found). -#[must_use] -pub fn is_dexie_offer_missing_error_text(error_text: &str) -> bool { - let normalized = error_text.trim().to_ascii_lowercase(); - if normalized.is_empty() { - return false; - } - (normalized.contains("dexie_get_offer_error") && normalized.contains("404")) - || normalized.contains("dexie_http_error:404") - || (normalized.contains("http error 404") && normalized.contains("not found")) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn classifies_dexie_404_shapes() { - assert!(is_dexie_offer_missing_error_text( - "dexie_http_error:404 not found" - )); - assert!(is_dexie_offer_missing_error_text( - "dexie_get_offer_error:404 missing" - )); - assert!(is_dexie_offer_missing_error_text( - "HTTP error 404: Not Found" - )); - assert!(!is_dexie_offer_missing_error_text( - "dexie_offer_offered_asset_missing:cat" - )); - } -} diff --git a/greenfloor-engine/src/adapters/dexie/mod.rs b/greenfloor-engine/src/adapters/dexie/mod.rs index e04d734d..a7c4342c 100644 --- a/greenfloor-engine/src/adapters/dexie/mod.rs +++ b/greenfloor-engine/src/adapters/dexie/mod.rs @@ -1,10 +1,8 @@ mod client; -mod errors; mod response; mod tokens; mod view_url; pub use client::DexieClient; -pub use errors::is_dexie_offer_missing_error_text; pub use response::DexieResponse; pub use view_url::dexie_offer_view_url; diff --git a/greenfloor-engine/src/adapters/http_json.rs b/greenfloor-engine/src/adapters/http_json.rs index d33352f4..d3166c36 100644 --- a/greenfloor-engine/src/adapters/http_json.rs +++ b/greenfloor-engine/src/adapters/http_json.rs @@ -1,7 +1,7 @@ use std::time::Duration; use reqwest::{Client, StatusCode}; -use serde_json::{json, Value}; +use serde_json::Value; use crate::error::{SignerError, SignerResult}; @@ -17,7 +17,7 @@ pub(crate) async fn get_json( http: &Client, url: &str, timeout_secs: u64, - network_err_tag: &str, + network_err_tag: &'static str, tags: AdapterResponseTags, ) -> SignerResult { let response = http @@ -25,7 +25,7 @@ pub(crate) async fn get_json( .timeout(Duration::from_secs(timeout_secs)) .send() .await - .map_err(|err| SignerError::Other(format!("{network_err_tag}:{err}")))?; + .map_err(|err| SignerError::from_reqwest(network_err_tag, &err))?; parse_response(response, tags).await } @@ -34,7 +34,7 @@ pub(crate) async fn post_json( url: &str, body: Value, timeout_secs: u64, - network_err_tag: &str, + network_err_tag: &'static str, tags: AdapterResponseTags, ) -> SignerResult { let response = http @@ -43,7 +43,7 @@ pub(crate) async fn post_json( .timeout(Duration::from_secs(timeout_secs)) .send() .await - .map_err(|err| SignerError::Other(format!("{network_err_tag}:{err}")))?; + .map_err(|err| SignerError::from_reqwest(network_err_tag, &err))?; parse_response(response, tags).await } @@ -55,7 +55,7 @@ async fn parse_response( let body = response .text() .await - .map_err(|err| SignerError::Other(format!("{}:{err}", tags.read_error_prefix)))?; + .map_err(|err| SignerError::from_reqwest(tags.read_error_prefix, &err))?; parse_response_body(status, &body, tags) } @@ -66,20 +66,20 @@ pub(crate) fn parse_response_body( ) -> SignerResult { if !status.is_success() { let snippet: String = body.chars().take(500).collect(); - let error = if snippet.is_empty() { - format!("{}:{}", tags.http_error_prefix, status.as_u16()) - } else { - format!("{}:{}:{snippet}", tags.http_error_prefix, status.as_u16()) - }; - return Ok(json!({"success": false, "error": error})); + return Err(SignerError::http_status( + tags.http_error_prefix, + status.as_u16(), + snippet, + )); } serde_json::from_str(body) - .map_err(|err| SignerError::Other(format!("{}:{err}", tags.json_error_prefix))) + .map_err(|err| SignerError::http(tags.json_error_prefix, err.to_string())) } #[cfg(test)] mod tests { use super::{parse_response_body, AdapterResponseTags}; + use crate::error::{SignerError, TransportError}; use reqwest::StatusCode; const TAGS: AdapterResponseTags = AdapterResponseTags { @@ -100,11 +100,16 @@ mod tests { } #[test] - fn parse_response_body_http_error_returns_success_false() { - let payload = parse_response_body(StatusCode::NOT_FOUND, "missing", TAGS).expect("parse"); - assert_eq!( - payload.get("error").and_then(|v| v.as_str()), - Some("test_http_error:404:missing") - ); + fn parse_response_body_http_error_is_typed_status() { + let err = parse_response_body(StatusCode::NOT_FOUND, "missing", TAGS).expect_err("404"); + assert!(matches!( + err, + SignerError::Transport(TransportError::HttpStatus { + layer: "test_http_error", + status: 404, + .. + }) + )); + assert!(err.is_http_not_found()); } } diff --git a/greenfloor-engine/src/adapters/mod.rs b/greenfloor-engine/src/adapters/mod.rs index bfc0948d..7602da6f 100644 --- a/greenfloor-engine/src/adapters/mod.rs +++ b/greenfloor-engine/src/adapters/mod.rs @@ -3,8 +3,6 @@ mod http_client; mod http_json; mod splash; -pub use dexie::{ - dexie_offer_view_url, is_dexie_offer_missing_error_text, DexieClient, DexieResponse, -}; +pub use dexie::{dexie_offer_view_url, DexieClient, DexieResponse}; pub use http_client::shared_http_client; pub use splash::{SplashClient, SplashResponse}; diff --git a/greenfloor-engine/src/adapters/splash/mod.rs b/greenfloor-engine/src/adapters/splash/mod.rs index fb19d380..d3a09563 100644 --- a/greenfloor-engine/src/adapters/splash/mod.rs +++ b/greenfloor-engine/src/adapters/splash/mod.rs @@ -3,7 +3,7 @@ mod response; use serde_json::json; use crate::adapters::http_json::{self, AdapterResponseTags}; -use crate::error::SignerResult; +use crate::error::{SignerError, SignerResult, TransportError}; pub use response::SplashResponse; @@ -38,7 +38,7 @@ impl SplashClient { /// /// Returns an error if the operation fails. pub async fn post_offer(&self, offer: &str) -> SignerResult { - http_json::post_json( + match http_json::post_json( &self.http, &self.base_url, json!({"offer": offer}), @@ -47,6 +47,17 @@ impl SplashClient { RESPONSE_TAGS, ) .await - .map(SplashResponse::from_value) + { + Ok(value) => Ok(SplashResponse::from_value(value)), + Err(err) => match &err { + SignerError::Transport(TransportError::HttpStatus { .. }) => { + Ok(SplashResponse::from_value(json!({ + "success": false, + "error": err.to_string(), + }))) + } + _ => Err(err), + }, + } } } diff --git a/greenfloor-engine/src/coin_ops/execution/context.rs b/greenfloor-engine/src/coin_ops/execution/context.rs index 2019e97c..596455e3 100644 --- a/greenfloor-engine/src/coin_ops/execution/context.rs +++ b/greenfloor-engine/src/coin_ops/execution/context.rs @@ -6,6 +6,8 @@ use crate::coin_ops::{ }; use crate::coinset::{list_wallet_unspent_coins_for_signer, spend_bundle_hash_from_hex}; use crate::config::{GatedOperatorMarket, MarketConfig}; +#[cfg(test)] +use crate::error::VaultError; use crate::error::{SignerError, SignerResult}; use crate::hex::{default_mojo_multiplier_for_asset, hex_to_bytes32, parse_coin_ids}; use crate::offer::OfferAssetResolver; @@ -170,7 +172,9 @@ impl CoinOpExecContext { #[cfg(test)] if self.test_overrides.take_mixed_split_stale_first_failure() { let _ = (output_amounts, coin_ids, fee_mojos); - return Err(SignerError::MixedSplitSelectedCoinsNotSpendable); + return Err(SignerError::Vault( + VaultError::MixedSplitSelectedCoinsNotSpendable, + )); } #[cfg(test)] if let Some(operation_id) = self.test_overrides.mixed_split_operation_id_override() { diff --git a/greenfloor-engine/src/coin_ops/execution/test_overrides.rs b/greenfloor-engine/src/coin_ops/execution/test_overrides.rs index 1abbeee9..1fe3561c 100644 --- a/greenfloor-engine/src/coin_ops/execution/test_overrides.rs +++ b/greenfloor-engine/src/coin_ops/execution/test_overrides.rs @@ -12,7 +12,7 @@ use crate::coin_ops::SpendableCoin; pub struct CoinOpTestOverrides { pub wallet_coins: Option>, pub mixed_split_operation_id: Option, - /// First `execute_mixed_split` returns [`SignerError::MixedSplitSelectedCoinsNotSpendable`]. + /// First `execute_mixed_split` returns [`crate::error::VaultError::MixedSplitSelectedCoinsNotSpendable`]. pub mixed_split_stale_first: bool, mixed_split_calls: Arc, } diff --git a/greenfloor-engine/src/coin_ops/scalars.rs b/greenfloor-engine/src/coin_ops/scalars.rs index 5db29841..263a57c9 100644 --- a/greenfloor-engine/src/coin_ops/scalars.rs +++ b/greenfloor-engine/src/coin_ops/scalars.rs @@ -3,7 +3,7 @@ //! Policy: validated plan/CLI inputs propagate errors (`InvalidPlanValues`). Output amount //! vectors use `coin_op_non_negative_u64_saturating` when splitting totals (overflow → `u64::MAX`). -use crate::error::{SignerError, SignerResult}; +use crate::error::{CoinOpsError, SignerError, SignerResult}; /// Coin op non negative u64. /// @@ -12,7 +12,7 @@ use crate::error::{SignerError, SignerResult}; /// Returns an error if the operation fails. pub fn coin_op_non_negative_u64(value: i64, field: &str) -> SignerResult { if value < 0 { - return Err(SignerError::InvalidPlanValues); + return Err(SignerError::CoinOps(CoinOpsError::InvalidPlanValues)); } u64::try_from(value) .map_err(|_| SignerError::Other(format!("{field} must fit in u64 for coin-op execution"))) @@ -25,7 +25,7 @@ pub fn coin_op_non_negative_u64(value: i64, field: &str) -> SignerResult { /// Returns an error if the operation fails. pub fn i64_to_usize(value: i64, field: &str) -> SignerResult { if value < 0 { - return Err(SignerError::InvalidPlanValues); + return Err(SignerError::CoinOps(CoinOpsError::InvalidPlanValues)); } usize::try_from(value) .map_err(|_| SignerError::Other(format!("{field} must fit in usize for coin-op execution"))) @@ -57,7 +57,7 @@ mod tests { assert_eq!(coin_op_non_negative_u64(10, "amount").expect("ok"), 10); assert!(matches!( coin_op_non_negative_u64(-1, "amount"), - Err(SignerError::InvalidPlanValues) + Err(SignerError::CoinOps(CoinOpsError::InvalidPlanValues)) )); } diff --git a/greenfloor-engine/src/coin_ops/vault_controlled.rs b/greenfloor-engine/src/coin_ops/vault_controlled.rs index e454c5b4..747ca0a4 100644 --- a/greenfloor-engine/src/coin_ops/vault_controlled.rs +++ b/greenfloor-engine/src/coin_ops/vault_controlled.rs @@ -4,7 +4,7 @@ use std::collections::HashSet; use crate::coinset::OfferCoinsetBackend; use crate::cycle::{unreturned_row_priority, ReconcileState}; -use crate::error::{SignerError, SignerResult}; +use crate::error::{OfferError, SignerError, SignerResult}; use crate::hex::{hex_to_bytes32, normalize_hex_id}; use crate::storage::SqliteStore; @@ -81,7 +81,7 @@ pub async fn vault_controlled_balance( } cat.coin.amount } - Err(SignerError::PresplitCoinNotFound) => continue, + Err(SignerError::Offer(OfferError::PresplitCoinNotFound)) => continue, Err(err) => return Err(err), }; unreturned_amount = unreturned_amount.saturating_add(amount); diff --git a/greenfloor-engine/src/coinset/cats/list.rs b/greenfloor-engine/src/coinset/cats/list.rs index feb7598b..44e4ec9d 100644 --- a/greenfloor-engine/src/coinset/cats/list.rs +++ b/greenfloor-engine/src/coinset/cats/list.rs @@ -8,7 +8,7 @@ use super::resolve; use crate::bech32m::decode_address; use crate::coinset::pagination::coin_records_by_puzzle_hash; use crate::coinset::retry::with_coinset_client_retries; -use crate::error::{SignerError, SignerResult}; +use crate::error::{CoinOpsError, SignerError, SignerResult}; use crate::operator_log::LogContext; pub(crate) async fn coin_records_for_cat_outer_puzzle_hash( @@ -42,7 +42,7 @@ pub(crate) async fn cats_with_lineage_from_records( match resolve::cat_from_record(client, record).await { Ok(Some(cat)) => cats.push(cat), Ok(None) => {} - Err(err @ SignerError::UnparseableCatLineage(_)) => { + Err(err @ SignerError::CoinOps(CoinOpsError::UnparseableCatLineage(_))) => { crate::trace_event!( DEBUG, LogContext::COINSET, diff --git a/greenfloor-engine/src/coinset/cats/resolve.rs b/greenfloor-engine/src/coinset/cats/resolve.rs index 1b33be34..ecffc40a 100644 --- a/greenfloor-engine/src/coinset/cats/resolve.rs +++ b/greenfloor-engine/src/coinset/cats/resolve.rs @@ -13,11 +13,11 @@ use clvmr::serde::{node_from_bytes, node_to_bytes}; use clvmr::{Allocator, NodePtr}; use crate::coinset::retry::with_coinset_client_retries; -use crate::error::{SignerError, SignerResult}; +use crate::error::{CoinOpsError, OfferError, SignerError, SignerResult}; use crate::hex::normalize_hex_id; fn unparseable_cat_lineage(detail: impl Into) -> SignerError { - SignerError::UnparseableCatLineage(detail.into()) + SignerError::CoinOps(CoinOpsError::UnparseableCatLineage(detail.into())) } /// Fetch the parent coin's spend (record must be spent and have a puzzle/solution). @@ -76,7 +76,8 @@ pub fn cat_from_parent_spend(coin: Coin, parent_spend: &CoinSpend) -> SignerResu /// /// Returns an error if the operation fails. pub fn require_cat_from_parent_spend(coin: Coin, parent_spend: &CoinSpend) -> SignerResult { - cat_from_parent_spend(coin, parent_spend)?.ok_or(SignerError::PresplitCoinNotFound) + cat_from_parent_spend(coin, parent_spend)? + .ok_or(SignerError::Offer(OfferError::PresplitCoinNotFound)) } /// One CAT child from a parent spend plus serialized inner-p2 `CREATE_COIN` memos (if any). @@ -257,6 +258,9 @@ mod tests { 1, ); let err = parse_cat_from_parent_spend(child, &empty_parent_spend()).expect_err("parse"); - assert!(matches!(err, SignerError::UnparseableCatLineage(_))); + assert!(matches!( + err, + SignerError::CoinOps(CoinOpsError::UnparseableCatLineage(_)) + )); } } diff --git a/greenfloor-engine/src/coinset/coin_select/mod.rs b/greenfloor-engine/src/coinset/coin_select/mod.rs index 0096efba..5f42927a 100644 --- a/greenfloor-engine/src/coinset/coin_select/mod.rs +++ b/greenfloor-engine/src/coinset/coin_select/mod.rs @@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet}; -use chia_protocol::Bytes32; +use chia_protocol::{Bytes32, Coin}; use chia_sdk_coinset::{CoinRecord, CoinsetClient}; use chia_sdk_driver::Cat; @@ -10,7 +10,7 @@ use super::cats::{ cat_from_record, coin_records_for_cat_outer_puzzle_hash, coin_records_for_coin_ids, }; use crate::coin_ops::{select_funding_coin_ids, FundingSelectionMode, SpendableCoin}; -use crate::error::{SignerError, SignerResult}; +use crate::error::{CoinOpsError, SignerError, SignerResult}; use crate::hex::{bytes32_to_hex, normalize_hex_id}; /// Minimum CAT output amount for offer/dust policy (1000 mojos = 1 CAT unit). @@ -23,6 +23,17 @@ pub struct SelectedCats { pub change_amount: u64, } +impl SelectedCats { + fn from_cats(selected: Vec, target_amount: u64) -> Self { + let offered_total: u64 = selected.iter().map(|cat| cat.coin.amount).sum(); + Self { + change_amount: offered_total.saturating_sub(target_amount), + selected, + offered_total, + } + } +} + fn amount_i64(amount: u64) -> i64 { i64::try_from(amount).unwrap_or(i64::MAX) } @@ -31,19 +42,11 @@ fn coin_spendable_id(coin_id: Bytes32) -> String { normalize_hex_id(&bytes32_to_hex(coin_id)) } -fn cat_to_spendable(cat: &Cat) -> SpendableCoin { +fn coin_to_spendable(coin: &Coin) -> SpendableCoin { SpendableCoin::with_puzzle_hash( - coin_spendable_id(cat.coin.coin_id()), - amount_i64(cat.coin.amount), - normalize_hex_id(&bytes32_to_hex(cat.coin.puzzle_hash)), - ) -} - -fn record_to_spendable(record: &CoinRecord) -> SpendableCoin { - SpendableCoin::with_puzzle_hash( - coin_spendable_id(record.coin.coin_id()), - amount_i64(record.coin.amount), - normalize_hex_id(&bytes32_to_hex(record.coin.puzzle_hash)), + coin_spendable_id(coin.coin_id()), + amount_i64(coin.amount), + normalize_hex_id(&bytes32_to_hex(coin.puzzle_hash)), ) } @@ -63,76 +66,41 @@ fn reorder_by_ids(items: Vec, ids: &[String], id_of: impl Fn(&T) -> String ids.iter().filter_map(|id| by_id.remove(id)).collect() } -fn select_from_spendable( +fn select_items( items: Vec, target_amount: u64, mode: FundingSelectionMode, - to_spendable: impl Fn(&T) -> SpendableCoin, - amount: impl Fn(&T) -> u64, + coin_of: impl Fn(&T) -> &Coin, ) -> SignerResult> { if items.is_empty() { - return Err(SignerError::NoUnspentCatCoins); + return Err(SignerError::CoinOps(CoinOpsError::NoUnspentCatCoins)); } - let spendable: Vec = items.iter().map(&to_spendable).collect(); + let spendable: Vec = items + .iter() + .map(|item| coin_to_spendable(coin_of(item))) + .collect(); let ids = select_funding_coin_ids(mode, &spendable, amount_i64(target_amount), None, None); if ids.is_empty() { - return Err(SignerError::InsufficientCatCoins); + return Err(SignerError::CoinOps(CoinOpsError::InsufficientCatCoins)); } - let selected = reorder_by_ids(items, &ids, |item| to_spendable(item).id); - let offered_total: u64 = selected.iter().map(&amount).sum(); + let selected = reorder_by_ids(items, &ids, |item| { + coin_spendable_id(coin_of(item).coin_id()) + }); + let offered_total: u64 = selected.iter().map(|item| coin_of(item).amount).sum(); if offered_total < target_amount { - return Err(SignerError::InsufficientCatCoins); + return Err(SignerError::CoinOps(CoinOpsError::InsufficientCatCoins)); } Ok(selected) } -#[must_use] -pub fn select_cats_smallest_first(cats: Vec, target_total: u64) -> Vec { - select_from_spendable( - cats, - target_total, - FundingSelectionMode::SmallestFirst, - cat_to_spendable, - |cat| cat.coin.amount, - ) - .unwrap_or_default() -} - -fn finalize_amount_selection( - items: Vec, - explicit_coin_ids: &[Bytes32], - target_amount: u64, - to_spendable: impl Fn(&T) -> SpendableCoin, - amount: impl Fn(&T) -> u64, -) -> SignerResult<(Vec, u64)> { - let selected = select_from_spendable( - items, - target_amount, - funding_mode_for_explicit_ids(explicit_coin_ids), - to_spendable, - &amount, - )?; - let offered_total: u64 = selected.iter().map(amount).sum(); - Ok((selected, offered_total)) -} - -pub(crate) fn finalize_selected_cats( +#[cfg(test)] +pub(crate) fn select_resolved_cats( cats: Vec, - explicit_coin_ids: &[Bytes32], target_amount: u64, + mode: FundingSelectionMode, ) -> SignerResult { - let (selected, offered_total) = finalize_amount_selection( - cats, - explicit_coin_ids, - target_amount, - cat_to_spendable, - |cat| cat.coin.amount, - )?; - Ok(SelectedCats { - change_amount: offered_total.saturating_sub(target_amount), - selected, - offered_total, - }) + let selected = select_items(cats, target_amount, mode, |cat| &cat.coin)?; + Ok(SelectedCats::from_cats(selected, target_amount)) } pub(crate) fn finalize_preselected_cats_for_spend( @@ -141,7 +109,11 @@ pub(crate) fn finalize_preselected_cats_for_spend( target_amount: u64, ) -> SignerResult { validate_preselected_cats_match_coin_ids(&cats, explicit_coin_ids)?; - finalize_selected_cats(cats, explicit_coin_ids, target_amount) + let selected = SelectedCats::from_cats(cats, target_amount); + if selected.offered_total < target_amount { + return Err(SignerError::CoinOps(CoinOpsError::InsufficientCatCoins)); + } + Ok(selected) } fn validate_preselected_cats_match_coin_ids( @@ -152,15 +124,21 @@ fn validate_preselected_cats_match_coin_ids( return Ok(()); } if cats.len() != explicit_coin_ids.len() { - return Err(SignerError::PreselectedCatCoinIdsMismatch); + return Err(SignerError::CoinOps( + CoinOpsError::PreselectedCatCoinIdsMismatch, + )); } let cat_ids: HashSet = cats.iter().map(|cat| cat.coin.coin_id()).collect(); if cat_ids.len() != cats.len() { - return Err(SignerError::PreselectedCatCoinIdsMismatch); + return Err(SignerError::CoinOps( + CoinOpsError::PreselectedCatCoinIdsMismatch, + )); } for id in explicit_coin_ids { if !cat_ids.contains(id) { - return Err(SignerError::PreselectedCatCoinIdsMismatch); + return Err(SignerError::CoinOps( + CoinOpsError::PreselectedCatCoinIdsMismatch, + )); } } Ok(()) @@ -202,12 +180,11 @@ async fn select_cats_for_spend_from_records( .copied() .filter(|record| !excluded.contains(&record.coin.coin_id())) .collect(); - let (selected_records, _offered_total) = finalize_amount_selection( + let selected_records = select_items( available, - explicit_coin_ids, target_amount, - record_to_spendable, - |record| record.coin.amount, + funding_mode_for_explicit_ids(explicit_coin_ids), + |record| &record.coin, )?; let mut selected = Vec::with_capacity(selected_records.len()); let mut unresolvable = Vec::new(); @@ -218,11 +195,11 @@ async fn select_cats_for_spend_from_records( } } if unresolvable.is_empty() { - return finalize_selected_cats(selected, explicit_coin_ids, target_amount); + return Ok(SelectedCats::from_cats(selected, target_amount)); } excluded.extend(unresolvable); if excluded.len() >= records.len() { - return Err(SignerError::InsufficientCatCoins); + return Err(SignerError::CoinOps(CoinOpsError::InsufficientCatCoins)); } } } diff --git a/greenfloor-engine/src/coinset/coin_select/tests.rs b/greenfloor-engine/src/coinset/coin_select/tests.rs index d914ec9f..eee742a2 100644 --- a/greenfloor-engine/src/coinset/coin_select/tests.rs +++ b/greenfloor-engine/src/coinset/coin_select/tests.rs @@ -4,15 +4,14 @@ use chia_sdk_driver::Cat; use chia_sdk_test::Simulator; use super::{ - cat_to_spendable, finalize_preselected_cats_for_spend, finalize_selected_cats, - select_cats_for_spend, select_from_spendable, + finalize_preselected_cats_for_spend, select_cats_for_spend, select_items, select_resolved_cats, }; use crate::coin_ops::FundingSelectionMode; use crate::coinset::test_support::{ cat_with_amount, mock_get_coin_record_by_name_body, mock_get_coin_records_by_puzzle_hash_body, mock_get_puzzle_and_solution_body, }; -use crate::error::SignerError; +use crate::error::{CoinOpsError, SignerError}; use crate::test_support::simulator::harness::SimulatorVaultHarness; fn select_cats_for_mode( @@ -20,9 +19,7 @@ fn select_cats_for_mode( target_amount: u64, mode: FundingSelectionMode, ) -> Result, SignerError> { - select_from_spendable(cats, target_amount, mode, cat_to_spendable, |cat| { - cat.coin.amount - }) + select_items(cats, target_amount, mode, |cat| &cat.coin) } fn parent_spent_block_index(sim: &Simulator, parent_coin_id: Bytes32) -> u32 { @@ -80,7 +77,10 @@ fn smallest_first_accumulates_when_no_single_coin_covers_target() { fn smallest_first_empty_list_uses_empty_error() { let err = select_cats_for_mode(Vec::::new(), 1000, FundingSelectionMode::SmallestFirst) .expect_err("empty"); - assert!(matches!(err, SignerError::NoUnspentCatCoins)); + assert!(matches!( + err, + SignerError::CoinOps(CoinOpsError::NoUnspentCatCoins) + )); } #[test] @@ -91,7 +91,10 @@ fn smallest_first_insufficient_uses_insufficient_error() { FundingSelectionMode::SmallestFirst, ) .expect_err("insufficient"); - assert!(matches!(err, SignerError::InsufficientCatCoins)); + assert!(matches!( + err, + SignerError::CoinOps(CoinOpsError::InsufficientCatCoins) + )); } #[test] @@ -117,7 +120,10 @@ fn explicit_sum_fails_when_total_below_target() { FundingSelectionMode::AllListed, ) .expect_err("below target"); - assert!(matches!(err, SignerError::InsufficientCatCoins)); + assert!(matches!( + err, + SignerError::CoinOps(CoinOpsError::InsufficientCatCoins) + )); } #[test] @@ -126,7 +132,10 @@ fn finalize_preselected_cats_rejects_mismatched_coin_ids() { let wrong_id = Bytes32::new([0xab; 32]); let err = finalize_preselected_cats_for_spend(vec![cat], &[wrong_id], 600).expect_err("mismatch"); - assert!(matches!(err, SignerError::PreselectedCatCoinIdsMismatch)); + assert!(matches!( + err, + SignerError::CoinOps(CoinOpsError::PreselectedCatCoinIdsMismatch) + )); } #[test] @@ -141,10 +150,10 @@ fn finalize_preselected_cats_accepts_matching_coin_ids() { } #[test] -fn finalize_selected_cats_uses_explicit_sum_for_fixed_ids() { +fn select_resolved_cats_all_listed_sums_all_cats() { let cats = vec![cat_with_amount(600), cat_with_amount(500)]; - let selected = finalize_selected_cats(cats, &[Bytes32::new([0xab; 32])], 1000) - .expect("vault-style explicit selection"); + let selected = + select_resolved_cats(cats, 1000, FundingSelectionMode::AllListed).expect("all listed"); assert_eq!(selected.selected.len(), 2); assert_eq!(selected.offered_total, 1100); assert_eq!(selected.change_amount, 100); diff --git a/greenfloor-engine/src/coinset/mod.rs b/greenfloor-engine/src/coinset/mod.rs index 1c8f3e9e..560f47fe 100644 --- a/greenfloor-engine/src/coinset/mod.rs +++ b/greenfloor-engine/src/coinset/mod.rs @@ -42,7 +42,7 @@ pub use cats::{ require_cat_from_parent_spend, }; pub(crate) use cats::{cat_outer_coinset_hex, cat_outer_puzzle_hash}; -pub use coin_select::{select_cats_smallest_first, SelectedCats, MIN_CAT_OUTPUT_MOJOS}; +pub use coin_select::{SelectedCats, MIN_CAT_OUTPUT_MOJOS}; pub use direct_api::{ effective_coinset_base_url, explicit_coinset_url_override, normalize_coinset_network, normalize_direct_base_url_input, resolve_coinset_endpoint, resolve_direct_client, diff --git a/greenfloor-engine/src/coinset/poll.rs b/greenfloor-engine/src/coinset/poll.rs index 3eac83eb..36158ca2 100644 --- a/greenfloor-engine/src/coinset/poll.rs +++ b/greenfloor-engine/src/coinset/poll.rs @@ -53,6 +53,8 @@ mod tests { use std::cell::Cell; use std::rc::Rc; + use crate::error::CoinOpsError; + #[tokio::test] async fn run_poll_loop_returns_when_attempt_succeeds() { let attempts = Rc::new(Cell::new(0u8)); @@ -75,7 +77,7 @@ mod tests { timeout: Duration::from_millis(50), interval: Duration::from_millis(1), }, - SignerError::CombineInputVerifyTimeout, + SignerError::CoinOps(CoinOpsError::CombineInputVerifyTimeout), ) .await .expect("poll"); @@ -91,10 +93,13 @@ mod tests { timeout: Duration::from_millis(5), interval: Duration::from_millis(1), }, - SignerError::CombineInputVerifyTimeout, + SignerError::CoinOps(CoinOpsError::CombineInputVerifyTimeout), ) .await .expect_err("timeout"); - assert!(matches!(err, SignerError::CombineInputVerifyTimeout)); + assert!(matches!( + err, + SignerError::CoinOps(CoinOpsError::CombineInputVerifyTimeout) + )); } } diff --git a/greenfloor-engine/src/coinset/presplit.rs b/greenfloor-engine/src/coinset/presplit.rs index d3008704..10bc62b8 100644 --- a/greenfloor-engine/src/coinset/presplit.rs +++ b/greenfloor-engine/src/coinset/presplit.rs @@ -4,7 +4,7 @@ use chia_sdk_coinset::{ChiaRpcClient, GetCoinRecordResponse}; use super::poll::{run_poll_loop, PollConfig}; use super::spent_verify::coin_record_is_spent; use super::{cats, CoinsetClient}; -use crate::error::{SignerError, SignerResult}; +use crate::error::{OfferError, SignerError, SignerResult}; use chia_sdk_driver::Cat; const PRESPLIT_CONFIRM_TIMEOUT_SECS: u64 = 120; @@ -41,7 +41,7 @@ pub async fn offer_input_coin_is_spent( /// /// # Errors /// -/// Returns [`SignerError::PresplitCoinNotFound`] when missing or spent. +/// Returns [`crate::error::OfferError::PresplitCoinNotFound`] when missing or spent. pub async fn fetch_unspent_offer_input_coin( client: &CoinsetClient, coin_id: Bytes32, @@ -51,10 +51,10 @@ pub async fn fetch_unspent_offer_input_coin( .await .map_err(SignerError::from)?; let Some(record) = response.coin_record else { - return Err(SignerError::PresplitCoinNotFound); + return Err(SignerError::Offer(OfferError::PresplitCoinNotFound)); }; if coin_record_is_spent(record.spent_block_index) { - return Err(SignerError::PresplitCoinNotFound); + return Err(SignerError::Offer(OfferError::PresplitCoinNotFound)); } Ok(record.coin) } @@ -70,14 +70,14 @@ pub async fn fetch_offer_input_cat(client: &CoinsetClient, coin_id: Bytes32) -> .await .map_err(SignerError::from)?; let Some(record) = response.coin_record else { - return Err(SignerError::PresplitCoinNotFound); + return Err(SignerError::Offer(OfferError::PresplitCoinNotFound)); }; if record.spent_block_index != 0 { - return Err(SignerError::PresplitCoinNotFound); + return Err(SignerError::Offer(OfferError::PresplitCoinNotFound)); } cats::cat_from_record(client, &record) .await? - .ok_or(SignerError::PresplitCoinNotFound) + .ok_or(SignerError::Offer(OfferError::PresplitCoinNotFound)) } /// Wait for unspent cat. @@ -118,7 +118,7 @@ where run_poll_loop( move || fetch(coin_id), poll, - SignerError::PresplitCoinConfirmationTimeout, + SignerError::Offer(OfferError::PresplitCoinConfirmationTimeout), ) .await } @@ -183,6 +183,9 @@ mod tests { ) .await .unwrap_err(); - assert!(matches!(err, SignerError::PresplitCoinConfirmationTimeout)); + assert!(matches!( + err, + SignerError::Offer(OfferError::PresplitCoinConfirmationTimeout) + )); } } diff --git a/greenfloor-engine/src/coinset/spent_verify.rs b/greenfloor-engine/src/coinset/spent_verify.rs index 428bcb9d..91e1dc08 100644 --- a/greenfloor-engine/src/coinset/spent_verify.rs +++ b/greenfloor-engine/src/coinset/spent_verify.rs @@ -2,7 +2,7 @@ use chia_protocol::Bytes32; use chia_sdk_coinset::{ChiaRpcClient, CoinsetClient}; use super::poll::{run_poll_loop, PollConfig}; -use crate::error::{SignerError, SignerResult}; +use crate::error::{CoinOpsError, SignerError, SignerResult}; const DEFAULT_VERIFY_TIMEOUT_SECS: u64 = 15 * 60; const DEFAULT_VERIFY_POLL_SECS: u64 = 8; @@ -79,7 +79,7 @@ pub(crate) async fn wait_until_coins_spent_poll( } }, poll, - SignerError::CombineInputVerifyTimeout, + SignerError::CoinOps(CoinOpsError::CombineInputVerifyTimeout), ) .await } @@ -106,7 +106,9 @@ mod test_helpers { return Ok(()); } if started.elapsed() >= poll.timeout { - return Err(SignerError::CombineInputVerifyTimeout); + return Err(SignerError::CoinOps( + CoinOpsError::CombineInputVerifyTimeout, + )); } tokio::time::sleep(poll.interval).await; } @@ -176,7 +178,10 @@ mod tests { ) .await .expect_err("timeout"); - assert!(matches!(err, SignerError::CombineInputVerifyTimeout)); + assert!(matches!( + err, + SignerError::CoinOps(CoinOpsError::CombineInputVerifyTimeout) + )); } #[test] diff --git a/greenfloor-engine/src/coinset/vault_fetch.rs b/greenfloor-engine/src/coinset/vault_fetch.rs index a264702e..a70495b0 100644 --- a/greenfloor-engine/src/coinset/vault_fetch.rs +++ b/greenfloor-engine/src/coinset/vault_fetch.rs @@ -5,7 +5,7 @@ use chia_sdk_driver::{Vault, VaultInfo}; use clvm_utils::TreeHash; use super::pagination::{coin_records_by_parent_ids, coin_records_by_puzzle_hash}; -use crate::error::{SignerError, SignerResult}; +use crate::error::{SignerError, SignerResult, VaultError}; /// Fetch latest vault. /// @@ -20,13 +20,13 @@ pub async fn fetch_latest_vault( let launcher_children = coin_records_by_parent_ids(client, vec![launcher_id], None, None, Some(true)).await?; let Some(first_child) = launcher_children.first() else { - return Err(SignerError::VaultSingletonNotFound); + return Err(SignerError::Vault(VaultError::SingletonNotFound)); }; let singleton_puzzle_hash = first_child.coin.puzzle_hash; let mut leaf_candidates = coin_records_by_puzzle_hash(client, singleton_puzzle_hash, None, None, Some(false)).await?; if leaf_candidates.is_empty() { - return Err(SignerError::VaultSingletonNotFound); + return Err(SignerError::Vault(VaultError::SingletonNotFound)); } leaf_candidates.sort_by_key(|record| std::cmp::Reverse(record.confirmed_block_index)); let current = &leaf_candidates[0]; @@ -36,7 +36,7 @@ pub async fn fetch_latest_vault( .await .map_err(SignerError::from)?; let Some(parent_record) = parent_response.coin_record else { - return Err(SignerError::VaultSingletonNotFound); + return Err(SignerError::Vault(VaultError::SingletonNotFound)); }; let parent_parent = parent_record.coin.parent_coin_info; let proof = if parent_id == launcher_id { @@ -66,7 +66,7 @@ mod tests { use super::fetch_latest_vault; use crate::coinset::json_util::to_coinset_hex; - use crate::error::SignerError; + use crate::error::{SignerError, VaultError}; fn hex32(byte: u8) -> String { to_coinset_hex(&[byte; 32]) @@ -98,7 +98,10 @@ mod tests { let err = fetch_latest_vault(&client, launcher_id, TreeHash::new([0x22; 32])) .await .expect_err("missing launcher child"); - assert!(matches!(err, SignerError::VaultSingletonNotFound)); + assert!(matches!( + err, + SignerError::Vault(VaultError::SingletonNotFound) + )); } #[tokio::test] diff --git a/greenfloor-engine/src/config/program.rs b/greenfloor-engine/src/config/program.rs index aede20cf..2aa480bf 100644 --- a/greenfloor-engine/src/config/program.rs +++ b/greenfloor-engine/src/config/program.rs @@ -6,7 +6,7 @@ use serde_json::Value; use super::keys_registry::SignerKeyEntry; use super::signer::{parse_signer_config, SignerConfig}; use crate::coinset::is_xch_like_asset; -use crate::error::{SignerError, SignerResult}; +use crate::error::{ConfigError, SignerError, SignerResult}; use crate::hex::is_hex_id; use crate::paths::expand_home; @@ -63,7 +63,7 @@ impl ManagerProgramConfig { if self.signer_offer_path_configured() { return Ok(()); } - Err(SignerError::SignerPathNotConfigured) + Err(SignerError::Config(ConfigError::SignerPathNotConfigured)) } } @@ -110,7 +110,7 @@ impl CycleProgramConfig { self.program.require_signer_offer_path()?; self.signer .as_ref() - .ok_or(SignerError::MissingConfigField("signer")) + .ok_or(SignerError::Config(ConfigError::MissingField("signer"))) } } @@ -120,8 +120,12 @@ pub const SIGNER_SKIP_MISSING_SIGNER_CONFIG: &str = "skipped_missing_signer_conf #[must_use] pub fn signer_execution_skip_reason(err: &SignerError) -> String { match err { - SignerError::SignerPathNotConfigured => SIGNER_SKIP_NO_SIGNER_PATH.to_string(), - SignerError::MissingConfigField("signer") => SIGNER_SKIP_MISSING_SIGNER_CONFIG.to_string(), + SignerError::Config(ConfigError::SignerPathNotConfigured) => { + SIGNER_SKIP_NO_SIGNER_PATH.to_string() + } + SignerError::Config(ConfigError::MissingField("signer")) => { + SIGNER_SKIP_MISSING_SIGNER_CONFIG.to_string() + } other => other.to_string(), } } diff --git a/greenfloor-engine/src/config/signer.rs b/greenfloor-engine/src/config/signer.rs index 31fdd2ca..de4f2e9b 100644 --- a/greenfloor-engine/src/config/signer.rs +++ b/greenfloor-engine/src/config/signer.rs @@ -4,7 +4,7 @@ use serde_json::Value; use super::program::read_program_yaml; use super::yaml_fields::{config_err, optional_trimmed_string, req_mapping, req_str, req_value}; -use crate::error::{SignerError, SignerResult}; +use crate::error::{ConfigError, SignerError, SignerResult, VaultError}; use crate::hex::hex_to_bytes32; use crate::kms::KmsRuntime; use crate::vault::context::VaultCustodySnapshot; @@ -103,7 +103,7 @@ fn parse_vault_section( vault: &serde_json::Map, ) -> SignerResult { let launcher_id = hex_to_bytes32(&require_nonempty_str(vault, "launcher_id")?) - .map_err(|_| SignerError::VaultLauncherIdInvalid)?; + .map_err(|_| SignerError::Vault(VaultError::LauncherIdInvalid))?; let custody_threshold = parse_u32_field( req_value(vault, "custody_threshold")?, "vault.custody_threshold", @@ -122,12 +122,12 @@ fn parse_vault_section( parse_wallet_keys(req_value(vault, "recovery_keys")?, "vault.recovery_keys")?; if custody_keys.is_empty() || recovery_keys.is_empty() { - return Err(SignerError::UnsupportedVaultSignerCardinality); + return Err(SignerError::Vault(VaultError::UnsupportedSignerCardinality)); } validate_vault_threshold(custody_threshold, custody_keys.len())?; validate_vault_threshold(recovery_threshold, recovery_keys.len())?; if recovery_clawback_timelock == 0 { - return Err(SignerError::InvalidVaultRecoveryTimelock); + return Err(SignerError::Vault(VaultError::InvalidRecoveryTimelock)); } Ok(VaultCustodySnapshot { @@ -164,7 +164,7 @@ fn require_nonempty_str( ) -> SignerResult { let trimmed = req_str(map, key)?.trim().to_string(); if trimmed.is_empty() { - return Err(SignerError::MissingConfigField(key)); + return Err(SignerError::Config(ConfigError::MissingField(key))); } Ok(trimmed) } diff --git a/greenfloor-engine/src/config/yaml_fields.rs b/greenfloor-engine/src/config/yaml_fields.rs index 5a009fac..3a6f2806 100644 --- a/greenfloor-engine/src/config/yaml_fields.rs +++ b/greenfloor-engine/src/config/yaml_fields.rs @@ -2,10 +2,10 @@ use serde_json::Value; -use crate::error::{SignerError, SignerResult}; +use crate::error::{ConfigError, SignerError, SignerResult}; pub fn config_err(message: impl Into) -> SignerError { - SignerError::Other(message.into()) + ConfigError::Parse(message.into()).into() } pub fn req_mapping<'a>( @@ -153,3 +153,16 @@ pub fn optional_trimmed_string(raw: Option<&Value>) -> Option { .filter(|value| !value.is_empty()) .map(str::to_string) } + +#[cfg(test)] +mod tests { + use super::config_err; + use crate::error::{ConfigError, SignerError}; + + #[test] + fn config_err_is_typed_parse() { + let err = config_err("markets config root must be a mapping"); + assert!(matches!(err, SignerError::Config(ConfigError::Parse(_)))); + assert_eq!(err.to_string(), "markets config root must be a mapping"); + } +} diff --git a/greenfloor-engine/src/cycle/mod.rs b/greenfloor-engine/src/cycle/mod.rs index d751eec5..91843c44 100644 --- a/greenfloor-engine/src/cycle/mod.rs +++ b/greenfloor-engine/src/cycle/mod.rs @@ -16,7 +16,6 @@ pub mod retry; pub mod strategy; pub mod strategy_action; -pub use crate::adapters::is_dexie_offer_missing_error_text; pub use cancel::{ abs_move_bps, cancel_move_threshold_bps, evaluate_cancel_policy_decision, CancelPolicyDecision, }; diff --git a/greenfloor-engine/src/cycle/orchestration.rs b/greenfloor-engine/src/cycle/orchestration.rs index 3bdd5332..909c510b 100644 --- a/greenfloor-engine/src/cycle/orchestration.rs +++ b/greenfloor-engine/src/cycle/orchestration.rs @@ -239,8 +239,6 @@ pub fn classify_dexie_stale_offer_status(status: i64) -> Option<&'static str> { } } -pub use crate::adapters::is_dexie_offer_missing_error_text; - #[must_use] pub fn record_stale_sweep_check( progress: &StaleSweepProgress, @@ -315,12 +313,4 @@ mod tests { assert_eq!(classify_dexie_stale_offer_status(4), Some("tx_confirmed")); assert_eq!(classify_dexie_stale_offer_status(0), None); } - - #[test] - fn is_dexie_offer_missing_error_text_detects_404() { - assert!(is_dexie_offer_missing_error_text( - "HTTP Error 404: Not Found" - )); - assert!(!is_dexie_offer_missing_error_text("timeout")); - } } diff --git a/greenfloor-engine/src/cycle/retry.rs b/greenfloor-engine/src/cycle/retry.rs index 69c998e9..8a639391 100644 --- a/greenfloor-engine/src/cycle/retry.rs +++ b/greenfloor-engine/src/cycle/retry.rs @@ -1,5 +1,7 @@ //! Shared transient-retry and polling backoff policy for HTTP adapters. +use crate::error::{SignerError, TransportError}; + const RATE_LIMIT_PATTERN: &str = "try again in "; const MAX_BACKOFF_ATTEMPT_SHIFT: u32 = 31; @@ -43,11 +45,19 @@ pub fn moderate_retry_next_sleep(current_sleep: f64) -> f64 { } #[must_use] -pub fn dexie_invalid_offer_should_retry(error: &str, attempt: u32, max_attempts: u32) -> bool { - let normalized = error.trim(); - normalized.contains("dexie_http_error:400") - && normalized.contains("Invalid Offer") - && attempt < max_attempts.saturating_sub(1) +pub fn dexie_invalid_offer_should_retry( + err: &SignerError, + attempt: u32, + max_attempts: u32, +) -> bool { + matches!( + err, + SignerError::Transport(TransportError::HttpStatus { + status: 400, + message, + .. + }) if message.contains("Invalid Offer") + ) && attempt < max_attempts.saturating_sub(1) } #[must_use] @@ -98,6 +108,7 @@ pub fn poll_exponential_advance_sleep( #[cfg(test)] mod tests { use super::*; + use crate::error::SignerError; #[test] fn parses_rate_limit_seconds_case_insensitive() { @@ -110,9 +121,18 @@ mod tests { #[test] fn dexie_invalid_offer_retry_gates() { - let err = r#"dexie_http_error:400:{"error_message":"Invalid Offer"}"#; - assert!(dexie_invalid_offer_should_retry(err, 0, 4)); - assert!(!dexie_invalid_offer_should_retry(err, 3, 4)); + let err = SignerError::http_status( + "dexie_http_error", + 400, + r#"{"error_message":"Invalid Offer"}"#, + ); + assert!(dexie_invalid_offer_should_retry(&err, 0, 4)); + assert!(!dexie_invalid_offer_should_retry(&err, 3, 4)); + assert!(!dexie_invalid_offer_should_retry( + &SignerError::http_status("dexie_http_error", 500, "Invalid Offer"), + 0, + 4 + )); } #[test] diff --git a/greenfloor-engine/src/daemon/cli.rs b/greenfloor-engine/src/daemon/cli.rs index 32e92f75..54f2d141 100644 --- a/greenfloor-engine/src/daemon/cli.rs +++ b/greenfloor-engine/src/daemon/cli.rs @@ -4,7 +4,7 @@ use clap::Args; use serde_json::Value; use crate::cli_util::optional_trimmed; -use crate::error::{SignerError, SignerResult}; +use crate::error::{ConfigError, SignerError, SignerResult}; use crate::paths::{expand_home, resolve_testnet_markets_path, TestnetMarketsPathPolicy}; use super::cycle_entry::{run_daemon_cycle_once, DaemonCycleOnceResponse}; @@ -52,7 +52,7 @@ pub async fn run_daemon_command(args: DaemonCliArgs) -> SignerResult { let mode = if args.once { "once" } else { "loop" }; let lock = match DaemonInstanceLock::acquire(&state_dir, mode) { Ok(lock) => lock, - Err(SignerError::DaemonAlreadyRunning { .. }) => return Ok(3), + Err(SignerError::Config(ConfigError::DaemonAlreadyRunning { .. })) => return Ok(3), Err(err) => return Err(err), }; let _guard = lock; diff --git a/greenfloor-engine/src/daemon/lock.rs b/greenfloor-engine/src/daemon/lock.rs index 2631e746..324e0fac 100644 --- a/greenfloor-engine/src/daemon/lock.rs +++ b/greenfloor-engine/src/daemon/lock.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use chrono::Utc; use serde_json::json; -use crate::error::{SignerError, SignerResult}; +use crate::error::{ConfigError, SignerError, SignerResult}; const LOCK_FILENAME: &str = "daemon.lock"; @@ -49,10 +49,10 @@ impl DaemonInstanceLock { } else { format!(" daemon_lock_metadata={}", existing.trim()) }; - return Err(SignerError::DaemonAlreadyRunning { + return Err(SignerError::Config(ConfigError::DaemonAlreadyRunning { path: path.display().to_string(), detail, - }); + })); } let payload = json!({ "pid": std::process::id(), @@ -124,7 +124,7 @@ fn unlock(_file: &File) {} #[cfg(test)] mod tests { use super::DaemonInstanceLock; - use crate::error::SignerError; + use crate::error::{ConfigError, SignerError}; #[test] fn acquire_writes_lock_metadata_and_releases_on_drop() { @@ -144,7 +144,7 @@ mod tests { let _first = DaemonInstanceLock::acquire(dir.path(), "loop").expect("first acquire"); let err = DaemonInstanceLock::acquire(dir.path(), "loop").expect_err("contention"); match err { - SignerError::DaemonAlreadyRunning { detail, .. } => { + SignerError::Config(ConfigError::DaemonAlreadyRunning { detail, .. }) => { assert!(detail.contains("daemon_lock_metadata=")); assert!(detail.contains("\"mode\":\"loop\"")); } diff --git a/greenfloor-engine/src/daemon/offer_dispatch/parallel.rs b/greenfloor-engine/src/daemon/offer_dispatch/parallel.rs index ee2a3562..a3c361c8 100644 --- a/greenfloor-engine/src/daemon/offer_dispatch/parallel.rs +++ b/greenfloor-engine/src/daemon/offer_dispatch/parallel.rs @@ -12,7 +12,7 @@ use crate::cycle::{ use super::{parallel_max_workers, reservation_release_status}; use crate::daemon::market_context::MarketCycleContext; -use crate::error::{SignerError, SignerResult}; +use crate::error::{PersistenceError, SignerError, SignerResult}; use crate::offer::request::normalize_offer_side; use crate::operator_log::{LogContext, PARALLEL_OFFER_DISPATCH}; @@ -178,7 +178,7 @@ async fn run_parallel_post_jobs( post_result } Ok(ReservationAcquireResult::Rejected { reason }) => { - return Err(SignerError::ReservationContention(reason.to_string())); + return Err(PersistenceError::ReservationContention(reason.to_string()).into()); } Err(err) => return Err(err), }; @@ -190,14 +190,7 @@ async fn run_parallel_post_jobs( for handle in handles { let (action, counts_as_executed) = handle .await - .map_err(|err| SignerError::Other(format!("parallel worker join failed: {err}")))? - .map_err(|err| { - if err.is_sqlite_fatal() { - err - } else { - SignerError::Other(format!("parallel worker failed: {err}")) - } - })?; + .map_err(|err| SignerError::Other(format!("parallel worker join failed: {err}")))??; if counts_as_executed { executed += 1; } diff --git a/greenfloor-engine/src/daemon/offer_dispatch/test_overrides.rs b/greenfloor-engine/src/daemon/offer_dispatch/test_overrides.rs index bca1da6b..33084f29 100644 --- a/greenfloor-engine/src/daemon/offer_dispatch/test_overrides.rs +++ b/greenfloor-engine/src/daemon/offer_dispatch/test_overrides.rs @@ -8,7 +8,7 @@ use crate::daemon::dispatch_test_controls::{ DaemonDispatchTestInjections, ManagedPostTestMode, ParallelDispatchTestMode, }; -use crate::error::{SignerError, SignerResult}; +use crate::error::{PersistenceError, SignerError, SignerResult}; use super::managed_post::ManagedPostContext; use super::OfferDispatchOutput; @@ -17,8 +17,8 @@ pub(crate) fn parallel_dispatch_result( injections: &DaemonDispatchTestInjections, ) -> Option> { match injections.parallel? { - ParallelDispatchTestMode::Transient => Some(Err(SignerError::ReservationContention( - "test override".to_string(), + ParallelDispatchTestMode::Transient => Some(Err(SignerError::Persistence( + PersistenceError::ReservationContention("test override".to_string()), ))), ParallelDispatchTestMode::Fatal => Some(Err(SignerError::Other( "permanent_offer_build_failure: test override".to_string(), @@ -53,7 +53,9 @@ mod tests { DaemonDispatchTestInjections::default().parallel(ParallelDispatchTestMode::Transient); assert!(matches!( parallel_dispatch_result(&transient).expect("configured"), - Err(SignerError::ReservationContention(_)) + Err(SignerError::Persistence( + PersistenceError::ReservationContention(_) + )) )); let fatal = diff --git a/greenfloor-engine/src/daemon/offer_dispatch/tests/classify_tests.rs b/greenfloor-engine/src/daemon/offer_dispatch/tests/classify_tests.rs index a8722a2b..15025153 100644 --- a/greenfloor-engine/src/daemon/offer_dispatch/tests/classify_tests.rs +++ b/greenfloor-engine/src/daemon/offer_dispatch/tests/classify_tests.rs @@ -8,7 +8,7 @@ use super::super::{ OfferDispatchOutput, ParallelDispatchDecision, }; use crate::config::ManagerProgramConfig; -use crate::error::SignerError; +use crate::error::{PersistenceError, SignerError}; use crate::storage::{lock_shared_store_for_test, CycleWriteStore}; use crate::test_support::market_config::sample_market; @@ -62,11 +62,12 @@ fn reservation_release_status_reflects_execution_outcome() { #[test] fn parallel_transient_signer_error_classifies_reservation_and_upstream() { - let contention = SignerError::ReservationContention("busy".to_string()); + let contention: SignerError = + PersistenceError::ReservationContention("busy".to_string()).into(); assert!(contention.is_parallel_dispatch_transient()); - let upstream = SignerError::ManagedUpstreamTransient("timeout".to_string()); + let upstream = SignerError::http_timeout("dexie", "timed out"); assert!(upstream.is_parallel_dispatch_transient()); - let locked = SignerError::DatabaseLocked; + let locked: SignerError = PersistenceError::DatabaseLocked.into(); assert!(locked.is_parallel_dispatch_transient()); let fatal = SignerError::Other("permanent_offer_build_failure: bad puzzle".to_string()); assert!(!fatal.is_parallel_dispatch_transient()); @@ -86,7 +87,7 @@ fn classify_parallel_dispatch_success_returns_output() { #[test] fn classify_parallel_dispatch_transient_error_falls_back() { - let err = SignerError::ReservationContention("busy".to_string()); + let err: SignerError = PersistenceError::ReservationContention("busy".to_string()).into(); match classify_parallel_dispatch(Err(err)) { ParallelDispatchDecision::FallbackTransient(message) => { assert!(message.to_string().contains("busy")); @@ -113,7 +114,9 @@ async fn record_parallel_fallback_audit_persists_event() { let dir = tempdir().expect("tempdir"); let db_path = dir.path().join("greenfloor.sqlite"); let store = CycleWriteStore::open(&db_path).expect("open"); - let err = SignerError::ReservationContention("simulated".to_string()); + let err = SignerError::Persistence(PersistenceError::ReservationContention( + "simulated".to_string(), + )); record_parallel_fallback_audit(&store, "m1", &err).expect("audit"); let events = lock_shared_store_for_test(&store) .list_recent_audit_events(Some(&["offer_parallel_fallback"]), Some("m1"), 5) diff --git a/greenfloor-engine/src/daemon/stale_sweep.rs b/greenfloor-engine/src/daemon/stale_sweep.rs index 50342f24..0459c1ac 100644 --- a/greenfloor-engine/src/daemon/stale_sweep.rs +++ b/greenfloor-engine/src/daemon/stale_sweep.rs @@ -1,10 +1,10 @@ use crate::adapters::DexieClient; use crate::cycle::{ - classify_dexie_stale_offer_status, collect_stale_sweep_candidates, - is_dexie_offer_missing_error_text, record_stale_sweep_check, OfferStateRow, StaleSweepHit, - StaleSweepProgress, + classify_dexie_stale_offer_status, collect_stale_sweep_candidates, record_stale_sweep_check, + OfferStateRow, StaleSweepHit, StaleSweepProgress, }; use crate::error::SignerResult; +use crate::offer::lifecycle::reconcile_prep::{fetch_dexie_offer, DexieOfferFetch}; use crate::storage::SqliteStore; const GLOBAL_STALE_OPEN_SWEEP_MAX_OFFERS_PER_MARKET: usize = 3; @@ -64,40 +64,24 @@ pub async fn detect_stale_open_offers_for_requeue( if !store.is_dexie_authoritative_offer(&offer_id)? { continue; } - let hit = match dexie.get_offer(&offer_id).await { - Ok(response) => { - if response.is_explicit_failure() { - if is_dexie_offer_missing_error_text(response.error_text()) { - Some(StaleSweepHit { - market_id: market_id.clone(), - offer_id: offer_id.clone(), - reason: "offer_missing_404".to_string(), - }) - } else { - None - } - } else if let Some(offer_obj) = response.offer_payload() { - let status = offer_obj - .get("status") - .and_then(serde_json::Value::as_i64) - .unwrap_or(-1); - classify_dexie_stale_offer_status(status).map(|reason| StaleSweepHit { - market_id: market_id.clone(), - offer_id: offer_id.clone(), - reason: reason.to_string(), - }) - } else { - None - } - } - Err(err) if is_dexie_offer_missing_error_text(&err.to_string()) => { - Some(StaleSweepHit { + let hit = match fetch_dexie_offer(dexie, &offer_id).await { + Ok(DexieOfferFetch::Found(offer_obj)) => { + let status = offer_obj + .get("status") + .and_then(serde_json::Value::as_i64) + .unwrap_or(-1); + classify_dexie_stale_offer_status(status).map(|reason| StaleSweepHit { market_id: market_id.clone(), offer_id: offer_id.clone(), - reason: "offer_missing_404".to_string(), + reason: reason.to_string(), }) } - Err(_) => None, + Ok(DexieOfferFetch::Missing(_)) => Some(StaleSweepHit { + market_id: market_id.clone(), + offer_id: offer_id.clone(), + reason: "offer_missing_404".to_string(), + }), + Ok(DexieOfferFetch::Mismatch) | Err(_) => None, }; progress = record_stale_sweep_check(&progress, hit); } @@ -182,4 +166,40 @@ mod tests { assert_eq!(progress.requeue_market_ids, vec!["m2".to_string()]); assert_eq!(progress.hits[0].reason, "offer_missing_404"); } + + #[tokio::test] + async fn detect_stale_open_offers_ignores_mismatch_and_transport() { + let dir = tempdir().expect("tempdir"); + let db_path = dir.path().join("state.db"); + let store = SqliteStore::open(&db_path).expect("open"); + store + .upsert_offer_state_with_metadata_at( + "offer-mismatch", + "m3", + "open", + Some(0), + &chrono::Utc::now().to_rfc3339(), + crate::storage::OfferCancelWrite { + listing: crate::storage::OfferListingWrite::venue(Some("dexie")), + ..Default::default() + }, + ) + .expect("seed"); + + let mut server = Server::new_async().await; + let _mock = server + .mock("GET", "/v1/offers/offer-mismatch") + .with_status(200) + .with_body(r#"{"success":true,"offer":{"id":"other-id","status":6}}"#) + .create(); + let dexie = DexieClient::new(server.url()); + + let progress = detect_stale_open_offers_for_requeue(&store, &dexie, &["m3".to_string()]) + .await + .expect("sweep"); + + assert_eq!(progress.checked_offer_count, 1); + assert!(progress.hits.is_empty()); + assert!(progress.requeue_market_ids.is_empty()); + } } diff --git a/greenfloor-engine/src/error/coin_ops.rs b/greenfloor-engine/src/error/coin_ops.rs new file mode 100644 index 00000000..1ed89bb6 --- /dev/null +++ b/greenfloor-engine/src/error/coin_ops.rs @@ -0,0 +1,71 @@ +use thiserror::Error; + +/// Coin selection, lineage, combine/split plan, and `CAT` dust failures. +#[derive(Debug, Error)] +pub enum CoinOpsError { + #[error("unparseable cat lineage: {0}")] + UnparseableCatLineage(String), + + #[error("no unspent cat coins")] + NoUnspentCatCoins, + + #[error("insufficient cat coins")] + InsufficientCatCoins, + + #[error("preselected cat coins do not match requested coin ids")] + PreselectedCatCoinIdsMismatch, + + #[error("proven dust coin does not match spend-ready cat")] + ProvenDustCoinMismatch, + + #[error("failed to resolve cat lineage for coin {0}")] + CatLineageResolutionFailed(String), + + #[error("derivation scan failed for selected coin")] + MissingSigningKeyForSelectedCoins, + + #[error("no unspent xch coins")] + NoUnspentXchCoins, + + #[error("insufficient xch fee balance for mixed split")] + InsufficientXchFeeBalanceForMixedSplit, + + #[error("no unspent offer xch coins")] + NoUnspentOfferXchCoins, + + #[error("insufficient offer xch coins")] + InsufficientOfferXchCoins, + + #[error("no unspent offer cat coins")] + NoUnspentOfferCatCoins, + + #[error("insufficient offer cat coins")] + InsufficientOfferCatCoins, + + #[error("unsupported operation type")] + UnsupportedOperationType, + + #[error("invalid plan values")] + InvalidPlanValues, + + #[error("insufficient selected coin total")] + InsufficientSelectedCoinTotal, + + #[error("xch coin selection failed")] + XchCoinSelectionFailed, + + #[error("cat output below minimum mojos")] + CatOutputBelowMinimum, + + #[error("cat change below minimum mojos")] + CatChangeBelowMinimum, + + #[error("combine input verify timeout")] + CombineInputVerifyTimeout, + + #[error("bootstrap shape wait timeout")] + BootstrapShapeWaitTimeout, + + #[error("invalid ladder math")] + InvalidLadderMath, +} diff --git a/greenfloor-engine/src/error/config.rs b/greenfloor-engine/src/error/config.rs new file mode 100644 index 00000000..ded3027a --- /dev/null +++ b/greenfloor-engine/src/error/config.rs @@ -0,0 +1,17 @@ +use thiserror::Error; + +/// Program/markets `YAML` and operator-path configuration failures. +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("missing config field: {0}")] + MissingField(&'static str), + + #[error("offer execution requires signer.kms_key_id and vault.launcher_id in program config")] + SignerPathNotConfigured, + + #[error("daemon_already_running:{path}{detail}")] + DaemonAlreadyRunning { path: String, detail: String }, + + #[error("{0}")] + Parse(String), +} diff --git a/greenfloor-engine/src/error/mod.rs b/greenfloor-engine/src/error/mod.rs index df360475..d8f47bc3 100644 --- a/greenfloor-engine/src/error/mod.rs +++ b/greenfloor-engine/src/error/mod.rs @@ -1,271 +1,40 @@ use thiserror::Error; +mod coin_ops; +mod config; +mod offer; +mod persistence; mod transport; +mod vault; +pub use coin_ops::CoinOpsError; +pub use config::ConfigError; +pub use offer::OfferError; +pub use persistence::PersistenceError; pub use transport::TransportError; +pub use vault::VaultError; +/// Operator failure with a single domain owner per variant. #[derive(Debug, Error)] pub enum SignerError { - #[error("vault custody snapshot unavailable")] - VaultSnapshotUnavailable, - - #[error("vault launcher id missing or invalid")] - VaultLauncherIdInvalid, - - #[error("vault threshold or timelock invalid")] - VaultThresholdOrTimelockInvalid, - - #[error("unsupported vault signer cardinality")] - UnsupportedVaultSignerCardinality, - - #[error("unsupported vault threshold")] - UnsupportedVaultThreshold, - - #[error("invalid vault recovery timelock")] - InvalidVaultRecoveryTimelock, - - #[error("unsupported vault curve: {0}")] - UnsupportedVaultCurve(String), - - #[error("kms public key mismatch: kms={kms} custody={custody}")] - KmsPublicKeyMismatch { kms: String, custody: String }, - - #[error("vault single secp256r1 custody key required, found {0}")] - VaultSecp256r1KeyCount(usize), - - #[error("missing config field: {0}")] - MissingConfigField(&'static str), - - #[error("kms error: {0}")] - Kms(String), - #[error(transparent)] - Transport(#[from] TransportError), - + Vault(#[from] VaultError), + #[error(transparent)] + CoinOps(#[from] CoinOpsError), + #[error(transparent)] + Offer(#[from] OfferError), #[error(transparent)] Reconcile(#[from] crate::cycle::ReconcileStateError), - - #[error("unparseable cat lineage: {0}")] - UnparseableCatLineage(String), - - #[error("no unspent cat coins")] - NoUnspentCatCoins, - - #[error("insufficient cat coins")] - InsufficientCatCoins, - - #[error("preselected cat coins do not match requested coin ids")] - PreselectedCatCoinIdsMismatch, - - #[error("proven dust coin does not match spend-ready cat")] - ProvenDustCoinMismatch, - - #[error("failed to resolve cat lineage for coin {0}")] - CatLineageResolutionFailed(String), - - #[error("derivation scan failed for selected coin")] - MissingSigningKeyForSelectedCoins, - - #[error("no unspent xch coins")] - NoUnspentXchCoins, - - #[error("insufficient xch fee balance for mixed split")] - InsufficientXchFeeBalanceForMixedSplit, - - #[error("no unspent offer xch coins")] - NoUnspentOfferXchCoins, - - #[error("insufficient offer xch coins")] - InsufficientOfferXchCoins, - - #[error("no unspent offer cat coins")] - NoUnspentOfferCatCoins, - - #[error("insufficient offer cat coins")] - InsufficientOfferCatCoins, - - #[error("unsupported operation type")] - UnsupportedOperationType, - - #[error("invalid plan values")] - InvalidPlanValues, - - #[error("insufficient selected coin total")] - InsufficientSelectedCoinTotal, - - #[error("xch coin selection failed")] - XchCoinSelectionFailed, - - #[error("unsupported network for signing")] - UnsupportedNetworkForSigning, - - #[error("cat output below minimum mojos")] - CatOutputBelowMinimum, - - #[error("cat change below minimum mojos")] - CatChangeBelowMinimum, - - #[error( - "vault cat create destination is the receive CAT outer puzzle hash (would double-wrap)" - )] - VaultCatCreateDestinationIsOuterLayer, - - #[error("vault cat create destination is not the vault receive p2 puzzle hash")] - VaultCatCreateDestinationNotReceiveP2, - - #[error("vault receive message mode 23 not found")] - VaultReceiveMessageNotFound, - - #[error("vault singleton coin not found")] - VaultSingletonNotFound, - - #[error("mixed split vault with fee not supported")] - MixedSplitVaultWithFeeNotSupported, - - #[error("invalid output amount")] - InvalidOutputAmount, - - #[error("selected mixed split coins are not spendable")] - MixedSplitSelectedCoinsNotSpendable, - - #[error("missing receive address")] - MissingReceiveAddress, - - #[error("missing asset id")] - MissingAssetId, - - #[error("missing output amounts")] - MissingOutputAmounts, - - #[error("presplit requires a single source cat coin")] - PresplitRequiresSingleSourceCat, - - #[error("offer input exceeds offer amount; enable split-input-coins or specify exact coin")] - OfferInputRequiresPresplit, - - #[error( - "direct offer requires exactly one input coin equal to offer amount; combine or enable split-input-coins" - )] - DirectOfferRequiresSingleInputCoin, - - #[error("presplit coin not found on chain")] - PresplitCoinNotFound, - - #[error("timeout waiting for presplit coin confirmation")] - PresplitCoinConfirmationTimeout, - - #[error("combine input verify timeout")] - CombineInputVerifyTimeout, - - #[error("bootstrap shape wait timeout")] - BootstrapShapeWaitTimeout, - - #[error("presplit offer step requires --offer-coin-ids of original source coins")] - PresplitOfferRequiresSourceCoinIds, - - #[error("presplit coin amount {coin} does not match offer amount {offer}")] - PresplitCoinAmountMismatch { coin: u64, offer: u64 }, - - #[error("presplit coin asset id does not match offer asset id")] - PresplitCoinAssetMismatch, - - #[error("presplit offer path supports exactly one presplit coin")] - PresplitOfferRequiresSingleCoin, - - #[error("presplit coin p2 puzzle hash does not match offer binding")] - PresplitCoinPuzzleHashMismatch, - - #[error("offer_missing_expiration")] - OfferMissingExpiration, - - #[error("offer_duplicate_spent_coin_ids")] - OfferDuplicateSpentCoinIds, - - #[error("offer_cancel_offer_file_not_found")] - OfferCancelOfferFileNotFound, - - #[error("offer_cancel_offer_file_missing")] - OfferCancelOfferFileMissing, - - #[error("offer_cancel_no_spendable_input")] - OfferCancelNoSpendableInput, - - #[error("offer_cancel_input_not_presplit_maker")] - OfferCancelInputNotPresplitMaker, - - #[error("offer_cancel_input_not_vault_owned: coin={coin_id} puzzle_hash={puzzle_hash} launcher={launcher_id}")] - OfferCancelInputNotVaultOwned { - coin_id: String, - puzzle_hash: String, - launcher_id: String, - }, - - #[error("offer_cancel_presplit_binding_parse_failed:{detail}")] - OfferCancelPresplitBindingParseFailed { detail: String }, - - #[error("offer_cancel_input_coin_already_spent")] - OfferCancelInputCoinAlreadySpent, - - #[error("invalid_size_base_units")] - InvalidSizeBaseUnits, - - #[error("request_amount must be positive")] - InvalidOfferRequestAmount, - - #[error("invalid ladder math")] - InvalidLadderMath, - - #[error("invalid_offer_amount")] - InvalidOfferAmount, - - #[error("signer_asset_resolution_failed:resolved_assets_collide_for_non_xch_pair")] - ResolvedAssetsCollideForNonXchPair, - - #[error("reservation contention: {0}")] - ReservationContention(String), - - #[error("managed upstream transient: {0}")] - ManagedUpstreamTransient(String), - - #[error("database is locked")] - DatabaseLocked, - - #[error("failed to open sqlite db {path}: {open_error}")] - SqliteOpenFailed { path: String, open_error: String }, - - #[error("offer execution requires signer.kms_key_id and vault.launcher_id in program config")] - SignerPathNotConfigured, - - #[error("daemon_already_running:{path}{detail}")] - DaemonAlreadyRunning { path: String, detail: String }, - + #[error(transparent)] + Persistence(#[from] PersistenceError), + #[error(transparent)] + Transport(#[from] TransportError), + #[error(transparent)] + Config(#[from] ConfigError), #[error("{0}")] Other(String), } -fn is_transient_managed_upstream_error_text(error_text: &str) -> bool { - const MARKERS: &[&str] = &[ - "timed out", - "timeout", - "temporary unavailable", - "temporarily unavailable", - "bad gateway", - "gateway timeout", - "service unavailable", - "connection reset", - "connection refused", - "managed_offer_http_error:502", - "managed_offer_http_error:503", - "managed_offer_http_error:504", - "managed_offer_network_error", - "signer_http_error:502", - "signer_http_error:503", - "signer_http_error:504", - ]; - let normalized = error_text.trim().to_ascii_lowercase(); - MARKERS.iter().any(|marker| normalized.contains(marker)) -} - const MIXED_SPLIT_SELECTED_COINS_NOT_SPENDABLE: &str = "Some selected coins are not spendable"; fn mixed_split_selected_coins_not_spendable_message(message: &str) -> bool { @@ -288,6 +57,44 @@ impl SignerError { }) } + /// HTTP timeout (`reqwest::Error::is_timeout`). + #[must_use] + pub fn http_timeout(layer: &'static str, message: impl Into) -> Self { + Self::Transport(TransportError::Timeout { + layer, + message: message.into(), + }) + } + + /// HTTP connect failure (`reqwest::Error::is_connect`). + #[must_use] + pub fn http_connect(layer: &'static str, message: impl Into) -> Self { + Self::Transport(TransportError::Connect { + layer, + message: message.into(), + }) + } + + #[must_use] + pub fn from_reqwest(layer: &'static str, err: &reqwest::Error) -> Self { + Self::Transport(TransportError::from_reqwest(layer, err)) + } + + /// HTTP response with a non-success status code. + #[must_use] + pub fn http_status(layer: &'static str, status: u16, message: impl Into) -> Self { + Self::Transport(TransportError::HttpStatus { + layer, + status, + message: message.into(), + }) + } + + #[must_use] + pub fn is_http_not_found(&self) -> bool { + matches!(self, Self::Transport(err) if err.is_http_not_found()) + } + /// chia-wallet-sdk driver failure. #[must_use] pub fn driver(message: impl Into) -> Self { @@ -296,16 +103,22 @@ impl SignerError { #[must_use] pub fn is_mixed_split_selected_coins_not_spendable(&self) -> bool { - matches!(self, Self::MixedSplitSelectedCoinsNotSpendable) + matches!( + self, + Self::Vault(VaultError::MixedSplitSelectedCoinsNotSpendable) + ) } #[must_use] pub fn normalize_mixed_split_error(err: Self) -> Self { - if matches!(err, Self::MixedSplitSelectedCoinsNotSpendable) { + if matches!( + err, + Self::Vault(VaultError::MixedSplitSelectedCoinsNotSpendable) + ) { return err; } if mixed_split_selected_coins_not_spendable_message(&err.to_string()) { - Self::MixedSplitSelectedCoinsNotSpendable + VaultError::MixedSplitSelectedCoinsNotSpendable.into() } else { err } @@ -313,7 +126,10 @@ impl SignerError { #[must_use] pub fn is_sqlite_fatal(&self) -> bool { - matches!(self, Self::SqliteOpenFailed { .. }) + match self { + Self::Persistence(err) => err.is_sqlite_fatal(), + _ => false, + } } #[must_use] @@ -322,17 +138,8 @@ impl SignerError { return false; } match self { - Self::ReservationContention(_) - | Self::ManagedUpstreamTransient(_) - | Self::DatabaseLocked => true, - Self::Transport(TransportError::Http { message, .. }) => { - is_transient_managed_upstream_error_text(message) - } - Self::Other(message) => { - let message = message.as_str(); - message.contains("database is locked") - || is_transient_managed_upstream_error_text(message) - } + Self::Persistence(err) => err.is_parallel_dispatch_transient(), + Self::Transport(err) => err.is_parallel_dispatch_transient(), _ => false, } } @@ -353,53 +160,76 @@ impl From for SignerError { impl From for SignerError { fn from(err: reqwest::Error) -> Self { - SignerError::http("http", err.to_string()) + SignerError::from_reqwest("http", &err) } } #[cfg(test)] mod tests { - use super::{SignerError, TransportError}; + use super::{ + CoinOpsError, ConfigError, OfferError, PersistenceError, SignerError, TransportError, + VaultError, + }; #[test] fn signer_error_display_messages_are_stable() { let cases: Vec<(SignerError, &str)> = vec![ ( - SignerError::VaultLauncherIdInvalid, + VaultError::LauncherIdInvalid.into(), "vault launcher id missing or invalid", ), - (SignerError::InsufficientCatCoins, "insufficient cat coins"), ( - SignerError::CatLineageResolutionFailed("abcd".to_string()), + CoinOpsError::InsufficientCatCoins.into(), + "insufficient cat coins", + ), + ( + CoinOpsError::CatLineageResolutionFailed("abcd".to_string()).into(), "failed to resolve cat lineage for coin abcd", ), ( - SignerError::OfferInputRequiresPresplit, + OfferError::OfferInputRequiresPresplit.into(), "offer input exceeds offer amount; enable split-input-coins or specify exact coin", ), ( - SignerError::PresplitCoinConfirmationTimeout, + OfferError::PresplitCoinConfirmationTimeout.into(), "timeout waiting for presplit coin confirmation", ), ( - SignerError::KmsPublicKeyMismatch { + VaultError::KmsPublicKeyMismatch { kms: "aa".to_string(), custody: "bb".to_string(), - }, + } + .into(), "kms public key mismatch: kms=aa custody=bb", ), ( - SignerError::MissingConfigField("signer"), + ConfigError::MissingField("signer").into(), "missing config field: signer", ), ( - SignerError::ResolvedAssetsCollideForNonXchPair, + ConfigError::Parse("markets config root must be a mapping".to_string()).into(), + "markets config root must be a mapping", + ), + ( + OfferError::ResolvedAssetsCollideForNonXchPair.into(), "signer_asset_resolution_failed:resolved_assets_collide_for_non_xch_pair", ), (SignerError::coinset("down"), "coinset error: down"), ( - SignerError::http("dexie", "timeout"), - "http error (dexie): timeout", + SignerError::http("dexie", "bad json"), + "http error (dexie): bad json", + ), + ( + SignerError::http_timeout("dexie", "timed out"), + "http timeout (dexie): timed out", + ), + ( + SignerError::http_connect("dexie", "connection refused"), + "http connect (dexie): connection refused", + ), + ( + SignerError::http_status("dexie_http_error", 404, "missing"), + "http status 404 (dexie_http_error): missing", ), ( SignerError::driver("invalid mod hash"), @@ -412,21 +242,34 @@ mod tests { } #[test] - fn transient_error_text_detects_timeout_markers() { + fn transient_http_uses_timeout_and_connect_variants() { assert!( - SignerError::Other("managed_offer_network_error: connection reset".to_string()) + SignerError::http_timeout("dexie_network_error", "timed out") .is_parallel_dispatch_transient() ); + assert!( + SignerError::http_connect("dexie_network_error", "connection refused") + .is_parallel_dispatch_transient() + ); + assert!(!SignerError::http("dexie", "timeout").is_parallel_dispatch_transient()); + assert!( + !SignerError::http_status("dexie_http_error", 404, "missing") + .is_parallel_dispatch_transient() + ); + assert!(SignerError::http_status("dexie_http_error", 404, "missing").is_http_not_found()); + assert!(!SignerError::http("dexie", "missing").is_http_not_found()); + assert!( + !SignerError::Other("connection reset".to_string()).is_parallel_dispatch_transient() + ); assert!(!SignerError::Other("invalid offer".to_string()).is_parallel_dispatch_transient()); } #[test] fn parallel_dispatch_transient_matches_typed_contention_and_upstream() { - assert!(SignerError::ManagedUpstreamTransient("timeout".to_string()) - .is_parallel_dispatch_transient()); - assert!( - SignerError::ReservationContention("busy".to_string()).is_parallel_dispatch_transient() - ); + assert!(SignerError::http_timeout("dexie", "timed out").is_parallel_dispatch_transient()); + let contention: SignerError = + PersistenceError::ReservationContention("busy".to_string()).into(); + assert!(contention.is_parallel_dispatch_transient()); assert!( !SignerError::Other("PermanentOfferBuildFailure: bad puzzle".to_string()) .is_parallel_dispatch_transient() @@ -435,30 +278,39 @@ mod tests { #[test] fn sqlite_fatal_errors_are_not_parallel_dispatch_transient() { - assert!(SignerError::SqliteOpenFailed { - path: "/tmp/greenfloor.sqlite".to_string(), - open_error: "unable to open database file".to_string(), - } - .is_sqlite_fatal()); + assert!( + SignerError::Persistence(PersistenceError::SqliteOpenFailed { + path: "/tmp/greenfloor.sqlite".to_string(), + open_error: "unable to open database file".to_string(), + }) + .is_sqlite_fatal() + ); assert!(!SignerError::Other("database is locked".to_string()).is_sqlite_fatal()); - assert!(!SignerError::SqliteOpenFailed { - path: "/tmp/x".to_string(), - open_error: "permission denied".to_string(), - } - .is_parallel_dispatch_transient()); - assert!(SignerError::DatabaseLocked.is_parallel_dispatch_transient()); + assert!( + !SignerError::Persistence(PersistenceError::SqliteOpenFailed { + path: "/tmp/x".to_string(), + open_error: "permission denied".to_string(), + }) + .is_parallel_dispatch_transient() + ); + assert!(SignerError::Persistence(PersistenceError::DatabaseLocked) + .is_parallel_dispatch_transient()); } #[test] fn parallel_dispatch_transient_rejects_non_transient_variants() { assert!(!SignerError::driver("invalid mod hash").is_parallel_dispatch_transient()); - assert!(!SignerError::InsufficientCatCoins.is_parallel_dispatch_transient()); + assert!(!SignerError::http("dexie", "bad json").is_parallel_dispatch_transient()); + assert!(!SignerError::CoinOps(CoinOpsError::InsufficientCatCoins) + .is_parallel_dispatch_transient()); } #[test] fn mixed_split_selected_coins_not_spendable_is_classified() { - assert!(SignerError::MixedSplitSelectedCoinsNotSpendable - .is_mixed_split_selected_coins_not_spendable()); + assert!( + SignerError::Vault(VaultError::MixedSplitSelectedCoinsNotSpendable) + .is_mixed_split_selected_coins_not_spendable() + ); assert!( !SignerError::Other("upstream: Some selected coins are not spendable".to_string()) .is_mixed_split_selected_coins_not_spendable() @@ -467,7 +319,7 @@ mod tests { SignerError::normalize_mixed_split_error(SignerError::Other( "Some selected coins are not spendable".to_string() )), - SignerError::MixedSplitSelectedCoinsNotSpendable + SignerError::Vault(VaultError::MixedSplitSelectedCoinsNotSpendable) )); } diff --git a/greenfloor-engine/src/error/offer.rs b/greenfloor-engine/src/error/offer.rs new file mode 100644 index 00000000..dfa5fa6d --- /dev/null +++ b/greenfloor-engine/src/error/offer.rs @@ -0,0 +1,80 @@ +use thiserror::Error; + +/// Offer construction, presplit, cancel, and asset-resolution failures. +#[derive(Debug, Error)] +pub enum OfferError { + #[error("presplit requires a single source cat coin")] + PresplitRequiresSingleSourceCat, + + #[error("offer input exceeds offer amount; enable split-input-coins or specify exact coin")] + OfferInputRequiresPresplit, + + #[error( + "direct offer requires exactly one input coin equal to offer amount; combine or enable split-input-coins" + )] + DirectOfferRequiresSingleInputCoin, + + #[error("presplit coin not found on chain")] + PresplitCoinNotFound, + + #[error("timeout waiting for presplit coin confirmation")] + PresplitCoinConfirmationTimeout, + + #[error("presplit offer step requires --offer-coin-ids of original source coins")] + PresplitOfferRequiresSourceCoinIds, + + #[error("presplit coin amount {coin} does not match offer amount {offer}")] + PresplitCoinAmountMismatch { coin: u64, offer: u64 }, + + #[error("presplit coin asset id does not match offer asset id")] + PresplitCoinAssetMismatch, + + #[error("presplit offer path supports exactly one presplit coin")] + PresplitOfferRequiresSingleCoin, + + #[error("presplit coin p2 puzzle hash does not match offer binding")] + PresplitCoinPuzzleHashMismatch, + + #[error("offer_missing_expiration")] + OfferMissingExpiration, + + #[error("offer_duplicate_spent_coin_ids")] + OfferDuplicateSpentCoinIds, + + #[error("offer_cancel_offer_file_not_found")] + OfferCancelOfferFileNotFound, + + #[error("offer_cancel_offer_file_missing")] + OfferCancelOfferFileMissing, + + #[error("offer_cancel_no_spendable_input")] + OfferCancelNoSpendableInput, + + #[error("offer_cancel_input_not_presplit_maker")] + OfferCancelInputNotPresplitMaker, + + #[error("offer_cancel_input_not_vault_owned: coin={coin_id} puzzle_hash={puzzle_hash} launcher={launcher_id}")] + OfferCancelInputNotVaultOwned { + coin_id: String, + puzzle_hash: String, + launcher_id: String, + }, + + #[error("offer_cancel_presplit_binding_parse_failed:{detail}")] + OfferCancelPresplitBindingParseFailed { detail: String }, + + #[error("offer_cancel_input_coin_already_spent")] + OfferCancelInputCoinAlreadySpent, + + #[error("invalid_size_base_units")] + InvalidSizeBaseUnits, + + #[error("request_amount must be positive")] + InvalidOfferRequestAmount, + + #[error("invalid_offer_amount")] + InvalidOfferAmount, + + #[error("signer_asset_resolution_failed:resolved_assets_collide_for_non_xch_pair")] + ResolvedAssetsCollideForNonXchPair, +} diff --git a/greenfloor-engine/src/error/persistence.rs b/greenfloor-engine/src/error/persistence.rs new file mode 100644 index 00000000..26d6fdf2 --- /dev/null +++ b/greenfloor-engine/src/error/persistence.rs @@ -0,0 +1,26 @@ +use thiserror::Error; + +/// `SQLite` reservation and lock failures. +#[derive(Debug, Error)] +pub enum PersistenceError { + #[error("reservation contention: {0}")] + ReservationContention(String), + + #[error("database is locked")] + DatabaseLocked, + + #[error("failed to open sqlite db {path}: {open_error}")] + SqliteOpenFailed { path: String, open_error: String }, +} + +impl PersistenceError { + #[must_use] + pub fn is_sqlite_fatal(&self) -> bool { + matches!(self, Self::SqliteOpenFailed { .. }) + } + + #[must_use] + pub fn is_parallel_dispatch_transient(&self) -> bool { + matches!(self, Self::ReservationContention(_) | Self::DatabaseLocked) + } +} diff --git a/greenfloor-engine/src/error/transport.rs b/greenfloor-engine/src/error/transport.rs index 52df3851..6c2ca619 100644 --- a/greenfloor-engine/src/error/transport.rs +++ b/greenfloor-engine/src/error/transport.rs @@ -5,11 +5,51 @@ use thiserror::Error; pub enum TransportError { #[error("coinset error: {0}")] Coinset(String), + #[error("http timeout ({layer}): {message}")] + Timeout { + layer: &'static str, + message: String, + }, + #[error("http connect ({layer}): {message}")] + Connect { + layer: &'static str, + message: String, + }, #[error("http error ({layer}): {message}")] Http { layer: &'static str, message: String, }, + #[error("http status {status} ({layer}): {message}")] + HttpStatus { + layer: &'static str, + status: u16, + message: String, + }, #[error("driver error: {0}")] Driver(String), } + +impl TransportError { + #[must_use] + pub fn from_reqwest(layer: &'static str, err: &reqwest::Error) -> Self { + let message = err.to_string(); + if err.is_timeout() { + Self::Timeout { layer, message } + } else if err.is_connect() { + Self::Connect { layer, message } + } else { + Self::Http { layer, message } + } + } + + #[must_use] + pub fn is_http_not_found(&self) -> bool { + matches!(self, Self::HttpStatus { status: 404, .. }) + } + + #[must_use] + pub fn is_parallel_dispatch_transient(&self) -> bool { + matches!(self, Self::Timeout { .. } | Self::Connect { .. }) + } +} diff --git a/greenfloor-engine/src/error/vault.rs b/greenfloor-engine/src/error/vault.rs new file mode 100644 index 00000000..99106f11 --- /dev/null +++ b/greenfloor-engine/src/error/vault.rs @@ -0,0 +1,70 @@ +use thiserror::Error; + +/// Vault custody, KMS, mixed-split, and vault `CAT` create failures. +#[derive(Debug, Error)] +pub enum VaultError { + #[error("vault custody snapshot unavailable")] + SnapshotUnavailable, + + #[error("vault launcher id missing or invalid")] + LauncherIdInvalid, + + #[error("vault threshold or timelock invalid")] + ThresholdOrTimelockInvalid, + + #[error("unsupported vault signer cardinality")] + UnsupportedSignerCardinality, + + #[error("unsupported vault threshold")] + UnsupportedThreshold, + + #[error("invalid vault recovery timelock")] + InvalidRecoveryTimelock, + + #[error("unsupported vault curve: {0}")] + UnsupportedCurve(String), + + #[error("kms public key mismatch: kms={kms} custody={custody}")] + KmsPublicKeyMismatch { kms: String, custody: String }, + + #[error("vault single secp256r1 custody key required, found {0}")] + Secp256r1KeyCount(usize), + + #[error("kms error: {0}")] + Kms(String), + + #[error( + "vault cat create destination is the receive CAT outer puzzle hash (would double-wrap)" + )] + CatCreateDestinationIsOuterLayer, + + #[error("vault cat create destination is not the vault receive p2 puzzle hash")] + CatCreateDestinationNotReceiveP2, + + #[error("vault receive message mode 23 not found")] + ReceiveMessageNotFound, + + #[error("vault singleton coin not found")] + SingletonNotFound, + + #[error("mixed split vault with fee not supported")] + MixedSplitWithFeeNotSupported, + + #[error("selected mixed split coins are not spendable")] + MixedSplitSelectedCoinsNotSpendable, + + #[error("missing receive address")] + MissingReceiveAddress, + + #[error("missing asset id")] + MissingAssetId, + + #[error("missing output amounts")] + MissingOutputAmounts, + + #[error("invalid output amount")] + InvalidOutputAmount, + + #[error("unsupported network for signing")] + UnsupportedNetworkForSigning, +} diff --git a/greenfloor-engine/src/kms.rs b/greenfloor-engine/src/kms.rs index e9333531..26069e55 100644 --- a/greenfloor-engine/src/kms.rs +++ b/greenfloor-engine/src/kms.rs @@ -1,7 +1,7 @@ use aws_sdk_kms::primitives::Blob; use sha2::{Digest, Sha256}; -use crate::error::{SignerError, SignerResult}; +use crate::error::{SignerError, SignerResult, VaultError}; use crate::hex::normalize_hex; mod runtime; @@ -27,10 +27,14 @@ pub async fn get_public_key_compressed_hex( .key_id(key_id) .send() .await - .map_err(|err| SignerError::Kms(format!("GetPublicKey failed: {err}")))?; - let der_bytes = response - .public_key() - .ok_or_else(|| SignerError::Kms("GetPublicKey returned no public key".to_string()))?; + .map_err(|err| { + SignerError::Vault(VaultError::Kms(format!("GetPublicKey failed: {err}"))) + })?; + let der_bytes = response.public_key().ok_or_else(|| { + SignerError::Vault(VaultError::Kms( + "GetPublicKey returned no public key".to_string(), + )) + })?; let compressed = der_spki_to_compressed_p256(der_bytes.as_ref())?; Ok(hex::encode(compressed)) } @@ -46,8 +50,9 @@ pub async fn sign_digest( region: &str, message_hex: &str, ) -> SignerResult { - let message_bytes = hex::decode(normalize_hex(message_hex)) - .map_err(|err| SignerError::Kms(format!("invalid message hex: {err}")))?; + let message_bytes = hex::decode(normalize_hex(message_hex)).map_err(|err| { + SignerError::Vault(VaultError::Kms(format!("invalid message hex: {err}"))) + })?; let digest = Sha256::digest(&message_bytes); let client = runtime.client(region).await?; let response = client @@ -58,10 +63,10 @@ pub async fn sign_digest( .signing_algorithm(aws_sdk_kms::types::SigningAlgorithmSpec::EcdsaSha256) .send() .await - .map_err(|err| SignerError::Kms(format!("Sign failed: {err}")))?; - let der_sig = response - .signature() - .ok_or_else(|| SignerError::Kms("Sign returned no signature".to_string()))?; + .map_err(|err| SignerError::Vault(VaultError::Kms(format!("Sign failed: {err}"))))?; + let der_sig = response.signature().ok_or_else(|| { + SignerError::Vault(VaultError::Kms("Sign returned no signature".to_string())) + })?; let compact = der_ecdsa_to_compact(der_sig.as_ref())?; Ok(hex::encode(compact)) } @@ -76,23 +81,23 @@ pub fn der_spki_to_compressed_p256(der: &[u8]) -> SignerResult<[u8; 33]> { let (idx, algo_len) = read_der_tag_length(der, idx)?; let idx = idx + algo_len; if der.get(idx) != Some(&0x03) { - return Err(SignerError::Kms( + return Err(SignerError::Vault(VaultError::Kms( "expected BIT STRING tag (0x03)".to_string(), - )); + ))); } let (idx, bs_len) = read_der_tag_length(der, idx)?; if der.get(idx) != Some(&0x00) { - return Err(SignerError::Kms(format!( + return Err(SignerError::Vault(VaultError::Kms(format!( "unexpected unused-bits byte: {:#x}", der[idx] - ))); + )))); } let point = &der[idx + 1..idx + bs_len]; if point.len() != 65 || point[0] != 0x04 { - return Err(SignerError::Kms(format!( + return Err(SignerError::Vault(VaultError::Kms(format!( "expected 65-byte uncompressed point (0x04||x||y), got {} bytes", point.len() - ))); + )))); } let x = &point[1..33]; let y = &point[33..65]; @@ -136,7 +141,7 @@ fn read_der_tag_length(data: &[u8], offset: usize) -> SignerResult<(usize, usize let offset = offset + 1; let first = *data .get(offset) - .ok_or_else(|| SignerError::Kms("truncated DER".to_string()))?; + .ok_or_else(|| SignerError::Vault(VaultError::Kms("truncated DER".to_string())))?; if first & 0x80 == 0 { return Ok((offset + 1, first as usize)); } @@ -152,10 +157,10 @@ fn read_der_tag_length(data: &[u8], offset: usize) -> SignerResult<(usize, usize fn read_der_integer(data: &[u8], offset: usize) -> SignerResult<(Vec, usize)> { if data.get(offset) != Some(&0x02) { - return Err(SignerError::Kms(format!( + return Err(SignerError::Vault(VaultError::Kms(format!( "expected INTEGER tag (0x02), got {:#x}", data.get(offset).copied().unwrap_or_default() - ))); + )))); } let (offset, length) = read_der_tag_length(data, offset)?; let mut raw = data[offset..offset + length].to_vec(); diff --git a/greenfloor-engine/src/kms/runtime.rs b/greenfloor-engine/src/kms/runtime.rs index 9ff2f1a6..97ff1135 100644 --- a/greenfloor-engine/src/kms/runtime.rs +++ b/greenfloor-engine/src/kms/runtime.rs @@ -2,7 +2,7 @@ use aws_sdk_kms::Client; -use crate::error::{SignerError, SignerResult}; +use crate::error::{SignerError, SignerResult, VaultError}; #[derive(Debug, Clone, Default)] pub struct KmsOverrides { @@ -44,9 +44,9 @@ impl KmsRuntime { /// Returns an error when `fast_fail` is set on the active overrides. pub fn ensure_client_allowed(&self) -> SignerResult<()> { if self.overrides.fast_fail { - return Err(SignerError::Kms( + return Err(SignerError::Vault(VaultError::Kms( "credentials not configured (test fast fail)".to_string(), - )); + ))); } Ok(()) } diff --git a/greenfloor-engine/src/kms_cli.rs b/greenfloor-engine/src/kms_cli.rs index 68926229..71a757a2 100644 --- a/greenfloor-engine/src/kms_cli.rs +++ b/greenfloor-engine/src/kms_cli.rs @@ -48,7 +48,7 @@ pub async fn run_kms_public_key_compressed_hex_with_runtime( #[cfg(test)] mod tests { use super::*; - use crate::error::SignerError; + use crate::error::{SignerError, VaultError}; use crate::kms::KmsOverrides; use serde_json::{json, Value}; @@ -87,7 +87,7 @@ mod tests { ) .await .expect_err("fast fail kms"); - assert!(matches!(err, SignerError::Kms(_))); + assert!(matches!(err, SignerError::Vault(VaultError::Kms(_)))); assert!( err.to_string().to_ascii_lowercase().contains("credentials"), "unexpected kms failure: {err}" diff --git a/greenfloor-engine/src/manager_cli/coin_op_loop/list.rs b/greenfloor-engine/src/manager_cli/coin_op_loop/list.rs index 883ffa01..3fa2a942 100644 --- a/greenfloor-engine/src/manager_cli/coin_op_loop/list.rs +++ b/greenfloor-engine/src/manager_cli/coin_op_loop/list.rs @@ -7,7 +7,7 @@ use crate::coinset::list_wallet_unspent_coins_for_signer; use crate::config::{ load_gated_operator_market, GatedOperatorMarketLoadRequest, OperatorMarketCommand, }; -use crate::error::{SignerError, SignerResult}; +use crate::error::{ConfigError, SignerError, SignerResult}; use crate::manager_cli::context::ManagerContext; @@ -168,11 +168,11 @@ async fn run_coin_list_command(cmd: CoinListCommand<'_>) -> SignerResult { }) .await { - Err(SignerError::SignerPathNotConfigured) => { + Err(SignerError::Config(ConfigError::SignerPathNotConfigured)) => { mgr.emit_json(&json!({ "ok": false, "error": "coin_list_requires_signer_backend", - "detail": SignerError::SignerPathNotConfigured.to_string(), + "detail": SignerError::Config(ConfigError::SignerPathNotConfigured).to_string(), }))?; return Ok(2); } diff --git a/greenfloor-engine/src/manager_cli/combine_market_cat_dust/batch_plan_test.rs b/greenfloor-engine/src/manager_cli/combine_market_cat_dust/batch_plan_test.rs index bc495856..b54576d8 100644 --- a/greenfloor-engine/src/manager_cli/combine_market_cat_dust/batch_plan_test.rs +++ b/greenfloor-engine/src/manager_cli/combine_market_cat_dust/batch_plan_test.rs @@ -6,7 +6,7 @@ use serde_json::json; use super::batches::DustBatchRunSelection; use super::combine_test_support::{ok_mixed_split_result, sample_combine_batch_plan}; use super::execute::{run_batch_plan, BatchPlanRunner}; -use crate::error::{SignerError, SignerResult}; +use crate::error::{CoinOpsError, SignerError, SignerResult}; use crate::vault::mixed_split::MixedSplitResult; use crate::vault_coinset_scan::{DustCombineBatch, DustPlan}; @@ -40,7 +40,9 @@ impl BatchPlanRunner for MockBatchPlanRunner { async fn wait_for_batch_spent(&self, _batch: &DustCombineBatch) -> SignerResult<()> { self.wait_calls.fetch_add(1, Ordering::SeqCst); if self.fail_wait { - Err(SignerError::CombineInputVerifyTimeout) + Err(SignerError::CoinOps( + CoinOpsError::CombineInputVerifyTimeout, + )) } else { Ok(()) } diff --git a/greenfloor-engine/src/manager_cli/combine_market_cat_dust/batches.rs b/greenfloor-engine/src/manager_cli/combine_market_cat_dust/batches.rs index 27df7900..6dd7cb04 100644 --- a/greenfloor-engine/src/manager_cli/combine_market_cat_dust/batches.rs +++ b/greenfloor-engine/src/manager_cli/combine_market_cat_dust/batches.rs @@ -1,6 +1,6 @@ use serde_json::{json, Value}; -use crate::error::SignerError; +use crate::error::{CoinOpsError, SignerError}; use crate::vault::mixed_split::MixedSplitResult; use crate::vault_coinset_scan::{DustCoin, DustCombineBatch, DustPlan}; @@ -22,9 +22,11 @@ impl BatchReportReason { pub(crate) fn batch_stderr_tail(err: &SignerError) -> String { match err { - SignerError::CombineInputVerifyTimeout => BatchReportReason::CombineInputVerifyTimeout - .stderr_tail() - .to_string(), + SignerError::CoinOps(CoinOpsError::CombineInputVerifyTimeout) => { + BatchReportReason::CombineInputVerifyTimeout + .stderr_tail() + .to_string() + } SignerError::Other(msg) => msg.clone(), _ => err.to_string(), } @@ -172,7 +174,9 @@ mod tests { #[test] fn batch_stderr_tail_maps_special_cases_and_delegates_display() { assert_eq!( - batch_stderr_tail(&SignerError::CombineInputVerifyTimeout), + batch_stderr_tail(&SignerError::CoinOps( + CoinOpsError::CombineInputVerifyTimeout + )), "combine input verify timeout" ); assert_eq!( @@ -180,8 +184,10 @@ mod tests { "dust batch total is zero" ); assert_eq!( - batch_stderr_tail(&SignerError::PreselectedCatCoinIdsMismatch), - SignerError::PreselectedCatCoinIdsMismatch.to_string() + batch_stderr_tail(&SignerError::CoinOps( + CoinOpsError::PreselectedCatCoinIdsMismatch + )), + SignerError::CoinOps(CoinOpsError::PreselectedCatCoinIdsMismatch).to_string() ); } diff --git a/greenfloor-engine/src/manager_cli/combine_market_cat_dust/execute_test.rs b/greenfloor-engine/src/manager_cli/combine_market_cat_dust/execute_test.rs index 48f30179..3a9bd24b 100644 --- a/greenfloor-engine/src/manager_cli/combine_market_cat_dust/execute_test.rs +++ b/greenfloor-engine/src/manager_cli/combine_market_cat_dust/execute_test.rs @@ -8,7 +8,7 @@ use crate::coinset::test_support::{ mock_unspent_coin_record_by_name_body, }; use crate::coinset::{CoinSpentVerifyConfig, PollConfig}; -use crate::error::SignerError; +use crate::error::{CoinOpsError, SignerError}; use crate::vault_coinset_scan::{DustCombineBatch, ProvenDustCoin}; #[tokio::test] @@ -84,5 +84,8 @@ async fn combine_batch_executor_verify_times_out_when_inputs_stay_unspent() { .wait_for_batch_spent(&batch) .await .expect_err("verify timeout"); - assert!(matches!(err, SignerError::CombineInputVerifyTimeout)); + assert!(matches!( + err, + SignerError::CoinOps(CoinOpsError::CombineInputVerifyTimeout) + )); } diff --git a/greenfloor-engine/src/manager_cli/combine_market_cat_dust/mod.rs b/greenfloor-engine/src/manager_cli/combine_market_cat_dust/mod.rs index 8c9b2e4a..87b7500b 100644 --- a/greenfloor-engine/src/manager_cli/combine_market_cat_dust/mod.rs +++ b/greenfloor-engine/src/manager_cli/combine_market_cat_dust/mod.rs @@ -23,7 +23,7 @@ use crate::coinset::ResolvedCoinsetEndpoint; use crate::config::{ load_combine_command_resources, CombineCommandLoadRequest, ManagerProgramConfig, }; -use crate::error::{SignerError, SignerResult}; +use crate::error::{ConfigError, SignerError, SignerResult}; use crate::manager_cli::context::ManagerContext; use crate::manager_cli::vault_scan::{ manager_vault_scan_params, resolve_manager_vault_launcher, run_manager_vault_scan, @@ -76,7 +76,10 @@ fn emit_command_error( } fn signer_load_error_reason(err: &SignerError) -> &'static str { - if matches!(err, SignerError::SignerPathNotConfigured) { + if matches!( + err, + SignerError::Config(ConfigError::SignerPathNotConfigured) + ) { "signer_not_configured" } else { "signer_load_failed" diff --git a/greenfloor-engine/src/offer/action.rs b/greenfloor-engine/src/offer/action.rs index 4f9dab24..06c7d015 100644 --- a/greenfloor-engine/src/offer/action.rs +++ b/greenfloor-engine/src/offer/action.rs @@ -9,7 +9,7 @@ use crate::config::{ bake_expiry_into_conditions_for_quote, CatTickerIndex, MarketConfig, MarketPricing, SignerConfig, }; -use crate::error::{SignerError, SignerResult}; +use crate::error::{OfferError, SignerError, SignerResult}; use crate::offer::assets::OfferAssetResolver; use crate::offer::build::build_vault_cat_offer; use crate::offer::build_context::resolve_offer_expiry_for_pricing; @@ -86,7 +86,8 @@ pub(crate) fn offer_terms_from_resolved_assets( side: &str, ) -> SignerResult { let quote_price = market.quote_price_for_side(side)?; - let size_i64 = i64::try_from(size_base_units).map_err(|_| SignerError::InvalidSizeBaseUnits)?; + let size_i64 = i64::try_from(size_base_units) + .map_err(|_| SignerError::Offer(OfferError::InvalidSizeBaseUnits))?; let leg = compute_signer_offer_leg_amounts( size_i64, quote_price, @@ -176,8 +177,8 @@ fn leg_amounts_for_request( resolved_quote_asset_id: &str, quote_price: f64, ) -> SignerResult { - let size = - i64::try_from(request.size_base_units).map_err(|_| SignerError::InvalidSizeBaseUnits)?; + let size = i64::try_from(request.size_base_units) + .map_err(|_| SignerError::Offer(OfferError::InvalidSizeBaseUnits))?; compute_signer_offer_leg_amounts( size, quote_price, diff --git a/greenfloor-engine/src/offer/assemble.rs b/greenfloor-engine/src/offer/assemble.rs index a711a841..60d7acee 100644 --- a/greenfloor-engine/src/offer/assemble.rs +++ b/greenfloor-engine/src/offer/assemble.rs @@ -5,7 +5,7 @@ use chia_sdk_driver::{Action, Id, Offer, Spends}; use clvmr::Allocator; use crate::coinset::{spend_bundle_hex, OfferCoinsetBackend, SelectedCats}; -use crate::error::{SignerError, SignerResult}; +use crate::error::{OfferError, SignerError, SignerResult}; use crate::hex::tree_hash_to_hex; use crate::offer::plan::{build_offer_payment_bundle, build_offer_request_conditions}; use crate::offer::presplit::{ @@ -26,13 +26,13 @@ pub(crate) fn validate_existing_presplit_cat( offer_amount: u64, ) -> SignerResult<()> { if presplit_cat.info.asset_id != offer_asset_id { - return Err(SignerError::PresplitCoinAssetMismatch); + return Err(SignerError::Offer(OfferError::PresplitCoinAssetMismatch)); } if presplit_cat.coin.amount != offer_amount { - return Err(SignerError::PresplitCoinAmountMismatch { + return Err(SignerError::Offer(OfferError::PresplitCoinAmountMismatch { coin: presplit_cat.coin.amount, offer: offer_amount, - }); + })); } Ok(()) } @@ -255,7 +255,9 @@ pub(crate) async fn execute_direct_offer( // Plan enforces a single Direct input; cancel metadata is required for Coinset-primary cancel. let [cat] = selection.selected.as_slice() else { - return Err(SignerError::DirectOfferRequiresSingleInputCoin); + return Err(SignerError::Offer( + OfferError::DirectOfferRequiresSingleInputCoin, + )); }; let cancel_fields = OfferCancelFields::from_direct_build( hex::encode(cat.coin.coin_id()), @@ -298,16 +300,19 @@ mod tests { 1000, ) .unwrap_err(); - assert!(matches!(err, SignerError::PresplitCoinAssetMismatch)); + assert!(matches!( + err, + SignerError::Offer(OfferError::PresplitCoinAssetMismatch) + )); let err = validate_existing_presplit_cat(&sample_cat(asset_id, 500), asset_id, 1000).unwrap_err(); assert!(matches!( err, - SignerError::PresplitCoinAmountMismatch { + SignerError::Offer(OfferError::PresplitCoinAmountMismatch { coin: 500, offer: 1000 - } + }) )); } diff --git a/greenfloor-engine/src/offer/assets.rs b/greenfloor-engine/src/offer/assets.rs index bf38633b..428b8b2e 100644 --- a/greenfloor-engine/src/offer/assets.rs +++ b/greenfloor-engine/src/offer/assets.rs @@ -5,7 +5,7 @@ use chia_sdk_coinset::CoinsetClient; use crate::coinset::{is_xch_like_asset, lookup_asset_by_symbol}; use crate::config::{lookup_asset_id_from_ticker, resolve_quote_asset_for_offer, CatTickerIndex}; use crate::config::{MarketConfig, SignerConfig}; -use crate::error::{SignerError, SignerResult}; +use crate::error::{OfferError, SignerError, SignerResult, VaultError}; use crate::vault_coinset_scan::types::AssetTypeFilter; /// Resolved on-chain asset ids for a configured market row (offer build / reservations). @@ -215,7 +215,9 @@ pub async fn resolve_offer_asset_ids( fn ensure_distinct_non_xch_pair(base: &str, quote: &str) -> SignerResult<()> { if base == quote && !is_xch_like_asset(base) && !is_xch_like_asset(quote) { - return Err(SignerError::ResolvedAssetsCollideForNonXchPair); + return Err(SignerError::Offer( + OfferError::ResolvedAssetsCollideForNonXchPair, + )); } Ok(()) } @@ -228,7 +230,7 @@ fn ensure_distinct_non_xch_pair(base: &str, quote: &str) -> SignerResult<()> { pub fn normalize_asset_id(raw: &str) -> SignerResult { let trimmed = raw.trim().to_lowercase(); if trimmed.is_empty() { - return Err(SignerError::MissingAssetId); + return Err(SignerError::Vault(VaultError::MissingAssetId)); } if matches!(trimmed.as_str(), "xch" | "txch" | "1") { return Ok(trimmed); @@ -287,7 +289,7 @@ mod tests { let err = ensure_distinct_non_xch_pair(&cat, &cat).expect_err("collision"); assert!(matches!( err, - SignerError::ResolvedAssetsCollideForNonXchPair + SignerError::Offer(OfferError::ResolvedAssetsCollideForNonXchPair) )); } diff --git a/greenfloor-engine/src/offer/cancel_input.rs b/greenfloor-engine/src/offer/cancel_input.rs index f3fccbe5..d922bdc9 100644 --- a/greenfloor-engine/src/offer/cancel_input.rs +++ b/greenfloor-engine/src/offer/cancel_input.rs @@ -1,7 +1,7 @@ //! Classify cancellable offer maker coins and resolve on-chain CAT inputs for cancel. use crate::coinset::OfferCoinsetBackend; -use crate::error::{SignerError, SignerResult}; +use crate::error::{OfferError, SignerError, SignerResult}; use crate::hex::{hex_to_bytes32, hex_to_tree_hash}; use crate::offer::presplit::{ offer_maker_cat_from_coin_input, presplit_binding_from_coin_input, @@ -137,7 +137,7 @@ pub(crate) async fn resolve_cancellable_cat( for coin_id in coin_id_candidates_for_cat_resolution(coin, spend_bundle, metadata)? { match fetch_input_cat_by_coin_id(backend, coin_id, coin.amount).await { Ok(Some(cat)) if cat.coin.coin_id() == coin.coin_id() => return Ok(Some(cat)), - Ok(_) | Err(SignerError::PresplitCoinNotFound) => {} + Ok(_) | Err(SignerError::Offer(OfferError::PresplitCoinNotFound)) => {} Err(err) => return Err(err), } } @@ -156,7 +156,7 @@ fn member_fixed_hash_from_stored_fields( .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) - .ok_or(SignerError::OfferCancelNoSpendableInput)?, + .ok_or(SignerError::Offer(OfferError::OfferCancelNoSpendableInput))?, )?; let binding_p2 = cat.map_or(coin.puzzle_hash, |value| value.info.p2_puzzle_hash); resolve_member_fixed_conditions_hash_for_binding(launcher_id, binding_p2, hash) @@ -202,7 +202,7 @@ fn classify_coin_and_cat( metadata, offer_bundle, )? - .ok_or(SignerError::OfferCancelNoSpendableInput); + .ok_or(SignerError::Offer(OfferError::OfferCancelNoSpendableInput)); } resolve_presplit_maker(vault_ctx.launcher_id, coin, None, metadata, offer_bundle)? @@ -232,7 +232,7 @@ fn resolve_presplit_maker( fixed_conditions_member_hash, })) } - Err(SignerError::PresplitCoinPuzzleHashMismatch) => { + Err(SignerError::Offer(OfferError::PresplitCoinPuzzleHashMismatch)) => { Err(offer_cancel_input_not_vault_owned(coin, launcher_id)) } Err(err) => Err(err), @@ -256,7 +256,7 @@ fn resolve_presplit_maker( })) } Ok(PresplitBindingLookup::NotPresplitMaker) => Ok(None), - Err(SignerError::PresplitCoinPuzzleHashMismatch) => { + Err(SignerError::Offer(OfferError::PresplitCoinPuzzleHashMismatch)) => { Err(offer_cancel_input_not_vault_owned(coin, launcher_id)) } Err(err) => Err(err), @@ -264,11 +264,11 @@ fn resolve_presplit_maker( } fn offer_cancel_input_not_vault_owned(coin: Coin, launcher_id: Bytes32) -> SignerError { - SignerError::OfferCancelInputNotVaultOwned { + SignerError::Offer(OfferError::OfferCancelInputNotVaultOwned { coin_id: hex::encode(coin.coin_id()), puzzle_hash: hex::encode(coin.puzzle_hash), launcher_id: hex::encode(launcher_id), - } + }) } async fn ensure_offer_input_unspent( @@ -276,7 +276,9 @@ async fn ensure_offer_input_unspent( coin_id: Bytes32, ) -> SignerResult<()> { if backend.offer_input_coin_is_spent(coin_id).await? { - return Err(SignerError::OfferCancelInputCoinAlreadySpent); + return Err(SignerError::Offer( + OfferError::OfferCancelInputCoinAlreadySpent, + )); } Ok(()) } @@ -307,8 +309,8 @@ fn direct_vault_cat_missing_on_coinset( /// /// # Errors /// -/// Returns [`SignerError::OfferCancelInputNotVaultOwned`] when the coin is not vault-owned. -/// Returns [`SignerError::OfferCancelInputCoinAlreadySpent`] when coinset shows the maker +/// Returns [`crate::error::OfferError::OfferCancelInputNotVaultOwned`] when the coin is not vault-owned. +/// Returns [`crate::error::OfferError::OfferCancelInputCoinAlreadySpent`] when coinset shows the maker /// input is missing or already spent. pub(crate) async fn classify_cancellable_maker_input( vault_ctx: &mut VaultSpendContext, @@ -339,7 +341,9 @@ pub(crate) async fn classify_cancellable_maker_input( } if direct_vault_cat_missing_on_coinset(vault_ctx, coin, spend_bundle, metadata)? { - return Err(SignerError::OfferCancelInputCoinAlreadySpent); + return Err(SignerError::Offer( + OfferError::OfferCancelInputCoinAlreadySpent, + )); } Err(offer_cancel_input_not_vault_owned( @@ -371,7 +375,7 @@ pub(crate) async fn classify_maker_input_from_stored_metadata { return classify_coin_and_cat(vault_ctx, cat.coin, Some(cat), Some(metadata), None); } - Err(SignerError::PresplitCoinNotFound) => {} + Err(SignerError::Offer(OfferError::PresplitCoinNotFound)) => {} Err(err) => return Err(err), } diff --git a/greenfloor-engine/src/offer/codec.rs b/greenfloor-engine/src/offer/codec.rs index 140d6e0f..31816997 100644 --- a/greenfloor-engine/src/offer/codec.rs +++ b/greenfloor-engine/src/offer/codec.rs @@ -7,7 +7,7 @@ use chia_traits::Streamable; use clvm_traits::FromClvm; use clvmr::{serde::node_from_bytes, Allocator, NodePtr}; -use crate::error::{SignerError, SignerResult, TransportError}; +use crate::error::{OfferError, SignerError, SignerResult, TransportError}; use crate::hex::normalize_hex_id; type RequestedXchPayments = Vec<(Vec, Vec<(Vec, u64)>)>; @@ -102,7 +102,7 @@ pub fn expires_at_seconds_from_offer_spend( .coin_spends .iter() .find(|spend| spend.coin.coin_id() == coin_id) - .ok_or(SignerError::OfferCancelNoSpendableInput)?; + .ok_or(SignerError::Offer(OfferError::OfferCancelNoSpendableInput))?; expires_at_seconds_from_coin_spend(coin_spend) } @@ -161,20 +161,22 @@ pub fn validate_offer_structure(offer: &str) -> SignerResult<()> { pub fn validate_offer_text(offer: &str) -> SignerResult<()> { let spend_bundle = decode_and_parse_offer(offer)?; if offer_has_duplicate_spent_coin_ids(&spend_bundle) { - return Err(SignerError::OfferDuplicateSpentCoinIds); + return Err(SignerError::Offer(OfferError::OfferDuplicateSpentCoinIds)); } if !offer_has_expiration_condition(&spend_bundle)? { - return Err(SignerError::OfferMissingExpiration); + return Err(SignerError::Offer(OfferError::OfferMissingExpiration)); } Ok(()) } fn dexie_verify_error_code(err: SignerError) -> String { match err { - SignerError::OfferDuplicateSpentCoinIds => { + SignerError::Offer(OfferError::OfferDuplicateSpentCoinIds) => { "wallet_sdk_offer_duplicate_spent_coin_ids".to_string() } - SignerError::OfferMissingExpiration => "wallet_sdk_offer_missing_expiration".to_string(), + SignerError::Offer(OfferError::OfferMissingExpiration) => { + "wallet_sdk_offer_missing_expiration".to_string() + } SignerError::Transport(TransportError::Driver(msg)) => { format!("wallet_sdk_offer_validate_failed:driver:{msg}") } diff --git a/greenfloor-engine/src/offer/lifecycle/market_reconcile/augment.rs b/greenfloor-engine/src/offer/lifecycle/market_reconcile/augment.rs index 35610928..78f9f097 100644 --- a/greenfloor-engine/src/offer/lifecycle/market_reconcile/augment.rs +++ b/greenfloor-engine/src/offer/lifecycle/market_reconcile/augment.rs @@ -12,7 +12,7 @@ use crate::storage::SqliteStore; use super::super::dexie_index::index_list_offers_by_local_ids; use super::super::reconcile_prep::{ - ensure_watches_from_dexie_payload, fetch_dexie_offer, DexieFetchMode, DexieOfferFetch, + ensure_watches_from_dexie_payload, fetch_dexie_offer, DexieOfferFetch, }; use super::super::{ReconcilePersistOptions, WatchedOfferReconciler}; use super::transition::{note_reconcile_transition_side_effects, ReconcileMarketCycleMetrics}; @@ -22,36 +22,6 @@ pub struct AugmentedDexieOffers { pub by_local_id: HashMap, } -fn apply_missing_watched_offer( - store: &SqliteStore, - market_id: &str, - watched_offer_id: &str, - error_text: &str, - state_by_offer_id: &mut HashMap, - metrics: &mut ReconcileMarketCycleMetrics, -) -> SignerResult<()> { - let current_state = state_by_offer_id - .get(watched_offer_id) - .map_or("open", String::as_str); - let options = ReconcilePersistOptions { - action: "reconcile_coins_and_offers", - venue: Some(crate::config::Venue::Dexie), - dexie_error: Some(error_text), - }; - let transition = WatchedOfferReconciler::new(store, &options).apply_missing( - market_id, - watched_offer_id, - current_state, - )?; - note_reconcile_transition_side_effects( - &transition, - watched_offer_id, - metrics, - state_by_offer_id, - ); - Ok(()) -} - /// Shared Dexie watch-error audit for the daemon cycle heal callback and watchlist augment — /// same `market_id`/`offer_id`/`error` payload shape and `DEXIE_WATCHLIST_AUGMENT_ERROR` /// event, differing only in the human-readable `message` for each call site. @@ -104,25 +74,38 @@ async fn fetch_missing_watched_offers( state_by_offer_id: &mut HashMap, metrics: &mut ReconcileMarketCycleMetrics, ) -> SignerResult<()> { + let options = ReconcilePersistOptions { + action: "reconcile_coins_and_offers", + venue: Some(crate::config::Venue::Dexie), + dexie_error: None, + }; + let reconciler = WatchedOfferReconciler::new(store, &options); for watched_offer_id in dexie_offer_ids { if augmented_by_local_id.contains_key(watched_offer_id) { continue; } - match fetch_dexie_offer(dexie, watched_offer_id, DexieFetchMode::HealStrict).await { - DexieOfferFetch::Found(body) => { + let current_state = state_by_offer_id + .get(watched_offer_id) + .map_or("open", String::as_str); + match fetch_dexie_offer(dexie, watched_offer_id).await { + Ok(DexieOfferFetch::Found(body)) => { augmented_by_local_id.insert(watched_offer_id.clone(), body); } - DexieOfferFetch::Missing(error_text) => { - apply_missing_watched_offer( - store, + Ok(DexieOfferFetch::Missing(error_text)) => { + let transition = reconciler.apply_missing( market_id, watched_offer_id, - &error_text, - state_by_offer_id, - metrics, + current_state, + Some(&error_text), )?; + note_reconcile_transition_side_effects( + &transition, + watched_offer_id, + metrics, + state_by_offer_id, + ); } - DexieOfferFetch::Mismatch => { + Ok(DexieOfferFetch::Mismatch) => { record_watchlist_augment_error( store, market_id, @@ -131,8 +114,14 @@ async fn fetch_missing_watched_offers( metrics, )?; } - DexieOfferFetch::LookupError(err) => { - record_watchlist_augment_error(store, market_id, watched_offer_id, &err, metrics)?; + Err(err) => { + record_watchlist_augment_error( + store, + market_id, + watched_offer_id, + &err.to_string(), + metrics, + )?; } } } diff --git a/greenfloor-engine/src/offer/lifecycle/mod.rs b/greenfloor-engine/src/offer/lifecycle/mod.rs index 29f77877..ca261f8c 100644 --- a/greenfloor-engine/src/offer/lifecycle/mod.rs +++ b/greenfloor-engine/src/offer/lifecycle/mod.rs @@ -57,10 +57,7 @@ pub use signal_apply::WatchedOfferReconciler; pub use status_cli::{ offers_status_cli, OfferStatusAuditEvent, OfferStatusRow, OffersStatusCliResult, }; -pub use transition::{ - coinset_signals_from_dexie_offer_payload, missing_offer_error_from_payload, - resolve_watched_offer_transition_from_dexie_fetch, WatchedOfferTransitionEnv, -}; +pub use transition::{coinset_signals_from_dexie_offer_payload, WatchedOfferTransitionEnv}; pub use ws_apply::{ apply_watch_hits_batch, apply_ws_offer_event, promote_cancel_submitted_for_confirmed_txs, signals_for_watch_hit, WsOfferApply, diff --git a/greenfloor-engine/src/offer/lifecycle/reconcile_prep/dexie_fetch.rs b/greenfloor-engine/src/offer/lifecycle/reconcile_prep/dexie_fetch.rs index 7c48aa2e..76aaa95a 100644 --- a/greenfloor-engine/src/offer/lifecycle/reconcile_prep/dexie_fetch.rs +++ b/greenfloor-engine/src/offer/lifecycle/reconcile_prep/dexie_fetch.rs @@ -1,189 +1,202 @@ //! Metrics-agnostic Dexie `get_offer` parsing shared by reconcile prepare, augment, and CLI. -use serde_json::Value; - -use crate::adapters::is_dexie_offer_missing_error_text; -use crate::adapters::DexieClient; -use crate::error::{SignerError, SignerResult}; +use crate::adapters::{DexieClient, DexieResponse}; +use crate::error::{OfferError, SignerError, SignerResult}; use crate::offer::dexie_payload::DexieOfferPayload; use super::super::dexie_index::offer_matches_local_id; -use super::super::transition::missing_offer_error_from_payload; - -/// Whether a Dexie `get_offer` result must match the requested local offer id. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DexieFetchMode { - /// Watch-heal / augment paths: an id mismatch is a hard failure (produces `Mismatch`). - HealStrict, - /// Lifecycle resolve paths: accept any returned payload — Dexie's `get_offer` is keyed - /// by the id we requested, so a payload it returns is authoritative for that id even - /// when the payload's own `id` field looks different. Never produces `Mismatch`. - LifecycleLoose, -} /// Result of a single Dexie `get_offer` lookup, before lifecycle or watch side effects. #[derive(Debug, Clone, PartialEq, Eq)] pub enum DexieOfferFetch { - /// Offer body (`get_offer.offer` when present and id-matched, else top-level payload). - Found(Value), - /// Dexie reported the offer missing (404 body or missing-error text). + /// Nested `offer` object whose lookup keys match the requested local id. + Found(serde_json::Value), + /// Dexie reported the offer missing (HTTP 404 or explicit `success: false`). Missing(String), - /// Response succeeded but did not match the requested local offer id - /// (only produced for [`DexieFetchMode::HealStrict`]). + /// Response succeeded but did not match the requested local offer id. Mismatch, - /// Transport or non-missing Dexie error. - LookupError(String), } -/// Parse a Dexie `get_offer` JSON body for one local offer id. -/// -/// Watch-heal paths (`HealStrict`) require an `offer` sub-object whose lookup keys match -/// `offer_id`. Lifecycle paths (`LifecycleLoose`) accept `offer` when present, otherwise the -/// top-level payload. -#[must_use] -pub fn parse_dexie_get_offer_response( - offer_id: &str, - payload: &Value, - mode: DexieFetchMode, -) -> DexieOfferFetch { - if let Some(error_text) = missing_offer_error_from_payload(payload) { - return DexieOfferFetch::Missing(error_text); - } - if let Some(single) = payload.get("offer") { +fn parse_dexie_get_offer_response(offer_id: &str, response: &DexieResponse) -> DexieOfferFetch { + if let Some(single) = response.offer_payload() { if offer_matches_local_id(single, offer_id) { return DexieOfferFetch::Found(single.clone()); } - if mode == DexieFetchMode::HealStrict { - return DexieOfferFetch::Mismatch; - } } - match mode { - DexieFetchMode::HealStrict => DexieOfferFetch::Mismatch, - DexieFetchMode::LifecycleLoose => { - DexieOfferFetch::Found(payload.get("offer").unwrap_or(payload).clone()) - } + if response.is_explicit_failure() { + return DexieOfferFetch::Missing(response.error_text().to_string()); } + DexieOfferFetch::Mismatch } -/// Fetch one offer from Dexie and classify the response (no metrics or persist). +/// Fetch one offer (id mismatch is [`DexieOfferFetch::Mismatch`]). +/// +/// Transport failures propagate as [`SignerError`]; HTTP 404 is [`DexieOfferFetch::Missing`]. pub async fn fetch_dexie_offer( dexie: &DexieClient, offer_id: &str, - mode: DexieFetchMode, -) -> DexieOfferFetch { +) -> SignerResult { match dexie.get_offer(offer_id).await { - Ok(response) => parse_dexie_get_offer_response(offer_id, response.body(), mode), - Err(err) if is_dexie_offer_missing_error_text(&err.to_string()) => { - DexieOfferFetch::Missing(err.to_string()) - } - Err(err) => DexieOfferFetch::LookupError(err.to_string()), + Ok(response) => Ok(parse_dexie_get_offer_response(offer_id, &response)), + Err(err) if err.is_http_not_found() => Ok(DexieOfferFetch::Missing(err.to_string())), + Err(err) => Err(err), } } -impl DexieOfferFetch { - /// Offer-file bech32 from a found Dexie payload (`offer_file` / nested offer text). - #[must_use] - pub fn offer_file_text(&self) -> Option { - match self { - Self::Found(value) => DexieOfferPayload::new(value.clone()) - .offer_file_text() - .map(str::to_string), - Self::Missing(_) | Self::Mismatch | Self::LookupError(_) => None, - } - } - - /// Whether publish visibility polling can treat this lookup as "offer exists". - #[must_use] - pub fn visibility_ready(&self) -> bool { - matches!(self, Self::Found(_)) - } -} - -/// Fetch Dexie offer-file text for cancel fallback (one parse path with [`fetch_dexie_offer`]). +/// Fetch Dexie offer-file text for cancel fallback. +/// +/// Trusts the URL-fetched body (no local-id match required). Transport errors propagate. pub async fn fetch_dexie_offer_file_text( dexie: &DexieClient, offer_id: &str, ) -> SignerResult { - let fetched = fetch_dexie_offer(dexie, offer_id, DexieFetchMode::LifecycleLoose).await; - if let Some(text) = fetched.offer_file_text() { - return Ok(text); - } - match fetched { - DexieOfferFetch::Found(_) => Err(SignerError::OfferCancelOfferFileMissing), - DexieOfferFetch::Missing(_) | DexieOfferFetch::Mismatch => { - Err(SignerError::OfferCancelOfferFileNotFound) + let response = match dexie.get_offer(offer_id).await { + Ok(response) => response, + Err(err) if err.is_http_not_found() => { + return Err(SignerError::Offer(OfferError::OfferCancelOfferFileNotFound)); } - DexieOfferFetch::LookupError(err) => Err(SignerError::Other(err)), + Err(err) => return Err(err), + }; + if response.is_explicit_failure() { + return Err(SignerError::Offer(OfferError::OfferCancelOfferFileNotFound)); } + if let Some(text) = DexieOfferPayload::new(response.body().clone()).offer_file_text() { + return Ok(text.to_string()); + } + Err(SignerError::Offer(OfferError::OfferCancelOfferFileMissing)) } #[cfg(test)] mod tests { use super::*; + use crate::error::{SignerError, TransportError}; use serde_json::json; #[test] fn parse_missing_from_success_false_body() { - let payload = json!({"success": false, "error": "HTTP Error 404: Not Found"}); + let response = DexieResponse::from_value(json!({ + "success": false, + "error": "HTTP Error 404: Not Found" + })); assert_eq!( - parse_dexie_get_offer_response("ab", &payload, DexieFetchMode::HealStrict), + parse_dexie_get_offer_response("ab", &response), DexieOfferFetch::Missing("HTTP Error 404: Not Found".into()) ); } #[test] - fn parse_found_requires_id_match_for_heal() { + fn parse_found_requires_id_match() { let offer_id = "ab".repeat(32); - let payload = json!({"offer": {"id": offer_id.clone(), "status": 1}}); + let response = DexieResponse::from_value(json!({ + "offer": {"id": offer_id.clone(), "status": 1} + })); assert!(matches!( - parse_dexie_get_offer_response(&offer_id, &payload, DexieFetchMode::HealStrict), + parse_dexie_get_offer_response(&offer_id, &response), DexieOfferFetch::Found(_) )); } #[test] - fn parse_mismatch_when_offer_id_differs_and_match_required() { - let payload = json!({"offer": {"id": "other-id", "status": 1}}); + fn parse_mismatch_when_offer_id_differs() { + let response = DexieResponse::from_value(json!({"offer": {"id": "other-id", "status": 1}})); assert_eq!( - parse_dexie_get_offer_response( - "ab".repeat(32).as_str(), - &payload, - DexieFetchMode::HealStrict - ), + parse_dexie_get_offer_response("ab".repeat(32).as_str(), &response), DexieOfferFetch::Mismatch ); } #[test] - fn parse_lifecycle_accepts_top_level_payload() { + fn parse_mismatch_on_top_level_payload_without_nested_offer() { let offer_id = "offer-ok"; - let payload = json!({"id": offer_id, "status": 4}); + let response = DexieResponse::from_value(json!({"id": offer_id, "status": 4})); + assert_eq!( + parse_dexie_get_offer_response(offer_id, &response), + DexieOfferFetch::Mismatch + ); + } + + #[tokio::test] + async fn fetch_offer_maps_http_404_to_missing() { + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/v1/offers/missing") + .with_status(404) + .with_body(r#"{"success":false,"error":"not_found"}"#) + .create(); + let dexie = DexieClient::new(server.url()); + let fetch = fetch_dexie_offer(&dexie, "missing").await.expect("fetch"); + assert!(matches!(fetch, DexieOfferFetch::Missing(_))); + } + + #[tokio::test] + async fn fetch_offer_file_text_extracts_without_id_match() { + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/v1/offers/local-id") + .with_status(200) + .with_body(r#"{"offer":{"id":"other-id","offer":"offer1qq"}}"#) + .create(); + let dexie = DexieClient::new(server.url()); + let text = fetch_dexie_offer_file_text(&dexie, "local-id") + .await + .expect("file"); + assert_eq!(text, "offer1qq"); + } + + #[tokio::test] + async fn fetch_offer_file_text_maps_404_to_not_found() { + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/v1/offers/missing") + .with_status(404) + .with_body(r#"{"success":false,"error":"not_found"}"#) + .create(); + let dexie = DexieClient::new(server.url()); + let err = fetch_dexie_offer_file_text(&dexie, "missing") + .await + .expect_err("404"); assert!(matches!( - parse_dexie_get_offer_response(offer_id, &payload, DexieFetchMode::LifecycleLoose), - DexieOfferFetch::Found(_) + err, + SignerError::Offer(OfferError::OfferCancelOfferFileNotFound) )); } - #[test] - fn parse_lifecycle_never_produces_mismatch_on_id_disagreement() { - let payload = json!({"offer": {"id": "other-id", "status": 1}}); + #[tokio::test] + async fn fetch_offer_file_text_maps_present_body_without_file() { + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/v1/offers/no-file") + .with_status(200) + .with_body(r#"{"offer":{"id":"no-file","status":1}}"#) + .create(); + let dexie = DexieClient::new(server.url()); + let err = fetch_dexie_offer_file_text(&dexie, "no-file") + .await + .expect_err("missing file"); assert!(matches!( - parse_dexie_get_offer_response( - "ab".repeat(32).as_str(), - &payload, - DexieFetchMode::LifecycleLoose - ), - DexieOfferFetch::Found(_) + err, + SignerError::Offer(OfferError::OfferCancelOfferFileMissing) )); } - #[test] - fn found_fetch_exposes_offer_file_text_and_visibility() { - let offer_text = "offer1qq"; - let fetch = DexieOfferFetch::Found(json!({"offer": offer_text, "id": "ab"})); - assert!(fetch.visibility_ready()); - assert_eq!(fetch.offer_file_text().as_deref(), Some(offer_text)); - assert!(!DexieOfferFetch::Mismatch.visibility_ready()); - assert!(DexieOfferFetch::Mismatch.offer_file_text().is_none()); + #[tokio::test] + async fn fetch_offer_file_text_propagates_transport() { + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/v1/offers/bad-json") + .with_status(200) + .with_body("not-json") + .create(); + let dexie = DexieClient::new(server.url()); + let err = fetch_dexie_offer_file_text(&dexie, "bad-json") + .await + .expect_err("transport"); + assert!(matches!( + err, + SignerError::Transport(TransportError::Http { + layer: "dexie_json_error", + .. + }) + )); } } diff --git a/greenfloor-engine/src/offer/lifecycle/reconcile_prep/mod.rs b/greenfloor-engine/src/offer/lifecycle/reconcile_prep/mod.rs index 0d72a894..6301e297 100644 --- a/greenfloor-engine/src/offer/lifecycle/reconcile_prep/mod.rs +++ b/greenfloor-engine/src/offer/lifecycle/reconcile_prep/mod.rs @@ -8,9 +8,7 @@ mod dexie_fetch; mod watch_plan; -pub use dexie_fetch::{ - fetch_dexie_offer, fetch_dexie_offer_file_text, DexieFetchMode, DexieOfferFetch, -}; +pub use dexie_fetch::{fetch_dexie_offer, fetch_dexie_offer_file_text, DexieOfferFetch}; pub use watch_plan::{ ensure_watches_from_dexie_payload, fetch_and_ensure_watches, prepare_market_reconcile_local, }; diff --git a/greenfloor-engine/src/offer/lifecycle/reconcile_prep/watch_plan.rs b/greenfloor-engine/src/offer/lifecycle/reconcile_prep/watch_plan.rs index 8b07eb94..db311ec0 100644 --- a/greenfloor-engine/src/offer/lifecycle/reconcile_prep/watch_plan.rs +++ b/greenfloor-engine/src/offer/lifecycle/reconcile_prep/watch_plan.rs @@ -17,7 +17,7 @@ use crate::storage::OfferStateListRow; use crate::storage::SqliteStore; use super::super::dexie_index::index_list_offers_by_local_ids; -use super::dexie_fetch::{fetch_dexie_offer, DexieFetchMode, DexieOfferFetch}; +use super::dexie_fetch::{fetch_dexie_offer, DexieOfferFetch}; /// Dexie HTTP roles after local metadata heal (pure classify result). #[derive(Debug, Clone, Default)] @@ -155,14 +155,10 @@ pub fn ensure_watches_from_dexie_payload( raw: &Value, ) -> SignerResult<()> { let (coin_ids, payload_p2s) = maker_watch_keys_from_dexie_payload(raw); - let p2s = match store.offer_cancel_metadata_for_id(offer_id)? { - Some(meta) if crate::offer::PostedOfferShape::from_metadata(&meta).is_presplit() => { - payload_p2s - } - _ => Vec::new(), - }; - if !coin_ids.is_empty() || !p2s.is_empty() { - store.ensure_offer_coin_watches(offer_id, market_id, &coin_ids, &p2s)?; + let meta = store.offer_cancel_metadata_for_id(offer_id)?; + let seed = MakerWatchSeed::from_dexie_heal(meta.as_ref(), coin_ids, payload_p2s); + if !seed.coin_ids.is_empty() || !seed.p2s.is_empty() { + store.ensure_offer_coin_watches(offer_id, market_id, &seed.coin_ids, &seed.p2s)?; } Ok(()) } @@ -178,11 +174,11 @@ async fn fetch_dexie_offer_body_for_heal( offer_id: &str, on_lookup_error: &mut dyn FnMut(&str, &str, &str) -> SignerResult<()>, ) -> SignerResult> { - match fetch_dexie_offer(dexie, offer_id, DexieFetchMode::HealStrict).await { - DexieOfferFetch::Found(body) => Ok(Some(body)), - DexieOfferFetch::Missing(_) | DexieOfferFetch::Mismatch => Ok(None), - DexieOfferFetch::LookupError(err) => { - on_lookup_error(market_id, offer_id, &err)?; + match fetch_dexie_offer(dexie, offer_id).await { + Ok(DexieOfferFetch::Found(body)) => Ok(Some(body)), + Ok(DexieOfferFetch::Missing(_) | DexieOfferFetch::Mismatch) => Ok(None), + Err(err) => { + on_lookup_error(market_id, offer_id, &err.to_string())?; Ok(None) } } diff --git a/greenfloor-engine/src/offer/lifecycle/signal_apply.rs b/greenfloor-engine/src/offer/lifecycle/signal_apply.rs index 17835b6e..c3876232 100644 --- a/greenfloor-engine/src/offer/lifecycle/signal_apply.rs +++ b/greenfloor-engine/src/offer/lifecycle/signal_apply.rs @@ -9,7 +9,9 @@ use crate::adapters::DexieClient; use crate::cycle::reconcile::{ resolve_watched_offer_transition_from_signals, CancelSubmittedContext, CoinsetTxSignals, }; -use crate::cycle::{resolve_missing_watched_offer_transition, CycleOfferTransition}; +use crate::cycle::{ + resolve_missing_watched_offer_transition, unchanged_offer_transition, CycleOfferTransition, +}; use crate::error::SignerResult; use crate::storage::{OfferStateListRow, SqliteStore}; @@ -18,10 +20,8 @@ use super::cancel_context::{ preload_cancel_submitted_contexts, }; use super::persist::{persist_offer_lifecycle_transition, ReconcilePersistOptions}; -use super::transition::{ - resolve_watched_offer_transition_from_dexie_fetch, transition_from_offer_body, - WatchedOfferTransitionEnv, -}; +use super::reconcile_prep::{fetch_dexie_offer, DexieOfferFetch}; +use super::transition::{transition_from_offer_body, WatchedOfferTransitionEnv}; fn persist_resolved_watched_transition( store: &SqliteStore, @@ -164,7 +164,7 @@ impl<'a> WatchedOfferReconciler<'a> { /// Resolve + persist a missing Dexie watched offer (404 / not-found). /// - /// Callers put the Dexie error text on `options.dexie_error`. + /// `dexie_error` is recorded on the persist audit when present. /// /// # Errors /// @@ -174,17 +174,32 @@ impl<'a> WatchedOfferReconciler<'a> { market_id: &str, offer_id: &str, current_state: &str, + dexie_error: Option<&str>, ) -> SignerResult { let transition = resolve_missing_watched_offer_transition(current_state)?; - self.persist_transition(market_id, offer_id, &transition, None)?; + let overlaid = ReconcilePersistOptions { + action: self.options.action, + venue: self.options.venue, + dexie_error: dexie_error.or(self.options.dexie_error), + }; + persist_resolved_watched_transition( + self.store, + market_id, + offer_id, + &transition, + None, + &overlaid, + )?; Ok(transition) } /// Fetch a watched Dexie offer and persist the resolved lifecycle transition. /// + /// Transport failures and id-mismatched payloads persist an unchanged transition. + /// /// # Errors /// - /// Returns an error if Dexie lookup, transition resolve, or `SQLite` persist fails. + /// Returns an error if transition resolve or `SQLite` persist fails. pub async fn fetch_and_apply( &self, dexie: &DexieClient, @@ -193,32 +208,28 @@ impl<'a> WatchedOfferReconciler<'a> { current_state: &str, env: WatchedOfferTransitionEnv<'_>, ) -> SignerResult<(CycleOfferTransition, Option)> { - let (transition, status, dexie_error) = resolve_watched_offer_transition_from_dexie_fetch( - self.store, - dexie, - offer_id, - current_state, - env, - ) - .await?; - if let Some(error_text) = dexie_error.as_deref() { - let persist_options = ReconcilePersistOptions { - action: self.options.action, - venue: self.options.venue, - dexie_error: Some(error_text), - }; - persist_resolved_watched_transition( - self.store, + match fetch_dexie_offer(dexie, offer_id).await { + Ok(DexieOfferFetch::Found(offer_body)) => { + self.apply_dexie_payload(market_id, offer_id, current_state, &offer_body, env) + } + Ok(DexieOfferFetch::Missing(error_text)) => { + let transition = + self.apply_missing(market_id, offer_id, current_state, Some(&error_text))?; + Ok((transition, None)) + } + Ok(DexieOfferFetch::Mismatch) => self.persist_lookup_unchanged( + market_id, + offer_id, + current_state, + "dexie_lookup_error:dexie get_offer payload did not match local offer id", + ), + Err(err) => self.persist_lookup_unchanged( market_id, offer_id, - &transition, - status, - &persist_options, - )?; - } else { - self.persist_transition(market_id, offer_id, &transition, status)?; + current_state, + format!("dexie_lookup_error:{err}"), + ), } - Ok((transition, status)) } #[allow(clippy::too_many_arguments)] @@ -249,4 +260,127 @@ impl<'a> WatchedOfferReconciler<'a> { self.persist_transition(market_id, offer_id, &transition, last_seen_status)?; Ok(transition) } + + fn persist_lookup_unchanged( + &self, + market_id: &str, + offer_id: &str, + current_state: &str, + reason: impl Into, + ) -> SignerResult<(CycleOfferTransition, Option)> { + let transition = unchanged_offer_transition(current_state, reason)?; + self.persist_transition(market_id, offer_id, &transition, None)?; + Ok((transition, None)) + } +} + +#[cfg(test)] +mod tests { + use super::{WatchedOfferReconciler, WatchedOfferTransitionEnv}; + use crate::adapters::DexieClient; + use crate::offer::lifecycle::ReconcilePersistOptions; + use crate::storage::SqliteStore; + use tempfile::tempdir; + + #[tokio::test] + async fn fetch_and_apply_expires_on_dexie_404() { + let dir = tempdir().expect("tempdir"); + let db_path = dir.path().join("state.db"); + let store = SqliteStore::open(&db_path).expect("open"); + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/v1/offers/offer-missing") + .with_status(404) + .with_body(r#"{"success":false,"error":"not_found"}"#) + .create(); + let dexie = DexieClient::new(server.url()); + let options = ReconcilePersistOptions { + action: "test", + venue: Some(crate::config::Venue::Dexie), + dexie_error: None, + }; + let reconciler = WatchedOfferReconciler::new(&store, &options); + let (transition, status) = reconciler + .fetch_and_apply( + &dexie, + "m1", + "offer-missing", + "open", + WatchedOfferTransitionEnv::at_now(None), + ) + .await + .expect("transition"); + assert_eq!( + transition.new_state, + crate::cycle::ReconcileState::parse("expired").expect("state") + ); + assert!(status.is_none()); + } + + #[tokio::test] + async fn fetch_and_apply_mismatch_leaves_state_unchanged() { + let dir = tempdir().expect("tempdir"); + let store = SqliteStore::open(&dir.path().join("state.db")).expect("open"); + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/v1/offers/local-id") + .with_status(200) + .with_body(r#"{"offer":{"id":"other-id","status":1}}"#) + .create(); + let dexie = DexieClient::new(server.url()); + let options = ReconcilePersistOptions { + action: "test", + venue: Some(crate::config::Venue::Dexie), + dexie_error: None, + }; + let reconciler = WatchedOfferReconciler::new(&store, &options); + let (transition, status) = reconciler + .fetch_and_apply( + &dexie, + "m1", + "local-id", + "open", + WatchedOfferTransitionEnv::at_now(None), + ) + .await + .expect("transition"); + assert!(!transition.changed); + assert_eq!( + transition.new_state, + crate::cycle::ReconcileState::parse("open").expect("state") + ); + assert!(status.is_none()); + } + + #[tokio::test] + async fn fetch_and_apply_transport_error_leaves_state_unchanged() { + let dir = tempdir().expect("tempdir"); + let store = SqliteStore::open(&dir.path().join("state.db")).expect("open"); + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/v1/offers/bad-json") + .with_status(200) + .with_body("not-json") + .create(); + let dexie = DexieClient::new(server.url()); + let options = ReconcilePersistOptions { + action: "test", + venue: Some(crate::config::Venue::Dexie), + dexie_error: None, + }; + let reconciler = WatchedOfferReconciler::new(&store, &options); + let (transition, status) = reconciler + .fetch_and_apply( + &dexie, + "m1", + "bad-json", + "open", + WatchedOfferTransitionEnv::at_now(None), + ) + .await + .expect("transition"); + assert!(!transition.changed); + assert!(transition.reason.contains("dexie_lookup_error")); + assert!(status.is_none()); + } } diff --git a/greenfloor-engine/src/offer/lifecycle/transition.rs b/greenfloor-engine/src/offer/lifecycle/transition.rs index 4f937b84..ebbaf188 100644 --- a/greenfloor-engine/src/offer/lifecycle/transition.rs +++ b/greenfloor-engine/src/offer/lifecycle/transition.rs @@ -3,17 +3,11 @@ use std::collections::HashMap; -use serde_json::Value; - use chrono::{DateTime, Utc}; +use serde_json::Value; -use crate::adapters::DexieClient; -use crate::cycle::reconcile::CancelSubmittedContext; -use crate::cycle::{ - is_dexie_offer_missing_error_text, resolve_missing_watched_offer_transition, - resolve_watched_offer_transition_from_signals, unchanged_offer_transition, - CycleOfferTransition, -}; +use crate::cycle::reconcile::{CancelSubmittedContext, CoinsetTxSignals}; +use crate::cycle::{resolve_watched_offer_transition_from_signals, CycleOfferTransition}; use crate::error::SignerResult; use crate::offer::dexie_payload::{dexie_offer_status, extract_coinset_tx_ids_from_offer_payload}; use crate::storage::SqliteStore; @@ -21,8 +15,6 @@ use crate::storage::SqliteStore; use super::cancel_context::{ cancel_submitted_context_for_offer, chain_confirmed_tx_ids_for_transition, }; -use super::reconcile_prep::{fetch_dexie_offer, DexieFetchMode, DexieOfferFetch}; -use crate::cycle::reconcile::CoinsetTxSignals; /// Clock and optional preloaded cancel-submit context for watched-offer reconcile. #[derive(Debug, Clone, Copy)] @@ -99,22 +91,6 @@ pub fn coinset_signals_from_dexie_offer_payload( )) } -pub fn missing_offer_error_from_payload(payload: &Value) -> Option { - if payload.get("success") != Some(&Value::Bool(false)) { - return None; - } - let error_text = payload.get("error").and_then(Value::as_str).unwrap_or(""); - if is_dexie_offer_missing_error_text(error_text) { - Some(error_text.to_string()) - } else { - None - } -} - -fn missing_watched_offer_transition(current_state: &str) -> SignerResult { - resolve_missing_watched_offer_transition(current_state).map_err(Into::into) -} - /// Resolve lifecycle + Dexie status from an offer body (list row or `get_offer.offer`). /// /// # Errors @@ -149,74 +125,3 @@ pub(crate) fn transition_from_offer_body( )?; Ok((transition, status)) } - -/// Resolve a lifecycle transition by fetching a single offer from Dexie. -/// -/// # Errors -/// -/// Returns an error if the operation fails. -pub async fn resolve_watched_offer_transition_from_dexie_fetch( - store: &SqliteStore, - dexie: &DexieClient, - offer_id: &str, - current_state: &str, - env: WatchedOfferTransitionEnv<'_>, -) -> SignerResult<(CycleOfferTransition, Option, Option)> { - match fetch_dexie_offer(dexie, offer_id, DexieFetchMode::LifecycleLoose).await { - DexieOfferFetch::Found(offer_body) => { - let (transition, status) = - transition_from_offer_body(store, offer_id, current_state, &offer_body, env)?; - Ok((transition, status, None)) - } - DexieOfferFetch::Missing(error_text) => { - let transition = missing_watched_offer_transition(current_state)?; - Ok((transition, None, Some(error_text))) - } - DexieOfferFetch::Mismatch => { - unreachable!("DexieFetchMode::LifecycleLoose never produces Mismatch") - } - DexieOfferFetch::LookupError(err) => { - let transition = - unchanged_offer_transition(current_state, format!("dexie_lookup_error:{err}"))?; - Ok((transition, None, None)) - } - } -} - -#[cfg(test)] -mod tests { - use tempfile::tempdir; - - use super::*; - use crate::adapters::DexieClient; - use crate::storage::SqliteStore; - - #[tokio::test] - async fn fetch_transition_expires_on_dexie_404() { - let dir = tempdir().expect("tempdir"); - let db_path = dir.path().join("state.db"); - let store = SqliteStore::open(&db_path).expect("open"); - let mut server = mockito::Server::new_async().await; - let _mock = server - .mock("GET", "/v1/offers/offer-missing") - .with_status(404) - .with_body(r#"{"success":false,"error":"not_found"}"#) - .create(); - let dexie = DexieClient::new(server.url()); - let (transition, status, error) = resolve_watched_offer_transition_from_dexie_fetch( - &store, - &dexie, - "offer-missing", - "open", - WatchedOfferTransitionEnv::at_now(None), - ) - .await - .expect("transition"); - assert_eq!( - transition.new_state, - crate::cycle::ReconcileState::parse("expired").expect("state") - ); - assert!(status.is_none()); - assert!(error.is_some()); - } -} diff --git a/greenfloor-engine/src/offer/maker_shape.rs b/greenfloor-engine/src/offer/maker_shape.rs index d7b6d46f..53f03202 100644 --- a/greenfloor-engine/src/offer/maker_shape.rs +++ b/greenfloor-engine/src/offer/maker_shape.rs @@ -1,6 +1,6 @@ //! Maker watch-seed policy (ADR 0019). Shape classification lives on [`PostedOfferShape`]. -use crate::hex::normalize_hex_id; +use crate::hex::canonical_tx_id; use crate::offer::types::{ OfferCancelFields, OfferExecutionMode, PostedOfferShape, StoredOfferCancelMetadata, }; @@ -12,31 +12,37 @@ pub struct MakerWatchSeed { pub p2s: Vec, } +fn watchable_coin_ids(ids: impl IntoIterator>) -> Vec { + let mut coin_ids: Vec = ids + .into_iter() + .filter_map(|value| canonical_tx_id(value.as_ref())) + .collect(); + coin_ids.sort(); + coin_ids.dedup(); + coin_ids +} + +fn p2s_if_presplit(shape: PostedOfferShape, p2s: impl IntoIterator) -> Vec { + if shape.is_presplit() { + p2s.into_iter() + .filter(|value| !value.trim().is_empty()) + .collect() + } else { + Vec::new() + } +} + impl MakerWatchSeed { /// Watches from stored cancel metadata (heal path). #[must_use] pub fn from_metadata(meta: &StoredOfferCancelMetadata) -> Self { - let mut coin_ids = Vec::new(); - if let Some(coin) = meta - .fields - .input_coin_id - .as_deref() - .map(normalize_hex_id) - .filter(|value| value.len() == 64) - { - coin_ids.push(coin); + Self { + coin_ids: watchable_coin_ids(meta.fields.input_coin_id.iter()), + p2s: p2s_if_presplit( + PostedOfferShape::from_metadata(meta), + meta.fields.maker_puzzle_hash.clone(), + ), } - let p2s = if PostedOfferShape::from_metadata(meta).is_presplit() { - meta.fields - .maker_puzzle_hash - .clone() - .into_iter() - .filter(|value| !value.trim().is_empty()) - .collect() - } else { - Vec::new() - }; - Self { coin_ids, p2s } } /// Watches at offer post persist time. @@ -46,27 +52,34 @@ impl MakerWatchSeed { cancel_fields: &OfferCancelFields, extra_coin_ids: impl IntoIterator, ) -> Self { - let mut coin_ids: Vec = extra_coin_ids.into_iter().collect(); - if let Some(input) = cancel_fields.input_coin_id.clone() { - coin_ids.push(input); + Self { + coin_ids: watchable_coin_ids( + extra_coin_ids + .into_iter() + .chain(cancel_fields.input_coin_id.clone()), + ), + p2s: p2s_if_presplit( + PostedOfferShape::from_execution( + execution_mode, + cancel_fields.fixed_delegated_puzzle_hash.as_deref(), + ), + cancel_fields.maker_puzzle_hash.clone(), + ), + } + } + + /// Dexie-heal watches: payload coin ids; p2s only when local metadata says presplit. + #[must_use] + pub fn from_dexie_heal( + meta: Option<&StoredOfferCancelMetadata>, + coin_ids: Vec, + payload_p2s: Vec, + ) -> Self { + let shape = meta.map_or(PostedOfferShape::Direct, PostedOfferShape::from_metadata); + Self { + coin_ids: watchable_coin_ids(coin_ids), + p2s: p2s_if_presplit(shape, payload_p2s), } - coin_ids.sort(); - coin_ids.dedup(); - let p2s = if PostedOfferShape::from_execution( - execution_mode, - cancel_fields.fixed_delegated_puzzle_hash.as_deref(), - ) - .is_presplit() - { - cancel_fields - .maker_puzzle_hash - .clone() - .into_iter() - .collect() - } else { - Vec::new() - }; - Self { coin_ids, p2s } } } @@ -93,4 +106,78 @@ mod tests { MakerWatchSeed::from_post_fields(Some(OfferExecutionMode::PresplitNew), &fields, []); assert_eq!(seed.p2s, vec!["dd".repeat(32)]); } + + #[test] + fn dexie_heal_seeds_payload_p2_only_for_presplit() { + let presplit = StoredOfferCancelMetadata { + fields: OfferCancelFields::from_presplit_build( + "aa".repeat(32), + "cc".repeat(32), + "dd".repeat(32), + ), + execution_mode: Some(OfferExecutionMode::PresplitExisting), + }; + let seed = MakerWatchSeed::from_dexie_heal( + Some(&presplit), + vec!["ee".repeat(32)], + vec!["ff".repeat(32)], + ); + assert_eq!(seed.coin_ids, vec!["ee".repeat(32)]); + assert_eq!(seed.p2s, vec!["ff".repeat(32)]); + + let direct = StoredOfferCancelMetadata { + fields: OfferCancelFields::from_direct_build("aa".repeat(32), "bb".repeat(32)), + execution_mode: Some(OfferExecutionMode::Direct), + }; + let seed = MakerWatchSeed::from_dexie_heal( + Some(&direct), + vec!["ee".repeat(32)], + vec!["ff".repeat(32)], + ); + assert_eq!(seed.coin_ids, vec!["ee".repeat(32)]); + assert!(seed.p2s.is_empty()); + + let seed = + MakerWatchSeed::from_dexie_heal(None, vec!["ee".repeat(32)], vec!["ff".repeat(32)]); + assert!(seed.p2s.is_empty()); + } + + #[test] + fn p2s_if_presplit_drops_empty_and_direct() { + assert!(p2s_if_presplit(PostedOfferShape::Direct, ["aa".repeat(32)]).is_empty()); + assert_eq!( + p2s_if_presplit(PostedOfferShape::Presplit, ["dd".repeat(32), String::new()]), + vec!["dd".repeat(32)] + ); + } + + #[test] + fn watchable_coin_ids_normalizes_dedups_and_drops_invalid() { + let raw = [ + format!("0x{}", "aa".repeat(32)), + "aa".repeat(32), + "not-a-coin".to_string(), + String::new(), + "bb".repeat(32), + ]; + assert_eq!( + watchable_coin_ids(raw), + vec!["aa".repeat(32), "bb".repeat(32)] + ); + + let fields = OfferCancelFields::from_direct_build("aa".repeat(32), "bb".repeat(32)); + let seed = MakerWatchSeed::from_post_fields( + Some(OfferExecutionMode::Direct), + &fields, + ["short".to_string(), format!("0x{}", "cc".repeat(32))], + ); + assert_eq!(seed.coin_ids, vec!["aa".repeat(32), "cc".repeat(32)]); + + let seed = MakerWatchSeed::from_dexie_heal( + None, + vec!["ee".repeat(32), "not-hex".to_string(), "ee".repeat(32)], + vec!["ff".repeat(32)], + ); + assert_eq!(seed.coin_ids, vec!["ee".repeat(32)]); + } } diff --git a/greenfloor-engine/src/offer/operator/ensure_size/mod.rs b/greenfloor-engine/src/offer/operator/ensure_size/mod.rs index d1b9028a..8bbeb50b 100644 --- a/greenfloor-engine/src/offer/operator/ensure_size/mod.rs +++ b/greenfloor-engine/src/offer/operator/ensure_size/mod.rs @@ -8,7 +8,7 @@ use tracing::{info, warn}; use crate::bech32m::decode_address; use crate::coinset::{client_for_signer_on_network, coin_id_is_unspent, LiveCoinset}; use crate::config::{market_wants_ladder_size, MarketConfig, SignerConfig}; -use crate::error::{SignerError, SignerResult}; +use crate::error::{OfferError, SignerError, SignerResult}; use crate::hex::{hex_to_bytes32, normalize_hex_id, tree_hash_to_hex}; use crate::offer::action::plan_offer_terms_for_market; use crate::offer::lifecycle::{restore_stale_maker_claims_synced, ExpiredMakerLease}; @@ -151,8 +151,8 @@ pub async fn ensure_size_n_offer( parts: BuildAndPostOfferRequest, ) -> SignerResult { let side = effective_offer_side(parts.action_side.as_deref()).to_string(); - let size_i64 = - i64::try_from(parts.size_base_units).map_err(|_| SignerError::InvalidSizeBaseUnits)?; + let size_i64 = i64::try_from(parts.size_base_units) + .map_err(|_| SignerError::Offer(OfferError::InvalidSizeBaseUnits))?; if !market_wants_ladder_size(market, &side, size_i64) { return Ok(false); } diff --git a/greenfloor-engine/src/offer/operator/signer_denomination/bootstrap_execute.rs b/greenfloor-engine/src/offer/operator/signer_denomination/bootstrap_execute.rs index 78a8f1e8..9b31f455 100644 --- a/greenfloor-engine/src/offer/operator/signer_denomination/bootstrap_execute.rs +++ b/greenfloor-engine/src/offer/operator/signer_denomination/bootstrap_execute.rs @@ -2,7 +2,7 @@ use std::time::Duration; use serde_json::json; -use crate::error::{SignerError, SignerResult}; +use crate::error::{CoinOpsError, SignerError, SignerResult}; use crate::offer::bootstrap::{ bootstrap_executed_phase, bootstrap_replan_after_combine, BootstrapCoin, BootstrapPhaseSnapshot, BootstrapPhaseStatus, BootstrapPlanOutcome, @@ -24,6 +24,13 @@ fn bootstrap_wait_timeout(timeout_seconds: u64) -> Duration { Duration::from_secs(timeout_seconds.max(BOOTSTRAP_WAIT_MIN_TIMEOUT_SECONDS)) } +fn is_bootstrap_shape_wait_timeout(err: &SignerError) -> bool { + matches!( + err, + SignerError::CoinOps(CoinOpsError::BootstrapShapeWaitTimeout) + ) +} + pub(crate) struct BootstrapShapeContext { pub(crate) split_asset_id: String, pub(crate) receive_address: String, @@ -150,7 +157,7 @@ async fn execute_bootstrap_combine_step( }) .await .map_err(|err| { - if matches!(err, SignerError::BootstrapShapeWaitTimeout) { + if is_bootstrap_shape_wait_timeout(&err) { return shape.executed_on_shape_wait_timeout( "bootstrap_submitted:after_combine_wait_timeout", BootstrapExecutedExtras { @@ -202,13 +209,12 @@ pub(crate) fn replan_after_combine( } } -#[allow(clippy::large_futures)] +#[allow(clippy::large_futures, clippy::too_many_lines)] pub(super) async fn execute_bootstrap_shape( build_ctx: &ResolvedBuildAndPostContext, mut shape: BootstrapShapeContext, ) -> SignerResult { let mut prepend_wait_events = Vec::new(); - if shape.bootstrap_plan.requires_combine_first() { let combine_target_amount = shape.bootstrap_plan.total_output_amount; let (events, replanned, spendable) = @@ -227,7 +233,6 @@ pub(super) async fn execute_bootstrap_shape( return Ok(result); } } - let bootstrap_plan = shape.bootstrap_plan.clone(); let split_result = match submit_bootstrap_mixed_split( build_ctx, @@ -270,7 +275,7 @@ pub(super) async fn execute_bootstrap_shape( { Ok(wait) => wait, Err(err) => { - if matches!(err, SignerError::BootstrapShapeWaitTimeout) { + if is_bootstrap_shape_wait_timeout(&err) { return Ok(shape.executed_on_shape_wait_timeout( "bootstrap_submitted:after_split_wait_timeout", BootstrapExecutedExtras { diff --git a/greenfloor-engine/src/offer/operator/signer_denomination/planning.rs b/greenfloor-engine/src/offer/operator/signer_denomination/planning.rs index e5f6f955..b9789484 100644 --- a/greenfloor-engine/src/offer/operator/signer_denomination/planning.rs +++ b/greenfloor-engine/src/offer/operator/signer_denomination/planning.rs @@ -1,7 +1,7 @@ use crate::coin_ops::is_spendable_coin_state; use crate::coinset::{get_conservative_fee_estimate_for_signer, WalletUnspentCoin}; use crate::config::{LadderEntry, MarketPricing, SignerConfig}; -use crate::error::{SignerError, SignerResult}; +use crate::error::{OfferError, SignerError, SignerResult}; use crate::offer::bootstrap::{BootstrapCoin, PlanAmount, PlannerLadderRow}; use crate::offer::build_context::mojo_multiplier_for_leg; use crate::offer::pricing::quote_mojos_for_base_size; @@ -33,7 +33,7 @@ pub(super) fn bootstrap_size_mojos_for_side( .max(1); size_base_units .checked_mul(base_mult) - .ok_or(SignerError::InvalidOfferRequestAmount) + .ok_or(SignerError::Offer(OfferError::InvalidOfferRequestAmount)) } } diff --git a/greenfloor-engine/src/offer/operator/signer_denomination/split_submit.rs b/greenfloor-engine/src/offer/operator/signer_denomination/split_submit.rs index 45fadc6e..01ab13a2 100644 --- a/greenfloor-engine/src/offer/operator/signer_denomination/split_submit.rs +++ b/greenfloor-engine/src/offer/operator/signer_denomination/split_submit.rs @@ -65,7 +65,9 @@ pub(super) async fn submit_bootstrap_combine( #[cfg(test)] test_overrides: &SignerDenominationTestOverrides, ) -> SignerResult { let ShapeFunding::CombineFirst(inputs) = &bootstrap_plan.funding else { - return Err(crate::error::SignerError::InvalidPlanValues); + return Err(crate::error::SignerError::CoinOps( + crate::error::CoinOpsError::InvalidPlanValues, + )); }; // Plan amounts are mojos on the denomination path. let output_amounts = bootstrap_combine_vault_outputs_as_mojos(inputs)?; @@ -96,7 +98,9 @@ pub(super) async fn submit_bootstrap_mixed_split( #[cfg(test)] test_overrides: &SignerDenominationTestOverrides, ) -> SignerResult { let ShapeFunding::SingleCoin { coin_id, .. } = &bootstrap_plan.funding else { - return Err(crate::error::SignerError::InvalidPlanValues); + return Err(crate::error::SignerError::CoinOps( + crate::error::CoinOpsError::InvalidPlanValues, + )); }; let output_amounts_mojos = vault_output_mojos_from_plan_amounts( &bootstrap_plan diff --git a/greenfloor-engine/src/offer/operator/signer_denomination/wait.rs b/greenfloor-engine/src/offer/operator/signer_denomination/wait.rs index 2fe01af5..24f69332 100644 --- a/greenfloor-engine/src/offer/operator/signer_denomination/wait.rs +++ b/greenfloor-engine/src/offer/operator/signer_denomination/wait.rs @@ -6,7 +6,7 @@ use crate::coin_ops::execution::resolve_combine_input_cap; use crate::coinset::list_wallet_unspent_coins_for_signer; use crate::config::SignerConfig; use crate::cycle::retry::{poll_exponential_advance_sleep, poll_exponential_sleep_now}; -use crate::error::{SignerError, SignerResult}; +use crate::error::{CoinOpsError, SignerError, SignerResult}; use crate::offer::bootstrap::{ bootstrap_wait_event_metadata, plan_bootstrap_mixed_outputs, resolve_bootstrap_wait_poll, BootstrapCoin, BootstrapPlanOutcome, BootstrapWaitContext, BootstrapWaitPoll, @@ -97,7 +97,9 @@ pub(super) async fn wait_for_bootstrap_shape_step( let mut baseline_spendable: Option> = None; loop { if start.elapsed() >= timeout { - return Err(SignerError::BootstrapShapeWaitTimeout); + return Err(SignerError::CoinOps( + CoinOpsError::BootstrapShapeWaitTimeout, + )); } let elapsed_seconds = i64::try_from(start.elapsed().as_secs()).map_err(|_| { SignerError::Other("confirmation wait elapsed seconds overflow".to_string()) @@ -109,7 +111,9 @@ pub(super) async fn wait_for_bootstrap_shape_step( initial_sleep, max_sleep, ) else { - return Err(SignerError::BootstrapShapeWaitTimeout); + return Err(SignerError::CoinOps( + CoinOpsError::BootstrapShapeWaitTimeout, + )); }; let spendable = fetch_bootstrap_spendable(network, signer, ctx).await?; let snapshot = normalized_spendable_snapshot(&spendable); @@ -167,7 +171,7 @@ mod tests { use super::{wait_for_bootstrap_shape_step, BootstrapWaitConfig, BootstrapWaitTimings}; use crate::coin_ops::shape::{ShapeDeficit, ShapeFunding}; - use crate::error::SignerError; + use crate::error::{CoinOpsError, SignerError}; use crate::offer::bootstrap::{BootstrapPlan, BootstrapWaitStepKind, PlannerLadderRow}; use crate::offer::operator::BootstrapShapeContext; use crate::test_support::bootstrap_shape::{ @@ -275,7 +279,10 @@ mod tests { )) .await .expect_err("timeout"); - assert!(matches!(err, SignerError::BootstrapShapeWaitTimeout)); + assert!(matches!( + err, + SignerError::CoinOps(CoinOpsError::BootstrapShapeWaitTimeout) + )); } #[tokio::test] diff --git a/greenfloor-engine/src/offer/operator/unique_maker.rs b/greenfloor-engine/src/offer/operator/unique_maker.rs index ccd792a5..3b816d7d 100644 --- a/greenfloor-engine/src/offer/operator/unique_maker.rs +++ b/greenfloor-engine/src/offer/operator/unique_maker.rs @@ -2,9 +2,10 @@ use std::collections::HashSet; +use crate::coin_ops::{select_funding_coin_ids, FundingSelectionMode, SpendableCoin}; use crate::coinset::{is_xch_like_asset, list_wallet_unspent_coins_for_signer, WalletUnspentCoin}; use crate::config::{MarketConfig, SignerConfig}; -use crate::error::{SignerError, SignerResult}; +use crate::error::{CoinOpsError, OfferError, SignerError, SignerResult}; use crate::hex::normalize_hex_id; use crate::offer::assets::ResolvedMarketOfferAssets; use crate::offer::request::compute_signer_offer_leg_amounts; @@ -186,7 +187,8 @@ fn offered_leg_for_unique_pin( side: &str, ) -> SignerResult<(String, u64)> { let quote_price = market.quote_price_for_side(side)?; - let size_i64 = i64::try_from(size_base_units).map_err(|_| SignerError::InvalidSizeBaseUnits)?; + let size_i64 = i64::try_from(size_base_units) + .map_err(|_| SignerError::Offer(OfferError::InvalidSizeBaseUnits))?; let leg = compute_signer_offer_leg_amounts( size_i64, quote_price, @@ -252,36 +254,48 @@ fn pick_from_unspent( offered_asset_id: &str, target_amount_mojos: u64, ) -> SignerResult { - let free: Vec<&WalletUnspentCoin> = coins + let spendable: Vec = coins .iter() - .filter(|coin| { - let id = normalize_hex_id(&coin.id); - !id.is_empty() && !excludes.contains(&id) + .map(|coin| { + SpendableCoin::with_puzzle_hash( + normalize_hex_id(&coin.id), + i64::try_from(coin.amount).unwrap_or(i64::MAX), + normalize_hex_id(&coin.puzzle_hash), + ) }) + .filter(|coin| !coin.id.is_empty()) .collect(); - if free.is_empty() { - return Err(empty_unspent_err(offered_asset_id)); + let ids = select_funding_coin_ids( + FundingSelectionMode::ExactDenom, + &spendable, + i64::try_from(target_amount_mojos).unwrap_or(i64::MAX), + Some(excludes), + Some(1), + ); + if let Some(id) = ids.into_iter().next() { + return Ok(id); } - free.iter() - .find(|coin| coin.amount == target_amount_mojos) - .map(|coin| normalize_hex_id(&coin.id)) - .filter(|id| !id.is_empty()) - .ok_or_else(|| insufficient_err(offered_asset_id)) + let any_free = spendable.iter().any(|coin| !excludes.contains(&coin.id)); + Err(if any_free { + insufficient_err(offered_asset_id) + } else { + empty_unspent_err(offered_asset_id) + }) } fn empty_unspent_err(offered_asset_id: &str) -> SignerError { if is_xch_like_asset(offered_asset_id) { - SignerError::NoUnspentOfferXchCoins + SignerError::CoinOps(CoinOpsError::NoUnspentOfferXchCoins) } else { - SignerError::NoUnspentCatCoins + SignerError::CoinOps(CoinOpsError::NoUnspentCatCoins) } } fn insufficient_err(offered_asset_id: &str) -> SignerError { if is_xch_like_asset(offered_asset_id) { - SignerError::InsufficientOfferXchCoins + SignerError::CoinOps(CoinOpsError::InsufficientOfferXchCoins) } else { - SignerError::InsufficientCatCoins + SignerError::CoinOps(CoinOpsError::InsufficientCatCoins) } } @@ -320,7 +334,10 @@ mod tests { let excludes = HashSet::new(); let err = pick_from_unspent(&coins, &excludes, &"ab".repeat(32), 10_000).expect_err("exact"); - assert!(matches!(err, SignerError::InsufficientCatCoins)); + assert!(matches!( + err, + SignerError::CoinOps(CoinOpsError::InsufficientCatCoins) + )); } #[test] @@ -329,18 +346,27 @@ mod tests { let excludes = HashSet::from([hex::encode([0xaa; 32])]); let err = pick_from_unspent(&coins, &excludes, &"ab".repeat(32), 10_000).expect_err("empty"); - assert!(matches!(err, SignerError::NoUnspentCatCoins)); + assert!(matches!( + err, + SignerError::CoinOps(CoinOpsError::NoUnspentCatCoins) + )); } #[test] fn pick_xch_errors_when_empty_or_only_oversize() { let excludes = HashSet::new(); let empty_err = pick_from_unspent(&[], &excludes, "xch", 10_000).expect_err("empty"); - assert!(matches!(empty_err, SignerError::NoUnspentOfferXchCoins)); + assert!(matches!( + empty_err, + SignerError::CoinOps(CoinOpsError::NoUnspentOfferXchCoins) + )); let oversize = vec![coin(0xaa, 20_000)]; let insuf = pick_from_unspent(&oversize, &excludes, "xch", 10_000).expect_err("insufficient"); - assert!(matches!(insuf, SignerError::InsufficientOfferXchCoins)); + assert!(matches!( + insuf, + SignerError::CoinOps(CoinOpsError::InsufficientOfferXchCoins) + )); } #[test] @@ -588,7 +614,10 @@ mod tests { pin_unique_exact_maker_coin_id("mainnet", &signer, RECEIVE, "xch", 5000, &excluded) .await .expect_err("excluded"); - assert!(matches!(err, SignerError::NoUnspentOfferXchCoins)); + assert!(matches!( + err, + SignerError::CoinOps(CoinOpsError::NoUnspentOfferXchCoins) + )); } #[tokio::test] @@ -613,6 +642,9 @@ mod tests { ) .await .expect_err("empty"); - assert!(matches!(err, SignerError::NoUnspentCatCoins)); + assert!(matches!( + err, + SignerError::CoinOps(CoinOpsError::NoUnspentCatCoins) + )); } } diff --git a/greenfloor-engine/src/offer/plan.rs b/greenfloor-engine/src/offer/plan.rs index 6dd72502..b8b441c2 100644 --- a/greenfloor-engine/src/offer/plan.rs +++ b/greenfloor-engine/src/offer/plan.rs @@ -7,7 +7,7 @@ use chia_sdk_driver::{AssetInfo, RequestedPayments, SpendContext}; use chia_sdk_types::{conditions::AssertBeforeSecondsAbsolute, Conditions}; use crate::coinset::{OfferCoinsetBackend, SelectedCats}; -use crate::error::{SignerError, SignerResult}; +use crate::error::{OfferError, SignerError, SignerResult, VaultError}; use crate::hex::hex_to_bytes32; use crate::offer::presplit::{offer_nonce_from_cats, offer_nonce_from_coin_ids}; use crate::offer::types::{OfferInput, OfferTerms}; @@ -31,7 +31,7 @@ pub(crate) enum OfferPlan { pub(crate) fn validate_offer_input(input: &OfferInput) -> SignerResult<()> { let terms = input.terms(); if terms.offer_amount == 0 || terms.request_amount == 0 { - return Err(SignerError::InvalidOutputAmount); + return Err(SignerError::Vault(VaultError::InvalidOutputAmount)); } if is_xch_like_asset(&terms.offer_asset_id) { return Err(SignerError::Other( @@ -49,7 +49,9 @@ fn offer_nonce_for_existing_presplit( return Ok(nonce); } if source_coin_ids.is_empty() { - return Err(SignerError::PresplitOfferRequiresSourceCoinIds); + return Err(SignerError::Offer( + OfferError::PresplitOfferRequiresSourceCoinIds, + )); } Ok(offer_nonce_from_coin_ids(source_coin_ids)) } @@ -89,7 +91,9 @@ pub(crate) async fn plan_vault_cat_offer( // Exact-size inputs need no vault split; Direct cancel metadata assumes one maker coin. if selection.offered_total <= terms.offer_amount { if selection.selected.len() != 1 { - return Err(SignerError::DirectOfferRequiresSingleInputCoin); + return Err(SignerError::Offer( + OfferError::DirectOfferRequiresSingleInputCoin, + )); } return Ok(OfferPlan::Direct { selection, @@ -103,7 +107,7 @@ pub(crate) async fn plan_vault_cat_offer( offer_nonce, }, OfferInput::Direct { .. } => { - return Err(SignerError::OfferInputRequiresPresplit); + return Err(SignerError::Offer(OfferError::OfferInputRequiresPresplit)); } OfferInput::PresplitExisting { .. } => unreachable!(), }) @@ -207,7 +211,7 @@ mod tests { fn direct_input_requires_split_flag_when_change_without_presplit() { assert!(matches!( direct_plan_kind_for_amounts(5000, 1000, 1), - Err(SignerError::OfferInputRequiresPresplit) + Err(SignerError::Offer(OfferError::OfferInputRequiresPresplit)) )); assert!(matches!( direct_plan_kind_for_amounts(1000, 1000, 1), @@ -215,7 +219,9 @@ mod tests { )); assert!(matches!( direct_plan_kind_for_amounts(1000, 1000, 2), - Err(SignerError::DirectOfferRequiresSingleInputCoin) + Err(SignerError::Offer( + OfferError::DirectOfferRequiresSingleInputCoin + )) )); } @@ -224,7 +230,7 @@ mod tests { let err = offer_nonce_for_existing_presplit(&[], None).unwrap_err(); assert!(matches!( err, - SignerError::PresplitOfferRequiresSourceCoinIds + SignerError::Offer(OfferError::PresplitOfferRequiresSourceCoinIds) )); } @@ -246,7 +252,10 @@ mod tests { offer_nonce: None, }; let err = OfferInput::try_from(request).unwrap_err(); - assert!(matches!(err, SignerError::PresplitOfferRequiresSingleCoin)); + assert!(matches!( + err, + SignerError::Offer(OfferError::PresplitOfferRequiresSingleCoin) + )); } enum DirectPlanKind { @@ -260,11 +269,13 @@ mod tests { ) -> Result { if offered_total <= offer_amount { if input_count != 1 { - return Err(SignerError::DirectOfferRequiresSingleInputCoin); + return Err(SignerError::Offer( + OfferError::DirectOfferRequiresSingleInputCoin, + )); } Ok(DirectPlanKind::Direct) } else { - Err(SignerError::OfferInputRequiresPresplit) + Err(SignerError::Offer(OfferError::OfferInputRequiresPresplit)) } } } diff --git a/greenfloor-engine/src/offer/presplit/binding.rs b/greenfloor-engine/src/offer/presplit/binding.rs index a790a1c1..e89ead8a 100644 --- a/greenfloor-engine/src/offer/presplit/binding.rs +++ b/greenfloor-engine/src/offer/presplit/binding.rs @@ -2,7 +2,7 @@ use chia_protocol::{Bytes32, Coin, SpendBundle}; use chia_sdk_driver::{Cat, SpendContext}; use clvm_utils::TreeHash; -use crate::error::{SignerError, SignerResult}; +use crate::error::{OfferError, SignerError, SignerResult}; use crate::offer::presplit::cancel_binding::{self, PresplitBindingLookup, PresplitCoinBinding}; use crate::offer::presplit::pipeline::PresplitPaymentContext; use crate::offer::types::OfferTerms; @@ -69,7 +69,9 @@ impl PresplitOfferBinding { )? { PresplitBindingLookup::Found(binding) => binding, PresplitBindingLookup::NotPresplitMaker => { - return Err(SignerError::OfferCancelInputNotPresplitMaker); + return Err(SignerError::Offer( + OfferError::OfferCancelInputNotPresplitMaker, + )); } }; Ok(Self::from_coin_binding(coin, &binding)) @@ -86,7 +88,9 @@ pub fn verify_presplit_cat_offer_binding( binding: &PresplitOfferBinding, ) -> SignerResult<()> { if presplit_cat.info.p2_puzzle_hash != binding.p2_puzzle_hash { - return Err(SignerError::PresplitCoinPuzzleHashMismatch); + return Err(SignerError::Offer( + OfferError::PresplitCoinPuzzleHashMismatch, + )); } Ok(()) } @@ -119,6 +123,9 @@ mod tests { .expect("binding"); let mismatched_cat = source_cat.child(Bytes32::new([0x99; 32]), 1000); let err = verify_presplit_cat_offer_binding(&mismatched_cat, &binding).unwrap_err(); - assert!(matches!(err, SignerError::PresplitCoinPuzzleHashMismatch)); + assert!(matches!( + err, + SignerError::Offer(OfferError::PresplitCoinPuzzleHashMismatch) + )); } } diff --git a/greenfloor-engine/src/offer/presplit/cancel_binding/mod.rs b/greenfloor-engine/src/offer/presplit/cancel_binding/mod.rs index ddfc2db2..bb20e261 100644 --- a/greenfloor-engine/src/offer/presplit/cancel_binding/mod.rs +++ b/greenfloor-engine/src/offer/presplit/cancel_binding/mod.rs @@ -8,7 +8,7 @@ use chia_sdk_driver::Cat; use clvm_utils::TreeHash; use clvmr::Allocator; -use crate::error::{SignerError, SignerResult}; +use crate::error::{OfferError, SignerError, SignerResult}; use crate::vault::members::{ p2_conditions_or_singleton_from_member_fixed, p2_conditions_or_singleton_puzzle_hash, }; @@ -90,7 +90,9 @@ pub(crate) fn verify_fixed_delegated_puzzle_hash_for_binding( let expected = p2_conditions_or_singleton_puzzle_hash(fixed_delegated_puzzle_hash, launcher_id)?; if binding_p2_puzzle_hash != expected.puzzle_hash.into() { - return Err(SignerError::PresplitCoinPuzzleHashMismatch); + return Err(SignerError::Offer( + OfferError::PresplitCoinPuzzleHashMismatch, + )); } Ok(()) } @@ -100,7 +102,7 @@ pub(crate) fn verify_fixed_delegated_puzzle_hash_for_binding( /// Accepts operator `fixed_delegated_puzzle_hash` (raw delegated CONDITIONS tree hash) or /// Cloud Wallet `fixedConditionsHash` (already member-wrapped). Construction errors propagate; /// only a successful hash build with a non-matching binding p2 falls through to the other -/// encoding before returning [`SignerError::PresplitCoinPuzzleHashMismatch`]. +/// encoding before returning [`crate::error::OfferError::PresplitCoinPuzzleHashMismatch`]. /// /// # Errors /// @@ -118,7 +120,9 @@ pub(crate) fn resolve_member_fixed_conditions_hash_for_binding( if binding_p2_puzzle_hash == member_hashes.puzzle_hash.into() { return Ok(member_hashes.fixed_conditions_hash); } - Err(SignerError::PresplitCoinPuzzleHashMismatch) + Err(SignerError::Offer( + OfferError::PresplitCoinPuzzleHashMismatch, + )) } /// Read presplit maker binding from a cancellable input (XCH or CAT). @@ -196,6 +200,9 @@ mod tests { TreeHash::new([0x33; 32]), ) .expect_err("unrelated hash"); - assert!(matches!(err, SignerError::PresplitCoinPuzzleHashMismatch)); + assert!(matches!( + err, + SignerError::Offer(OfferError::PresplitCoinPuzzleHashMismatch) + )); } } diff --git a/greenfloor-engine/src/offer/presplit/cancel_binding/parse.rs b/greenfloor-engine/src/offer/presplit/cancel_binding/parse.rs index be1f57cf..cdb1db09 100644 --- a/greenfloor-engine/src/offer/presplit/cancel_binding/parse.rs +++ b/greenfloor-engine/src/offer/presplit/cancel_binding/parse.rs @@ -2,7 +2,7 @@ use chia_protocol::{Coin, CoinSpend, SpendBundle}; use chia_sdk_driver::{Cat, Puzzle}; use clvmr::{serde::node_from_bytes, Allocator, NodePtr}; -use crate::error::{SignerError, SignerResult}; +use crate::error::{OfferError, SignerError, SignerResult}; pub(super) struct ParsedOfferMakerSpend { pub cat: Option, @@ -11,9 +11,9 @@ pub(super) struct ParsedOfferMakerSpend { } pub(super) fn binding_parse_err(detail: impl Into) -> SignerError { - SignerError::OfferCancelPresplitBindingParseFailed { + SignerError::Offer(OfferError::OfferCancelPresplitBindingParseFailed { detail: detail.into(), - } + }) } pub(super) fn coin_spend_for_presplit_input( @@ -25,7 +25,7 @@ pub(super) fn coin_spend_for_presplit_input( return Ok(coin_spend); } } - Err(SignerError::OfferCancelNoSpendableInput) + Err(SignerError::Offer(OfferError::OfferCancelNoSpendableInput)) } pub(super) fn parse_offer_maker_coin_spend( @@ -42,7 +42,7 @@ pub(super) fn parse_offer_maker_coin_spend( Cat::parse(allocator, coin_spend.coin, puzzle, solution_ptr).map_err(SignerError::from)? { if parsed_cat.coin.coin_id() != coin.coin_id() { - return Err(SignerError::OfferCancelNoSpendableInput); + return Err(SignerError::Offer(OfferError::OfferCancelNoSpendableInput)); } Ok(ParsedOfferMakerSpend { cat: Some(parsed_cat), diff --git a/greenfloor-engine/src/offer/presplit/split.rs b/greenfloor-engine/src/offer/presplit/split.rs index 98493bb4..be08fe2f 100644 --- a/greenfloor-engine/src/offer/presplit/split.rs +++ b/greenfloor-engine/src/offer/presplit/split.rs @@ -4,7 +4,7 @@ use chia_sdk_driver::{Cat, CatSpend, SpendContext, Vault}; use chia_sdk_types::Conditions; use crate::coinset::OfferCoinsetBackend; -use crate::error::{SignerError, SignerResult}; +use crate::error::{OfferError, SignerError, SignerResult}; use crate::vault::materialize::{ append_vault_singleton_spend_for_vault, build_vault_cat_inner_spend, }; @@ -18,7 +18,9 @@ use crate::vault::spend::{VaultFastForwardSigner, VaultSpendContext}; /// Returns an error when more or fewer than one source CAT is provided. pub fn validate_presplit_source_cats(source_cat_count: usize) -> SignerResult<()> { if source_cat_count != 1 { - return Err(SignerError::PresplitRequiresSingleSourceCat); + return Err(SignerError::Offer( + OfferError::PresplitRequiresSingleSourceCat, + )); } Ok(()) } @@ -134,6 +136,9 @@ mod tests { #[test] fn presplit_requires_single_source_cat() { let err = validate_presplit_source_cats(2).unwrap_err(); - assert!(matches!(err, SignerError::PresplitRequiresSingleSourceCat)); + assert!(matches!( + err, + SignerError::Offer(OfferError::PresplitRequiresSingleSourceCat) + )); } } diff --git a/greenfloor-engine/src/offer/pricing.rs b/greenfloor-engine/src/offer/pricing.rs index 69d2c00b..ccc03767 100644 --- a/greenfloor-engine/src/offer/pricing.rs +++ b/greenfloor-engine/src/offer/pricing.rs @@ -3,7 +3,7 @@ //! Policy: non-finite or out-of-range ladder/offer math returns `SignerError` (no silent zero). //! Offer-leg quote mojos use `InvalidOfferRequestAmount`; ladder thresholds use `InvalidLadderMath`. -use crate::error::{SignerError, SignerResult}; +use crate::error::{CoinOpsError, OfferError, SignerError, SignerResult}; #[allow(clippy::cast_precision_loss)] #[must_use] @@ -36,7 +36,8 @@ fn f64_to_i64_round_internal(value: f64) -> Result { /// Returns an error if the operation fails. #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] pub fn f64_to_i64_round_ladder(value: f64) -> SignerResult { - f64_to_i64_round_internal(value).map_err(|()| SignerError::InvalidLadderMath) + f64_to_i64_round_internal(value) + .map_err(|()| SignerError::CoinOps(CoinOpsError::InvalidLadderMath)) } /// Quote-leg mojos for a base size at the given price and unit multiplier. @@ -52,7 +53,7 @@ pub fn quote_mojos_for_base_size( f64_to_i64_round_internal( i64_to_f64(size_base_units) * quote_price * i64_to_f64(quote_unit_multiplier), ) - .map_err(|()| SignerError::InvalidOfferRequestAmount) + .map_err(|()| SignerError::Offer(OfferError::InvalidOfferRequestAmount)) } /// Ladder combine threshold: `ceil(target_count * factor)` with a minimum of 2. @@ -66,7 +67,7 @@ pub fn combine_threshold_count( ) -> SignerResult { let scaled = i64_to_f64(target_count) * combine_when_excess_factor; if !scaled.is_finite() { - return Err(SignerError::InvalidLadderMath); + return Err(SignerError::CoinOps(CoinOpsError::InvalidLadderMath)); } f64_to_i64_round_ladder(scaled.ceil().max(2.0)) } diff --git a/greenfloor-engine/src/offer/publish/dexie/mod.rs b/greenfloor-engine/src/offer/publish/dexie/mod.rs index 63d9bb51..f3eae40d 100644 --- a/greenfloor-engine/src/offer/publish/dexie/mod.rs +++ b/greenfloor-engine/src/offer/publish/dexie/mod.rs @@ -2,15 +2,11 @@ use serde_json::json; use crate::adapters::{DexieClient, DexieResponse}; use crate::cycle::{dexie_invalid_offer_retry_sleep, dexie_invalid_offer_should_retry}; -use crate::error::SignerResult; -use crate::offer::lifecycle::reconcile_prep::{fetch_dexie_offer, DexieFetchMode, DexieOfferFetch}; +use crate::error::{SignerError, SignerResult, TransportError}; +use crate::offer::lifecycle::reconcile_prep::{fetch_dexie_offer, DexieOfferFetch}; use super::{dexie_offer_asset_expectation_error, ExpectedPublishAssetFields}; -mod visibility; - -use visibility::is_transient_dexie_visibility_404_error; - const DEXIE_INVALID_OFFER_RETRY_MAX_ATTEMPTS: u32 = 4; const DEXIE_INVALID_OFFER_RETRY_INITIAL_SLEEP_SECONDS: f64 = 1.0; const DEXIE_VISIBILITY_POLL_ATTEMPTS: u32 = 4; @@ -28,10 +24,11 @@ pub struct PostOfferPhaseDexieParams<'a> { } #[derive(Debug)] -pub(super) enum OfferVisibilityPoll { - Ready, - Retry(String), +enum OfferVisibilityWait { + Visible, Failed(String), + Missing(String), + Unresolved(String), } async fn sleep_for_publish(seconds: f64) { @@ -66,6 +63,20 @@ fn dexie_publish_failure(response: DexieResponse, error: impl Into) -> D DexieResponse::from_value(body) } +fn failed_dexie_from_http_status(err: SignerError) -> SignerResult { + if matches!( + &err, + SignerError::Transport(TransportError::HttpStatus { .. }) + ) { + Ok(DexieResponse::from_value(json!({ + "success": false, + "error": err.to_string(), + }))) + } else { + Err(err) + } +} + async fn post_dexie_offer_with_invalid_offer_retry( dexie: &DexieClient, offer_text: &str, @@ -74,81 +85,66 @@ async fn post_dexie_offer_with_invalid_offer_retry( ) -> SignerResult { let mut attempt = 0u32; loop { - let result = dexie - .post_offer(offer_text, drop_only, claim_rewards) - .await?; - if !dexie_invalid_offer_should_retry( - result.error_text(), - attempt, - DEXIE_INVALID_OFFER_RETRY_MAX_ATTEMPTS, - ) { - return Ok(result); - } - let sleep_seconds = dexie_invalid_offer_retry_sleep( - attempt, - DEXIE_INVALID_OFFER_RETRY_INITIAL_SLEEP_SECONDS, - ); - sleep_for_publish(sleep_seconds).await; - attempt += 1; - } -} - -pub(super) async fn poll_dexie_offer_visibility_once( - dexie: &DexieClient, - offer_id: &str, - expected: &ExpectedPublishAssetFields, -) -> OfferVisibilityPoll { - let fetched = fetch_dexie_offer(dexie, offer_id, DexieFetchMode::HealStrict).await; - if !fetched.visibility_ready() { - return match fetched { - DexieOfferFetch::Missing(err) => OfferVisibilityPoll::Retry(err), - DexieOfferFetch::Mismatch => { - OfferVisibilityPoll::Retry("dexie_offer_visibility_payload_mismatch".to_string()) - } - DexieOfferFetch::LookupError(err) => { - OfferVisibilityPoll::Retry(format!("dexie_get_offer_error:{err}")) + match dexie.post_offer(offer_text, drop_only, claim_rewards).await { + Ok(result) => return Ok(result), + Err(err) + if dexie_invalid_offer_should_retry( + &err, + attempt, + DEXIE_INVALID_OFFER_RETRY_MAX_ATTEMPTS, + ) => + { + let sleep_seconds = dexie_invalid_offer_retry_sleep( + attempt, + DEXIE_INVALID_OFFER_RETRY_INITIAL_SLEEP_SECONDS, + ); + sleep_for_publish(sleep_seconds).await; + attempt += 1; } - DexieOfferFetch::Found(_) => unreachable!("visibility_ready is true for Found"), - }; - } - let DexieOfferFetch::Found(offer_obj) = fetched else { - unreachable!("visibility_ready is true for Found"); - }; - if let Some(asset_error) = dexie_offer_asset_expectation_error( - offer_obj.get("offered").unwrap_or(&serde_json::Value::Null), - offer_obj - .get("requested") - .unwrap_or(&serde_json::Value::Null), - expected, - ) { - return OfferVisibilityPoll::Failed(asset_error); + Err(err) => return failed_dexie_from_http_status(err), + } } - OfferVisibilityPoll::Ready } async fn wait_for_dexie_offer_visible( dexie: &DexieClient, offer_id: &str, expected: &ExpectedPublishAssetFields, -) -> Option { +) -> OfferVisibilityWait { let clean_offer_id = offer_id.trim(); if clean_offer_id.is_empty() { - return Some("dexie_offer_missing_id_after_publish".to_string()); + return OfferVisibilityWait::Unresolved("dexie_offer_missing_id_after_publish".to_string()); } - let mut last_error = "dexie_offer_not_visible_after_publish".to_string(); + let mut last: Result = + Err("dexie_offer_not_visible_after_publish".to_string()); for attempt in 1..=DEXIE_VISIBILITY_POLL_ATTEMPTS { - match poll_dexie_offer_visibility_once(dexie, clean_offer_id, expected).await { - OfferVisibilityPoll::Ready => return None, - OfferVisibilityPoll::Failed(error) => return Some(error), - OfferVisibilityPoll::Retry(error) => { - last_error = error; - if attempt < DEXIE_VISIBILITY_POLL_ATTEMPTS { - sleep_for_publish(DEXIE_VISIBILITY_POLL_DELAY_SECONDS).await; + match fetch_dexie_offer(dexie, clean_offer_id).await { + Ok(DexieOfferFetch::Found(offer_obj)) => { + if let Some(asset_error) = dexie_offer_asset_expectation_error( + offer_obj.get("offered").unwrap_or(&serde_json::Value::Null), + offer_obj + .get("requested") + .unwrap_or(&serde_json::Value::Null), + expected, + ) { + return OfferVisibilityWait::Failed(asset_error); } + return OfferVisibilityWait::Visible; } + Ok(fetch) => last = Ok(fetch), + Err(err) => last = Err(format!("dexie_get_offer_error:{err}")), + } + if attempt < DEXIE_VISIBILITY_POLL_ATTEMPTS { + sleep_for_publish(DEXIE_VISIBILITY_POLL_DELAY_SECONDS).await; } } - Some(last_error) + match last { + Ok(DexieOfferFetch::Missing(error)) => OfferVisibilityWait::Missing(error), + Ok(_) => { + OfferVisibilityWait::Unresolved("dexie_offer_visibility_payload_mismatch".to_string()) + } + Err(error) => OfferVisibilityWait::Unresolved(error), + } } /// Post offer to Dexie with invalid-offer retry and post-publish visibility checks. @@ -170,7 +166,7 @@ pub async fn post_offer_phase_dexie( "success": false, "error": "dexie_offer_not_visible_after_publish", })); - let mut last_visibility_error = String::new(); + let mut last_missing_error = String::new(); for attempt in 1..=DEXIE_VISIBILITY_REPOST_MAX_ATTEMPTS { let result = post_dexie_offer_with_invalid_offer_retry(dexie, offer_text, drop_only, claim_rewards) @@ -180,26 +176,25 @@ pub async fn post_offer_phase_dexie( return Ok(result); } let posted_offer_id = result.offer_id().unwrap_or("").to_string(); - if let Some(visibility_error) = - wait_for_dexie_offer_visible(dexie, &posted_offer_id, expected).await - { - last_visibility_error = visibility_error; - if !is_transient_dexie_visibility_404_error(&last_visibility_error) { - return Ok(dexie_publish_failure(result, last_visibility_error)); + match wait_for_dexie_offer_visible(dexie, &posted_offer_id, expected).await { + OfferVisibilityWait::Visible => return Ok(result), + OfferVisibilityWait::Failed(error) | OfferVisibilityWait::Unresolved(error) => { + return Ok(dexie_publish_failure(result, error)); } - if attempt < DEXIE_VISIBILITY_REPOST_MAX_ATTEMPTS { - sleep_for_publish(DEXIE_VISIBILITY_REPOST_DELAY_SECONDS).await; + OfferVisibilityWait::Missing(error) => { + last_missing_error = error; + if attempt < DEXIE_VISIBILITY_REPOST_MAX_ATTEMPTS { + sleep_for_publish(DEXIE_VISIBILITY_REPOST_DELAY_SECONDS).await; + } } - continue; } - return Ok(result); } Ok(dexie_publish_failure( last_result, - if last_visibility_error.is_empty() { + if last_missing_error.is_empty() { "dexie_offer_not_visible_after_publish".to_string() } else { - last_visibility_error + last_missing_error }, )) } diff --git a/greenfloor-engine/src/offer/publish/dexie/tests.rs b/greenfloor-engine/src/offer/publish/dexie/tests.rs index 01290ad4..016bcf0b 100644 --- a/greenfloor-engine/src/offer/publish/dexie/tests.rs +++ b/greenfloor-engine/src/offer/publish/dexie/tests.rs @@ -1,7 +1,7 @@ use mockito::Matcher; use serde_json::json; -use super::{poll_dexie_offer_visibility_once, post_offer_phase_dexie, PostOfferPhaseDexieParams}; +use super::{post_offer_phase_dexie, PostOfferPhaseDexieParams}; use crate::adapters::DexieClient; use crate::offer::publish::{ExpectedPublishAssetFields, PublishAssetSide}; @@ -59,6 +59,55 @@ async fn post_offer_phase_posts_and_verifies_visibility() { assert_eq!(result.offer_id(), Some(offer_id)); } +#[tokio::test] +async fn post_offer_phase_retries_http_400_invalid_offer() { + let mut server = mockito::Server::new_async().await; + let offer_id = "offer-400"; + let _post_invalid = server + .mock("POST", "/v1/offers") + .with_status(400) + .with_body(r#"{"error_message":"Invalid Offer"}"#) + .expect(1) + .create_async() + .await; + let _post_ok = server + .mock("POST", "/v1/offers") + .with_status(200) + .with_body(json!({"success": true, "id": offer_id}).to_string()) + .expect(1) + .create_async() + .await; + let _get = server + .mock("GET", Matcher::Regex(r"/v1/offers/.*".to_string())) + .with_status(200) + .with_body( + json!({ + "offer": { + "id": offer_id, + "offered": [{"id": "basecat"}], + "requested": [{"code": "xch"}], + } + }) + .to_string(), + ) + .create_async() + .await; + + let dexie = DexieClient::new(server.url()); + let expected = expected_fields(); + let result = post_offer_phase_dexie(PostOfferPhaseDexieParams { + dexie: &dexie, + offer_text: "offer1test", + drop_only: true, + claim_rewards: false, + expected: &expected, + }) + .await + .expect("post"); + assert!(result.success()); + assert_eq!(result.offer_id(), Some(offer_id)); +} + #[tokio::test] async fn post_offer_phase_fails_on_asset_mismatch() { let mut server = mockito::Server::new_async().await; @@ -154,25 +203,48 @@ async fn post_offer_phase_reposts_on_transient_visibility_404() { } #[tokio::test] -async fn poll_visibility_once_retries_on_http_error_payload() { +async fn post_offer_phase_mismatch_does_not_repost() { let mut server = mockito::Server::new_async().await; - let offer_id = "offer-404"; + let offer_id = "offer-local"; + let _post = server + .mock("POST", "/v1/offers") + .with_status(200) + .with_body(json!({"success": true, "id": offer_id}).to_string()) + .expect(1) + .create_async() + .await; let _get = server .mock("GET", Matcher::Regex(r"/v1/offers/.*".to_string())) - .with_status(404) - .with_body("missing") + .with_status(200) + .with_body( + json!({ + "offer": { + "id": "other-id", + "offered": [{"id": "basecat"}], + "requested": [{"code": "xch"}], + } + }) + .to_string(), + ) .create_async() .await; let dexie = DexieClient::new(server.url()); let expected = expected_fields(); - let poll = poll_dexie_offer_visibility_once(&dexie, offer_id, &expected).await; - match poll { - super::OfferVisibilityPoll::Retry(error) => { - assert!(error.contains("dexie_http_error:404")); - } - other => panic!("expected retry, got {other:?}"), - } + let result = post_offer_phase_dexie(PostOfferPhaseDexieParams { + dexie: &dexie, + offer_text: "offer1test", + drop_only: true, + claim_rewards: false, + expected: &expected, + }) + .await + .expect("post"); + assert!(!result.success()); + assert_eq!( + result.error_text(), + "dexie_offer_visibility_payload_mismatch" + ); } #[test] diff --git a/greenfloor-engine/src/offer/publish/dexie/visibility.rs b/greenfloor-engine/src/offer/publish/dexie/visibility.rs deleted file mode 100644 index bb367666..00000000 --- a/greenfloor-engine/src/offer/publish/dexie/visibility.rs +++ /dev/null @@ -1,24 +0,0 @@ -use crate::adapters::is_dexie_offer_missing_error_text; - -#[must_use] -pub(super) fn is_transient_dexie_visibility_404_error(error: &str) -> bool { - is_dexie_offer_missing_error_text(error) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn dexie_visibility_404_is_transient() { - assert!(is_transient_dexie_visibility_404_error( - "dexie_http_error:404 not found" - )); - assert!(is_transient_dexie_visibility_404_error( - "dexie_get_offer_error:404 missing" - )); - assert!(!is_transient_dexie_visibility_404_error( - "dexie_offer_offered_asset_missing:cat" - )); - } -} diff --git a/greenfloor-engine/src/offer/reclaim.rs b/greenfloor-engine/src/offer/reclaim.rs index c4db5dc3..b84498d1 100644 --- a/greenfloor-engine/src/offer/reclaim.rs +++ b/greenfloor-engine/src/offer/reclaim.rs @@ -10,7 +10,7 @@ use crate::coinset::{ OfferCoinsetBackend, }; use crate::config::SignerConfig; -use crate::error::{SignerError, SignerResult}; +use crate::error::{OfferError, SignerError, SignerResult}; use crate::hex::normalize_hex_id; use crate::offer::cancel_input::{ classify_cancellable_maker_input, classify_maker_input_from_stored_metadata, @@ -172,7 +172,7 @@ pub async fn build_offer_cancel_spend_bundle( let offer = Offer::from_spend_bundle(&mut allocator, &spend_bundle)?; let cancellable = offer.cancellable_coin_spends().map_err(SignerError::from)?; if cancellable.is_empty() { - return Err(SignerError::OfferCancelNoSpendableInput); + return Err(SignerError::Offer(OfferError::OfferCancelNoSpendableInput)); } let change_puzzle_hash = vault_change_puzzle_hash(vault_ctx.launcher_id)?; diff --git a/greenfloor-engine/src/offer/request.rs b/greenfloor-engine/src/offer/request.rs index 2d50c670..1269af51 100644 --- a/greenfloor-engine/src/offer/request.rs +++ b/greenfloor-engine/src/offer/request.rs @@ -1,6 +1,6 @@ //! Deterministic signer ``create_offer`` leg math and request shaping (no IO). -use crate::error::{SignerError, SignerResult}; +use crate::error::{OfferError, SignerError, SignerResult}; use crate::offer::build_context::mojo_multiplier_for_leg; use crate::offer::pricing::quote_mojos_for_base_size; @@ -107,21 +107,22 @@ fn base_and_quote_leg_mojos( quote_mult: i64, ) -> SignerResult<(u64, u64)> { if size_base_units <= 0 { - return Err(SignerError::InvalidSizeBaseUnits); + return Err(SignerError::Offer(OfferError::InvalidSizeBaseUnits)); } let base_offer = size_base_units .checked_mul(base_mult) - .ok_or(SignerError::InvalidOfferAmount)?; + .ok_or(SignerError::Offer(OfferError::InvalidOfferAmount))?; if base_offer <= 0 { - return Err(SignerError::InvalidOfferAmount); + return Err(SignerError::Offer(OfferError::InvalidOfferAmount)); } let request_amount = quote_mojos_for_base_size(size_base_units, quote_price, quote_mult)?; if request_amount <= 0 { - return Err(SignerError::InvalidOfferRequestAmount); + return Err(SignerError::Offer(OfferError::InvalidOfferRequestAmount)); } - let offer_u = u64::try_from(base_offer).map_err(|_| SignerError::InvalidOfferAmount)?; - let request_u = - u64::try_from(request_amount).map_err(|_| SignerError::InvalidOfferRequestAmount)?; + let offer_u = u64::try_from(base_offer) + .map_err(|_| SignerError::Offer(OfferError::InvalidOfferAmount))?; + let request_u = u64::try_from(request_amount) + .map_err(|_| SignerError::Offer(OfferError::InvalidOfferRequestAmount))?; Ok((offer_u, request_u)) } @@ -250,7 +251,10 @@ mod tests { &pricing(1_000, 1_000), ) .unwrap_err(); - assert!(matches!(err, SignerError::InvalidOfferRequestAmount)); + assert!(matches!( + err, + SignerError::Offer(OfferError::InvalidOfferRequestAmount) + )); } #[test] @@ -264,7 +268,10 @@ mod tests { &pricing(1_000, 1_000), ) .unwrap_err(); - assert!(matches!(err, SignerError::InvalidSizeBaseUnits)); + assert!(matches!( + err, + SignerError::Offer(OfferError::InvalidSizeBaseUnits) + )); } #[test] @@ -278,7 +285,10 @@ mod tests { &pricing(0, 1_000), ) .unwrap_err(); - assert!(matches!(err, SignerError::InvalidOfferAmount)); + assert!(matches!( + err, + SignerError::Offer(OfferError::InvalidOfferAmount) + )); } #[test] diff --git a/greenfloor-engine/src/offer/types.rs b/greenfloor-engine/src/offer/types.rs index 3bf84f34..c2553efa 100644 --- a/greenfloor-engine/src/offer/types.rs +++ b/greenfloor-engine/src/offer/types.rs @@ -1,7 +1,7 @@ use chia_protocol::Bytes32; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use crate::error::{SignerError, SignerResult}; +use crate::error::{OfferError, SignerError, SignerResult}; fn default_bake_expiry_into_conditions() -> bool { true @@ -145,7 +145,9 @@ impl TryFrom for OfferInput { if !request.presplit_coin_ids.is_empty() { if request.presplit_coin_ids.len() != 1 { - return Err(SignerError::PresplitOfferRequiresSingleCoin); + return Err(SignerError::Offer( + OfferError::PresplitOfferRequiresSingleCoin, + )); } return Ok(Self::PresplitExisting { terms, @@ -462,7 +464,9 @@ mod tests { invalid.presplit_coin_ids = vec![sample_coin_id(0x02), sample_coin_id(0x03)]; assert!(matches!( OfferInput::try_from(invalid), - Err(SignerError::PresplitOfferRequiresSingleCoin) + Err(SignerError::Offer( + OfferError::PresplitOfferRequiresSingleCoin + )) )); } diff --git a/greenfloor-engine/src/storage/sqlite/mod.rs b/greenfloor-engine/src/storage/sqlite/mod.rs index d5052de3..2d797c39 100644 --- a/greenfloor-engine/src/storage/sqlite/mod.rs +++ b/greenfloor-engine/src/storage/sqlite/mod.rs @@ -28,7 +28,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use chrono::Utc; use rusqlite::{Connection, Row}; -use crate::error::{SignerError, SignerResult}; +use crate::error::{PersistenceError, SignerError, SignerResult}; use crate::offer::types::{OfferCancelFields, OfferExecutionMode}; use super::schema::schema_sql; @@ -67,10 +67,21 @@ pub use reservations::{ OfferReservationRejectReason, }; -pub(crate) fn db_err(context: &str, err: impl std::fmt::Display) -> SignerError { +#[allow(clippy::needless_pass_by_value)] +pub(crate) fn db_err(context: &str, err: rusqlite::Error) -> SignerError { + if is_sqlite_lock_error(&err) { + return PersistenceError::DatabaseLocked.into(); + } SignerError::Other(format!("{context}: {err}")) } +fn is_sqlite_lock_error(err: &rusqlite::Error) -> bool { + matches!( + err.sqlite_error_code(), + Some(rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked) + ) +} + /// Numbered `?1, ?2, …` placeholders for `IN (...)` clauses. pub(crate) fn in_placeholders(count: usize) -> String { (1..=count) @@ -210,9 +221,11 @@ impl SqliteStore { )) })?; } - let conn = Connection::open(db_path).map_err(|err| SignerError::SqliteOpenFailed { - path: db_path.display().to_string(), - open_error: err.to_string(), + let conn = Connection::open(db_path).map_err(|err| { + SignerError::Persistence(PersistenceError::SqliteOpenFailed { + path: db_path.display().to_string(), + open_error: err.to_string(), + }) })?; conn.busy_timeout(Duration::from_secs(30)).map_err(|err| { SignerError::Other(format!("failed to set sqlite busy_timeout: {err}")) @@ -242,3 +255,33 @@ impl SqliteStore { pub(crate) fn utcnow_iso() -> String { Utc::now().to_rfc3339() } + +#[cfg(test)] +mod tests { + use super::db_err; + use crate::error::{PersistenceError, SignerError}; + + fn sqlite_failure(code: i32) -> rusqlite::Error { + rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(code), None) + } + + #[test] + fn db_err_maps_busy_and_locked_to_database_locked() { + const SQLITE_BUSY: i32 = 5; + const SQLITE_LOCKED: i32 = 6; + assert!(matches!( + db_err("query offers", sqlite_failure(SQLITE_BUSY)), + SignerError::Persistence(PersistenceError::DatabaseLocked) + )); + assert!(matches!( + db_err("query offers", sqlite_failure(SQLITE_LOCKED)), + SignerError::Persistence(PersistenceError::DatabaseLocked) + )); + let other = db_err("query offers", sqlite_failure(1)); + assert!(!matches!( + other, + SignerError::Persistence(PersistenceError::DatabaseLocked) + )); + assert!(other.to_string().contains("query offers")); + } +} diff --git a/greenfloor-engine/src/test_support/simulator/coinset_backend.rs b/greenfloor-engine/src/test_support/simulator/coinset_backend.rs index 5feb9f34..efb2c521 100644 --- a/greenfloor-engine/src/test_support/simulator/coinset_backend.rs +++ b/greenfloor-engine/src/test_support/simulator/coinset_backend.rs @@ -7,9 +7,12 @@ use clvm_utils::TreeHash; use super::harness::{ fetch_cat_from_sim, fetch_cat_from_sim_by_id, fetch_vault_from_sim, SimChain, }; -use crate::coinset::coin_select::{finalize_selected_cats, SelectedCats}; +use crate::coin_ops::FundingSelectionMode; +use crate::coinset::coin_select::{ + finalize_preselected_cats_for_spend, select_resolved_cats, SelectedCats, +}; use crate::coinset::OfferCoinsetBackend; -use crate::error::{SignerError, SignerResult}; +use crate::error::{OfferError, SignerError, SignerResult}; use chia_sdk_driver::{Cat, Vault}; pub(crate) struct SimulatorOfferCoinset<'a> { @@ -49,7 +52,7 @@ impl<'a> SimulatorOfferCoinset<'a> { let sim = self.chain.sim.lock().expect("sim lock"); if let Some(state) = sim.coin_state(coin_id) { if state.spent_height.is_some() { - return Err(SignerError::PresplitCoinNotFound); + return Err(SignerError::Offer(OfferError::PresplitCoinNotFound)); } } } @@ -83,7 +86,11 @@ impl OfferCoinsetBackend for SimulatorOfferCoinset<'_> { } cats }; - finalize_selected_cats(cats, explicit_coin_ids, target_amount) + if explicit_coin_ids.is_empty() { + select_resolved_cats(cats, target_amount, FundingSelectionMode::SmallestFirst) + } else { + finalize_preselected_cats_for_spend(cats, explicit_coin_ids, target_amount) + } } async fn fetch_latest_vault( @@ -102,8 +109,8 @@ impl OfferCoinsetBackend for SimulatorOfferCoinset<'_> { async fn fetch_offer_input_cat(&self, coin_id: Bytes32) -> SignerResult { match self.fetch_by_id(coin_id) { Ok(cat) => Ok(cat), - Err(SignerError::PresplitCoinNotFound | SignerError::Other(_)) => { - Err(SignerError::PresplitCoinNotFound) + Err(SignerError::Offer(OfferError::PresplitCoinNotFound) | SignerError::Other(_)) => { + Err(SignerError::Offer(OfferError::PresplitCoinNotFound)) } Err(err) => Err(err), } @@ -113,7 +120,7 @@ impl OfferCoinsetBackend for SimulatorOfferCoinset<'_> { let sim = self.chain.sim.lock().expect("sim lock"); match sim.coin_state(coin_id) { Some(state) if state.spent_height.is_none() => Ok(state.coin), - _ => Err(SignerError::PresplitCoinNotFound), + _ => Err(SignerError::Offer(OfferError::PresplitCoinNotFound)), } } diff --git a/greenfloor-engine/src/test_support/simulator/harness.rs b/greenfloor-engine/src/test_support/simulator/harness.rs index 3390d8fe..e4ab12b0 100644 --- a/greenfloor-engine/src/test_support/simulator/harness.rs +++ b/greenfloor-engine/src/test_support/simulator/harness.rs @@ -53,8 +53,9 @@ impl SimulatorVaultHarness { let sk = signer.sk; vault_ctx.set_local_fast_forward_signer(Arc::new(move |message| { let digest: [u8; 32] = Sha256::digest(&message).into(); - sk.sign_prehashed(&digest) - .map_err(|err| crate::error::SignerError::Kms(err.to_string())) + sk.sign_prehashed(&digest).map_err(|err| { + crate::error::SignerError::Vault(crate::error::VaultError::Kms(err.to_string())) + }) })); Self { chain: SimChain { diff --git a/greenfloor-engine/src/test_support/simulator/offer_cancel_roundtrips.rs b/greenfloor-engine/src/test_support/simulator/offer_cancel_roundtrips.rs index 007017e5..3f7a88f3 100644 --- a/greenfloor-engine/src/test_support/simulator/offer_cancel_roundtrips.rs +++ b/greenfloor-engine/src/test_support/simulator/offer_cancel_roundtrips.rs @@ -11,7 +11,7 @@ use super::offer_roundtrip_setup::{ TEST_CAT_MOJO_MULT, }; use crate::coinset::OfferCoinsetBackend; -use crate::error::SignerError; +use crate::error::{OfferError, SignerError}; use crate::offer::classify_cancellable_maker_input; use crate::offer::presplit::{ build_offer_from_presplit_xch, vault_change_puzzle_hash, PresplitOfferBinding, @@ -513,7 +513,10 @@ async fn build_offer_cancel_rejects_non_vault_maker_coin() { .await .expect_err("non-vault maker must fail"); assert!( - matches!(err, SignerError::OfferCancelInputNotVaultOwned { .. }), + matches!( + err, + SignerError::Offer(OfferError::OfferCancelInputNotVaultOwned { .. }) + ), "expected OfferCancelInputNotVaultOwned, got {err}" ); } @@ -536,7 +539,10 @@ async fn build_offer_cancel_rejects_spent_direct_vault_cat() { .await .expect_err("spent direct vault cat must fail fast"); assert!( - matches!(err, SignerError::OfferCancelInputCoinAlreadySpent), + matches!( + err, + SignerError::Offer(OfferError::OfferCancelInputCoinAlreadySpent) + ), "expected OfferCancelInputCoinAlreadySpent, got {err}" ); } @@ -573,7 +579,10 @@ async fn build_offer_cancel_rejects_spent_presplit_cat() { .await .expect_err("spent presplit cat must fail fast"); assert!( - matches!(err, SignerError::OfferCancelInputCoinAlreadySpent), + matches!( + err, + SignerError::Offer(OfferError::OfferCancelInputCoinAlreadySpent) + ), "expected OfferCancelInputCoinAlreadySpent, got {err}" ); } @@ -633,7 +642,10 @@ async fn classify_direct_vault_p2_rejects_spent_coin() { .await .expect_err("spent direct vault p2 must fail fast"); assert!( - matches!(err, SignerError::OfferCancelInputCoinAlreadySpent), + matches!( + err, + SignerError::Offer(OfferError::OfferCancelInputCoinAlreadySpent) + ), "expected OfferCancelInputCoinAlreadySpent, got {err}" ); } diff --git a/greenfloor-engine/src/vault/cat_create.rs b/greenfloor-engine/src/vault/cat_create.rs index e05bd3e7..d697352d 100644 --- a/greenfloor-engine/src/vault/cat_create.rs +++ b/greenfloor-engine/src/vault/cat_create.rs @@ -13,7 +13,7 @@ use chia_protocol::Bytes32; use chia_sdk_driver::{Cat, Outputs}; use crate::coinset::cat_outer_puzzle_hash; -use crate::error::{SignerError, SignerResult}; +use crate::error::{SignerError, SignerResult, VaultError}; /// CAT creates recorded on a [`Outputs`] map (after `Spends::prepare`). pub(crate) fn created_cats(outputs: &Outputs) -> impl Iterator { @@ -25,9 +25,9 @@ pub(crate) fn created_cats(outputs: &Outputs) -> impl Iterator { /// /// # Errors /// -/// - [`SignerError::VaultCatCreateDestinationIsOuterLayer`] when a create used the +/// - [`crate::error::VaultError::CatCreateDestinationIsOuterLayer`] when a create used the /// receive CAT outer as its p2 (the double-wrap regression). -/// - [`SignerError::VaultCatCreateDestinationNotReceiveP2`] when a create used any +/// - [`crate::error::VaultError::CatCreateDestinationNotReceiveP2`] when a create used any /// other unexpected p2. pub(crate) fn assert_cat_creates<'a>( cats: impl IntoIterator, @@ -42,12 +42,16 @@ pub(crate) fn assert_cat_creates<'a>( } let p2 = cat.info.p2_puzzle_hash; if p2 == receive_outer { - return Err(SignerError::VaultCatCreateDestinationIsOuterLayer); + return Err(SignerError::Vault( + VaultError::CatCreateDestinationIsOuterLayer, + )); } if p2 == receive_p2 || allowed_non_receive_p2s.contains(&p2) { continue; } - return Err(SignerError::VaultCatCreateDestinationNotReceiveP2); + return Err(SignerError::Vault( + VaultError::CatCreateDestinationNotReceiveP2, + )); } Ok(()) } @@ -92,7 +96,7 @@ mod tests { let err = assert_cat_creates([&cat], asset, receive, &[]).unwrap_err(); assert!(matches!( err, - SignerError::VaultCatCreateDestinationIsOuterLayer + SignerError::Vault(VaultError::CatCreateDestinationIsOuterLayer) )); } @@ -105,7 +109,7 @@ mod tests { let err = assert_cat_creates([&cat], asset, receive, &[]).unwrap_err(); assert!(matches!( err, - SignerError::VaultCatCreateDestinationNotReceiveP2 + SignerError::Vault(VaultError::CatCreateDestinationNotReceiveP2) )); } diff --git a/greenfloor-engine/src/vault/context.rs b/greenfloor-engine/src/vault/context.rs index 123d475b..521259cf 100644 --- a/greenfloor-engine/src/vault/context.rs +++ b/greenfloor-engine/src/vault/context.rs @@ -3,7 +3,7 @@ use clvm_utils::{tree_hash_pair, TreeHash}; use serde::Serialize; use serde_json::Value; -use crate::error::{SignerError, SignerResult}; +use crate::error::{SignerError, SignerResult, VaultError}; use crate::hex::{bytes32_to_hex, hex_to_bytes32, tree_hash_nil, tree_hash_to_hex}; use crate::vault::members::{ force_1_of_2_restriction, m_of_n_hash, member_hash_for_key, nonce_member_puzzle_hash, @@ -34,26 +34,26 @@ impl VaultCustodySnapshot { .and_then(Value::as_str) .unwrap_or_default(), ) - .map_err(|_| SignerError::VaultLauncherIdInvalid)?; + .map_err(|_| SignerError::Vault(VaultError::LauncherIdInvalid))?; let custody_threshold = parse_u32_field(value, "custodyThreshold") - .ok_or(SignerError::VaultThresholdOrTimelockInvalid)?; + .ok_or(SignerError::Vault(VaultError::ThresholdOrTimelockInvalid))?; let recovery_threshold = parse_u32_field(value, "recoveryThreshold") - .ok_or(SignerError::VaultThresholdOrTimelockInvalid)?; + .ok_or(SignerError::Vault(VaultError::ThresholdOrTimelockInvalid))?; let recovery_clawback_timelock = value .get("recoveryClawbackTimelock") .and_then(parse_json_u64) - .ok_or(SignerError::VaultThresholdOrTimelockInvalid)?; + .ok_or(SignerError::Vault(VaultError::ThresholdOrTimelockInvalid))?; let custody_keys = extract_wallet_keys(value.get("custodyKeys")); let recovery_keys = extract_wallet_keys(value.get("recoveryKeys")); if custody_keys.is_empty() || recovery_keys.is_empty() { - return Err(SignerError::UnsupportedVaultSignerCardinality); + return Err(SignerError::Vault(VaultError::UnsupportedSignerCardinality)); } validate_vault_threshold(custody_threshold, custody_keys.len())?; validate_vault_threshold(recovery_threshold, recovery_keys.len())?; if recovery_clawback_timelock == 0 { - return Err(SignerError::InvalidVaultRecoveryTimelock); + return Err(SignerError::Vault(VaultError::InvalidRecoveryTimelock)); } Ok(Self { @@ -185,15 +185,15 @@ pub fn compute_vault_context_from_hashes( secp256r1_custody_keys.len() == 1 && normalized_kms == secp256r1_custody_keys[0]; if secp256r1_custody_keys.len() != 1 { - return Err(SignerError::VaultSecp256r1KeyCount( + return Err(SignerError::Vault(VaultError::Secp256r1KeyCount( secp256r1_custody_keys.len(), - )); + ))); } if !kms_custody_key_match { - return Err(SignerError::KmsPublicKeyMismatch { + return Err(SignerError::Vault(VaultError::KmsPublicKeyMismatch { kms: normalized_kms, custody: secp256r1_custody_keys[0].clone(), - }); + })); } Ok(VaultContext { diff --git a/greenfloor-engine/src/vault/materialize.rs b/greenfloor-engine/src/vault/materialize.rs index f8450a67..d02fe9e8 100644 --- a/greenfloor-engine/src/vault/materialize.rs +++ b/greenfloor-engine/src/vault/materialize.rs @@ -15,7 +15,7 @@ use chia_secp::R1Signature; use clvm_utils::TreeHash; use crate::coinset::OfferCoinsetBackend; -use crate::error::{SignerError, SignerResult}; +use crate::error::{SignerError, SignerResult, VaultError}; use crate::vault::members::u32_to_usize; use crate::vault::messages::extract_mode23_receive_messages; use crate::vault::spend::{VaultFastForwardSigner, VaultSpendContext}; @@ -196,7 +196,7 @@ where { let receive_messages = extract_mode23_receive_messages(ctx)?; if receive_messages.is_empty() { - return Err(SignerError::VaultReceiveMessageNotFound); + return Err(SignerError::Vault(VaultError::ReceiveMessageNotFound)); } let mut conditions = Conditions::new().create_coin( vault_ctx.inner_puzzle_hash.into(), diff --git a/greenfloor-engine/src/vault/members/curves.rs b/greenfloor-engine/src/vault/members/curves.rs index e170db7d..a766361d 100644 --- a/greenfloor-engine/src/vault/members/curves.rs +++ b/greenfloor-engine/src/vault/members/curves.rs @@ -11,7 +11,7 @@ use chia_sdk_types::{ use chia_secp::{K1PublicKey, R1PublicKey}; use clvm_utils::TreeHash; -use crate::error::{SignerError, SignerResult}; +use crate::error::{SignerError, SignerResult, VaultError}; use crate::hex::{fixed_bytes, hex_to_bytes}; use super::config::{MemberConfig, WalletKey}; @@ -152,7 +152,9 @@ impl WalletCurve { Self::Bls12_381 => { let key_array = fixed_bytes::<48>(key_bytes)?; let pk = PublicKey::from_bytes(&key_array).map_err(|err| { - SignerError::UnsupportedVaultCurve(format!("BLS12_381 decode: {err}")) + SignerError::Vault(VaultError::UnsupportedCurve(format!( + "BLS12_381 decode: {err}" + ))) })?; bls_member_hash(config, pk, false) } @@ -162,14 +164,20 @@ impl WalletCurve { fn decode_r1_public_key(key_bytes: &[u8], curve_label: &str) -> SignerResult { let key_array = fixed_bytes::<33>(key_bytes)?; - R1PublicKey::from_bytes(&key_array) - .map_err(|err| SignerError::UnsupportedVaultCurve(format!("{curve_label} decode: {err}"))) + R1PublicKey::from_bytes(&key_array).map_err(|err| { + SignerError::Vault(VaultError::UnsupportedCurve(format!( + "{curve_label} decode: {err}" + ))) + }) } fn decode_k1_public_key(key_bytes: &[u8]) -> SignerResult { let key_array = fixed_bytes::<33>(key_bytes)?; - K1PublicKey::from_bytes(&key_array) - .map_err(|err| SignerError::UnsupportedVaultCurve(format!("SECP256K1 decode: {err}"))) + K1PublicKey::from_bytes(&key_array).map_err(|err| { + SignerError::Vault(VaultError::UnsupportedCurve(format!( + "SECP256K1 decode: {err}" + ))) + }) } /// Member hash for key. @@ -179,7 +187,9 @@ fn decode_k1_public_key(key_bytes: &[u8]) -> SignerResult { /// Returns an error if the operation fails. pub fn member_hash_for_key(config: &MemberConfig, key: &WalletKey) -> SignerResult { let Some(curve) = WalletCurve::parse(&key.curve) else { - return Err(SignerError::UnsupportedVaultCurve(key.curve.clone())); + return Err(SignerError::Vault(VaultError::UnsupportedCurve( + key.curve.clone(), + ))); }; let key_bytes = hex_to_bytes(&key.public_key_hex)?; curve.hash_key(config, &key_bytes) @@ -252,6 +262,9 @@ mod tests { let config = MemberConfig::default(); let err = member_hash_for_key(&config, &wallet_key("ED25519", &hex::encode([0u8; 32]))) .expect_err("unsupported curve"); - assert!(matches!(err, SignerError::UnsupportedVaultCurve(_))); + assert!(matches!( + err, + SignerError::Vault(VaultError::UnsupportedCurve(_)) + )); } } diff --git a/greenfloor-engine/src/vault/members/hash.rs b/greenfloor-engine/src/vault/members/hash.rs index 5a5dcf48..fb5558bd 100644 --- a/greenfloor-engine/src/vault/members/hash.rs +++ b/greenfloor-engine/src/vault/members/hash.rs @@ -1,12 +1,12 @@ use chia_sdk_driver::{mips_puzzle_hash, MofN}; use clvm_utils::TreeHash; -use crate::error::{SignerError, SignerResult}; +use crate::error::{SignerError, SignerResult, VaultError}; use super::config::MemberConfig; pub(crate) fn u32_to_usize(value: u32) -> SignerResult { - usize::try_from(value).map_err(|_| SignerError::UnsupportedVaultThreshold) + usize::try_from(value).map_err(|_| SignerError::Vault(VaultError::UnsupportedThreshold)) } pub(crate) fn member_hash(config: &MemberConfig, inner_hash: TreeHash) -> SignerResult { diff --git a/greenfloor-engine/src/vault/mixed_split.rs b/greenfloor-engine/src/vault/mixed_split.rs index 586bf591..4d18942a 100644 --- a/greenfloor-engine/src/vault/mixed_split.rs +++ b/greenfloor-engine/src/vault/mixed_split.rs @@ -5,7 +5,7 @@ use chia_sdk_driver::{Action, Cat, Id, Relation, SpendContext, Spends}; use crate::bech32m::decode_address; use crate::coinset::{self, CoinsetClient, LiveCoinset, OfferCoinsetBackend, MIN_CAT_OUTPUT_MOJOS}; use crate::config::SignerConfig; -use crate::error::{SignerError, SignerResult}; +use crate::error::{CoinOpsError, SignerError, SignerResult, VaultError}; use crate::vault::cat_create::{assert_cat_creates, created_cats}; use crate::vault::materialize::materialize_vault_cat_finished_spends; use crate::vault::session::resolve_vault_spend_context; @@ -42,13 +42,15 @@ pub struct MixedSplitResult { pub(crate) fn validate_mixed_split_request(request: &MixedSplitRequest) -> SignerResult<()> { if request.fee_mojos > 0 { - return Err(SignerError::MixedSplitVaultWithFeeNotSupported); + return Err(SignerError::Vault( + VaultError::MixedSplitWithFeeNotSupported, + )); } if request.output_amounts.is_empty() { - return Err(SignerError::MissingOutputAmounts); + return Err(SignerError::Vault(VaultError::MissingOutputAmounts)); } if request.output_amounts.contains(&0) { - return Err(SignerError::InvalidOutputAmount); + return Err(SignerError::Vault(VaultError::InvalidOutputAmount)); } if !request.allow_sub_cat_output && request @@ -56,7 +58,7 @@ pub(crate) fn validate_mixed_split_request(request: &MixedSplitRequest) -> Signe .iter() .any(|amount| *amount < MIN_CAT_OUTPUT_MOJOS) { - return Err(SignerError::CatOutputBelowMinimum); + return Err(SignerError::CoinOps(CoinOpsError::CatOutputBelowMinimum)); } Ok(()) } @@ -124,7 +126,7 @@ async fn build_vault_cat_mixed_split_with_selection( }; let change_amount = selection.change_amount; if !request.allow_sub_cat_output && change_amount > 0 && change_amount < MIN_CAT_OUTPUT_MOJOS { - return Err(SignerError::CatChangeBelowMinimum); + return Err(SignerError::CoinOps(CoinOpsError::CatChangeBelowMinimum)); } let spend_bundle = build_vault_cat_mixed_split_spend_bundle( @@ -234,7 +236,7 @@ async fn build_vault_cat_mixed_split_spend_bundle( #[cfg(test)] mod tests { use super::{validate_mixed_split_request, MixedSplitRequest}; - use crate::error::SignerError; + use crate::error::{CoinOpsError, SignerError, VaultError}; use chia_protocol::Bytes32; fn sample_request(output_amounts: Vec, allow_sub_cat_output: bool) -> MixedSplitRequest { @@ -252,7 +254,10 @@ mod tests { #[test] fn rejects_sub_unit_cat_outputs() { let err = validate_mixed_split_request(&sample_request(vec![999], false)).unwrap_err(); - assert!(matches!(err, SignerError::CatOutputBelowMinimum)); + assert!(matches!( + err, + SignerError::CoinOps(CoinOpsError::CatOutputBelowMinimum) + )); } #[test] @@ -267,20 +272,26 @@ mod tests { let err = validate_mixed_split_request(&request).unwrap_err(); assert!(matches!( err, - SignerError::MixedSplitVaultWithFeeNotSupported + SignerError::Vault(VaultError::MixedSplitWithFeeNotSupported) )); } #[test] fn rejects_empty_output_amounts() { let err = validate_mixed_split_request(&sample_request(vec![], false)).unwrap_err(); - assert!(matches!(err, SignerError::MissingOutputAmounts)); + assert!(matches!( + err, + SignerError::Vault(VaultError::MissingOutputAmounts) + )); } #[test] fn rejects_zero_output_amount() { let err = validate_mixed_split_request(&sample_request(vec![1000, 0], false)).unwrap_err(); - assert!(matches!(err, SignerError::InvalidOutputAmount)); + assert!(matches!( + err, + SignerError::Vault(VaultError::InvalidOutputAmount) + )); } #[tokio::test] @@ -337,7 +348,7 @@ mod tests { .unwrap_err(); assert!(matches!( err, - SignerError::VaultCatCreateDestinationIsOuterLayer + SignerError::Vault(VaultError::CatCreateDestinationIsOuterLayer) )); } } diff --git a/greenfloor-engine/src/vault/spend.rs b/greenfloor-engine/src/vault/spend.rs index 9030776d..f8277d57 100644 --- a/greenfloor-engine/src/vault/spend.rs +++ b/greenfloor-engine/src/vault/spend.rs @@ -7,7 +7,7 @@ use chia_secp::{R1PublicKey, R1Signature}; use clvm_utils::TreeHash; use crate::config::SignerConfig; -use crate::error::{SignerError, SignerResult}; +use crate::error::{SignerError, SignerResult, VaultError}; use crate::hex::hex_to_bytes; use crate::kms::{self, KmsRuntime}; use crate::vault::context::{VaultComputedHashes, VaultContext, VaultCustodySnapshot}; @@ -174,8 +174,11 @@ pub fn build_vault_spend_context_from_hashes( let key_bytes = hex_to_bytes(&display.secp256r1_custody_keys[0])?; let mut key_array = [0u8; 33]; key_array.copy_from_slice(&key_bytes); - let secp256r1_public_key = R1PublicKey::from_bytes(&key_array) - .map_err(|err| SignerError::UnsupportedVaultCurve(format!("SECP256R1 decode: {err}")))?; + let secp256r1_public_key = R1PublicKey::from_bytes(&key_array).map_err(|err| { + SignerError::Vault(VaultError::UnsupportedCurve(format!( + "SECP256R1 decode: {err}" + ))) + })?; Ok(VaultSpendContext { launcher_id: snapshot.launcher_id, inner_puzzle_hash: hashes.inner_puzzle_hash, @@ -206,13 +209,17 @@ pub(crate) async fn sign_vault_fast_forward_digest( &hex::encode(signature_message), ) .await?; - let signature_bytes = hex::decode(crate::hex::normalize_hex(&signature_hex)) - .map_err(|err| SignerError::Kms(format!("invalid signature hex: {err}")))?; - let signature_array: [u8; 64] = signature_bytes - .try_into() - .map_err(|_| SignerError::Kms("invalid compact signature length".to_string()))?; + let signature_bytes = + hex::decode(crate::hex::normalize_hex(&signature_hex)).map_err(|err| { + SignerError::Vault(VaultError::Kms(format!("invalid signature hex: {err}"))) + })?; + let signature_array: [u8; 64] = signature_bytes.try_into().map_err(|_| { + SignerError::Vault(VaultError::Kms( + "invalid compact signature length".to_string(), + )) + })?; R1Signature::from_bytes(&signature_array) - .map_err(|err| SignerError::Kms(format!("invalid r1 signature: {err}"))) + .map_err(|err| SignerError::Vault(VaultError::Kms(format!("invalid r1 signature: {err}")))) } #[cfg(test)] diff --git a/greenfloor-engine/src/vault/threshold.rs b/greenfloor-engine/src/vault/threshold.rs index c2ef34d7..606e143a 100644 --- a/greenfloor-engine/src/vault/threshold.rs +++ b/greenfloor-engine/src/vault/threshold.rs @@ -1,6 +1,6 @@ //! Vault custody threshold validation (YAML signer config and GraphQL snapshots). -use crate::error::{SignerError, SignerResult}; +use crate::error::{SignerError, SignerResult, VaultError}; /// Validate vault threshold. /// @@ -8,10 +8,10 @@ use crate::error::{SignerError, SignerResult}; /// /// Returns an error if the operation fails. pub fn validate_vault_threshold(threshold: u32, key_count: usize) -> SignerResult<()> { - let threshold_usize = - usize::try_from(threshold).map_err(|_| SignerError::UnsupportedVaultThreshold)?; + let threshold_usize = usize::try_from(threshold) + .map_err(|_| SignerError::Vault(VaultError::UnsupportedThreshold))?; if threshold == 0 || threshold_usize > key_count { - return Err(SignerError::UnsupportedVaultThreshold); + return Err(SignerError::Vault(VaultError::UnsupportedThreshold)); } Ok(()) } diff --git a/greenfloor-engine/src/vault_coinset_scan/dust.rs b/greenfloor-engine/src/vault_coinset_scan/dust.rs index de9d375c..48b2556d 100644 --- a/greenfloor-engine/src/vault_coinset_scan/dust.rs +++ b/greenfloor-engine/src/vault_coinset_scan/dust.rs @@ -46,11 +46,13 @@ impl ProvenDustCoin { /// /// # Errors /// - /// Returns [`SignerError::ProvenDustCoinMismatch`] when `dust` and `cat` disagree. + /// Returns [`crate::error::CoinOpsError::ProvenDustCoinMismatch`] when `dust` and `cat` disagree. pub fn from_lineage(dust: &DustCoin, cat: Cat) -> SignerResult { let projected = DustCoin::from_cat(&cat); if dust.coin_id != projected.coin_id || dust.amount != projected.amount { - return Err(crate::error::SignerError::ProvenDustCoinMismatch); + return Err(crate::error::SignerError::CoinOps( + crate::error::CoinOpsError::ProvenDustCoinMismatch, + )); } Ok(Self { cat }) } @@ -306,7 +308,7 @@ mod tests { .unwrap_err(); assert!(matches!( err, - crate::error::SignerError::ProvenDustCoinMismatch + crate::error::SignerError::CoinOps(crate::error::CoinOpsError::ProvenDustCoinMismatch) )); let mut cat = cat_with_amount(50); @@ -325,7 +327,7 @@ mod tests { .unwrap_err(); assert!(matches!( err, - crate::error::SignerError::ProvenDustCoinMismatch + crate::error::SignerError::CoinOps(crate::error::CoinOpsError::ProvenDustCoinMismatch) )); } diff --git a/greenfloor-engine/tests/config/program.rs b/greenfloor-engine/tests/config/program.rs index f7bf53d6..1fe1641e 100644 --- a/greenfloor-engine/tests/config/program.rs +++ b/greenfloor-engine/tests/config/program.rs @@ -2,7 +2,7 @@ use greenfloor_engine::config::{ is_signer_execution_soft_skip, parse_program_config, signer_execution_skip_reason, CycleProgramConfig, SIGNER_SKIP_NO_SIGNER_PATH, }; -use greenfloor_engine::error::SignerError; +use greenfloor_engine::error::{ConfigError, SignerError}; use serde_json::{json, Value}; use super::shared::base_program_raw; @@ -362,7 +362,10 @@ fn signer_execution_skip_reason_maps_missing_signer_path() { let err = cfg .require_signer_offer_path() .expect_err("missing signer path"); - assert!(matches!(err, SignerError::SignerPathNotConfigured)); + assert!(matches!( + err, + SignerError::Config(ConfigError::SignerPathNotConfigured) + )); assert_eq!( signer_execution_skip_reason(&err), SIGNER_SKIP_NO_SIGNER_PATH @@ -372,7 +375,7 @@ fn signer_execution_skip_reason_maps_missing_signer_path() { #[test] fn signer_execution_skip_reason_maps_missing_signer_section() { - let err = SignerError::MissingConfigField("signer"); + let err = SignerError::Config(ConfigError::MissingField("signer")); assert_eq!( signer_execution_skip_reason(&err), "skipped_missing_signer_config" From 6a5f4d8ea8d3fd76677a3fd5fba0abdd6090409c Mon Sep 17 00:00:00 2001 From: Gene Hoffman Date: Thu, 13 Aug 2026 14:20:40 -0700 Subject: [PATCH 3/7] Keep Dexie publish typed and skip mixed-denomination combines. HTTP status and visibility stay SignerError until the operator JSON edge, get_offer has one 404 mapping, and managed combine no longer covers a target with unrelated coin sizes. --- config/program.yaml | 1 + docs/runbook.md | 4 +- greenfloor-engine/src/adapters/splash/mod.rs | 17 +-- .../src/coin_ops/execution/managed/combine.rs | 15 +-- .../src/coin_ops/execution/managed/tests.rs | 19 ++++ .../input_selection/combine_inputs.rs | 25 ----- .../src/coin_ops/input_selection/mod.rs | 2 +- .../src/coin_ops/input_selection/tests.rs | 13 --- greenfloor-engine/src/coin_ops/mod.rs | 4 +- greenfloor-engine/src/coin_ops/selection.rs | 22 ---- greenfloor-engine/src/daemon/stale_sweep.rs | 2 +- greenfloor-engine/src/error/offer.rs | 12 ++ .../lifecycle/market_reconcile/augment.rs | 10 +- .../lifecycle/reconcile_prep/dexie_fetch.rs | 43 +++++--- .../lifecycle/reconcile_prep/watch_plan.rs | 2 +- .../src/offer/lifecycle/signal_apply.rs | 5 +- .../offer/operator/build_and_post/publish.rs | 25 +++-- .../offer/operator/build_and_post/types.rs | 8 ++ .../src/offer/publish/dexie/mod.rs | 103 +++++------------- .../src/offer/publish/dexie/tests.rs | 44 +++----- 20 files changed, 136 insertions(+), 240 deletions(-) diff --git a/config/program.yaml b/config/program.yaml index 266b31cd..af939419 100644 --- a/config/program.yaml +++ b/config/program.yaml @@ -53,6 +53,7 @@ coin_ops: minimum_fee_mojos: 10000000 split_fee_mojos: 0 combine_fee_mojos: 0 + combine_input_coin_cap: 5 venues: dexie: diff --git a/docs/runbook.md b/docs/runbook.md index c33ea5a1..3f428b4a 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -267,8 +267,8 @@ Monitor `audit_event` records in `~/.greenfloor/db/greenfloor.sqlite`: - `GREENFLOOR_COINSET_BASE_URL` - Default behavior: mainnet endpoint when unset; testnet11 endpoint when market/network is `testnet11`. - For `testnet11`, do not route to mainnet Coinset endpoint unless you explicitly set `GREENFLOOR_ALLOW_MAINNET_COINSET_FOR_TESTNET11=1` for temporary debugging. -- Coin combine input cap (manager/daemon coin-op execution): - - `GREENFLOOR_COIN_OPS_COMBINE_INPUT_COIN_CAP` (default: `5`, min `2`) +- Coin combine input cap (manager/daemon coin-op execution; `~/.greenfloor/config/program.yaml` -> `coin_ops`): + - `combine_input_coin_cap` (default: `5`, min `2`) - Daemon tx-signal ingestion controls (`~/.greenfloor/config/program.yaml` -> `chain_signals.tx_block_trigger`): - `mode`: must be `websocket` - `websocket_url`: Coinset websocket endpoint (defaults by network when blank) diff --git a/greenfloor-engine/src/adapters/splash/mod.rs b/greenfloor-engine/src/adapters/splash/mod.rs index d3a09563..fb19d380 100644 --- a/greenfloor-engine/src/adapters/splash/mod.rs +++ b/greenfloor-engine/src/adapters/splash/mod.rs @@ -3,7 +3,7 @@ mod response; use serde_json::json; use crate::adapters::http_json::{self, AdapterResponseTags}; -use crate::error::{SignerError, SignerResult, TransportError}; +use crate::error::SignerResult; pub use response::SplashResponse; @@ -38,7 +38,7 @@ impl SplashClient { /// /// Returns an error if the operation fails. pub async fn post_offer(&self, offer: &str) -> SignerResult { - match http_json::post_json( + http_json::post_json( &self.http, &self.base_url, json!({"offer": offer}), @@ -47,17 +47,6 @@ impl SplashClient { RESPONSE_TAGS, ) .await - { - Ok(value) => Ok(SplashResponse::from_value(value)), - Err(err) => match &err { - SignerError::Transport(TransportError::HttpStatus { .. }) => { - Ok(SplashResponse::from_value(json!({ - "success": false, - "error": err.to_string(), - }))) - } - _ => Err(err), - }, - } + .map(SplashResponse::from_value) } } diff --git a/greenfloor-engine/src/coin_ops/execution/managed/combine.rs b/greenfloor-engine/src/coin_ops/execution/managed/combine.rs index b7cf29f6..e698677e 100644 --- a/greenfloor-engine/src/coin_ops/execution/managed/combine.rs +++ b/greenfloor-engine/src/coin_ops/execution/managed/combine.rs @@ -1,4 +1,3 @@ -use crate::coin_ops::shape::{plan_combine_inputs_for_target, ShapeCoin}; use crate::coin_ops::{i64_to_usize, plan_exact_amount_combine_inputs, CoinOpPlan, SpendableCoin}; use super::items::{ @@ -43,25 +42,13 @@ async fn prepare_managed_combine_inputs( i64_to_usize(capped_number_of_coins, "combine.capped_op_count"), )?; - let mut combine_input_coin_ids = plan_exact_amount_combine_inputs( + let combine_input_coin_ids = plan_exact_amount_combine_inputs( &spendable, requested_count, target_coin_amount_mojos, None, Some(capped_count), ); - if combine_input_coin_ids.len() < 2 { - let shape_coins: Vec = spendable - .iter() - .map(|coin| ShapeCoin::new(coin.id.clone(), coin.amount)) - .collect(); - let cover = target_coin_amount_mojos.saturating_mul(2); - if let Some(planned) = - plan_combine_inputs_for_target(&shape_coins, cover, capped_number_of_coins) - { - combine_input_coin_ids = planned.input_coin_ids; - } - } if combine_input_coin_ids.len() < 2 { return Err(plan_skip(plan, "no_spendable_combine_coin_available")); } diff --git a/greenfloor-engine/src/coin_ops/execution/managed/tests.rs b/greenfloor-engine/src/coin_ops/execution/managed/tests.rs index 38295740..4e74480e 100644 --- a/greenfloor-engine/src/coin_ops/execution/managed/tests.rs +++ b/greenfloor-engine/src/coin_ops/execution/managed/tests.rs @@ -525,3 +525,22 @@ async fn execute_managed_combine_plan_skips_when_insufficient_inputs() { assert_eq!(items.len(), 1); assert_eq!(items[0].reason, "no_spendable_combine_coin_available"); } + +#[tokio::test] +async fn execute_managed_combine_plan_skips_mixed_denomination_cover() { + let ctx = test_exec_context( + sample_market("xch1test"), + vec![ + SpendableCoin::new(test_coin_id('a'), 8_000), + SpendableCoin::new(test_coin_id('b'), 15_000), + ], + Some("must-not-combine"), + ); + let plan = sample_plan(CoinOpKind::Combine); + + let (items, executed) = Box::pin(execute_managed_combine_plan(&ctx, &plan)).await; + + assert_eq!(executed, 0); + assert_eq!(items.len(), 1); + assert_eq!(items[0].reason, "no_spendable_combine_coin_available"); +} diff --git a/greenfloor-engine/src/coin_ops/input_selection/combine_inputs.rs b/greenfloor-engine/src/coin_ops/input_selection/combine_inputs.rs index 1b46fd3c..dcbb25eb 100644 --- a/greenfloor-engine/src/coin_ops/input_selection/combine_inputs.rs +++ b/greenfloor-engine/src/coin_ops/input_selection/combine_inputs.rs @@ -30,28 +30,3 @@ pub fn plan_exact_amount_combine_inputs( Some(capped_count(number_of_coins, max_count)), ) } - -/// Select the largest spendable combine inputs. -#[must_use] -pub fn plan_largest_combine_inputs( - spendable_coins: &[SpendableCoin], - number_of_coins: usize, - exclude_coin_ids: Option<&HashSet>, - max_count: Option, -) -> Vec { - let excluded = normalized_exclude_ids(exclude_coin_ids); - let cap = capped_count(number_of_coins, max_count); - let mut eligible: Vec<&SpendableCoin> = spendable_coins - .iter() - .filter(|coin| { - !coin.id.is_empty() - && !crate::coin_ops::selection::coin_id_is_excluded(&coin.id, &excluded) - }) - .collect(); - eligible.sort_by_key(|coin| std::cmp::Reverse(coin.amount)); - eligible - .iter() - .take(cap) - .map(|coin| coin.id.clone()) - .collect() -} diff --git a/greenfloor-engine/src/coin_ops/input_selection/mod.rs b/greenfloor-engine/src/coin_ops/input_selection/mod.rs index c38fad4b..07f9df35 100644 --- a/greenfloor-engine/src/coin_ops/input_selection/mod.rs +++ b/greenfloor-engine/src/coin_ops/input_selection/mod.rs @@ -13,7 +13,7 @@ pub use auto_split::{ plan_cli_auto_split_selection, plan_daemon_auto_split_selection, plan_daemon_low_watermark_split, }; -pub use combine_inputs::{plan_exact_amount_combine_inputs, plan_largest_combine_inputs}; +pub use combine_inputs::plan_exact_amount_combine_inputs; #[cfg(test)] use combine_prereq_plan::build_combine_prereq_plan; pub use types::{ diff --git a/greenfloor-engine/src/coin_ops/input_selection/tests.rs b/greenfloor-engine/src/coin_ops/input_selection/tests.rs index 66f6c404..5a64272d 100644 --- a/greenfloor-engine/src/coin_ops/input_selection/tests.rs +++ b/greenfloor-engine/src/coin_ops/input_selection/tests.rs @@ -125,19 +125,6 @@ fn combine_exact_amount_normalizes_exclude_ids() { assert_eq!(ids, vec!["coin_b", "coin_c"]); } -#[test] -fn combine_largest_by_amount_picks_top_coins_respecting_exclude() { - let spendable = coins(&[ - ("small", 100), - ("medium", 500), - ("big", 1500), - ("excluded", 2000), - ]); - let excluded = HashSet::from(["EXCLUDED".to_string()]); - let ids = plan_largest_combine_inputs(&spendable, 2, Some(&excluded), None); - assert_eq!(ids, vec!["big", "medium"]); -} - #[test] fn daemon_auto_skips_when_only_funding_coin_would_create_sub_cat_change() { // Dust filter runs inside resolve_shape_funding, so a sole 10_500→10_000 CAT funder diff --git a/greenfloor-engine/src/coin_ops/mod.rs b/greenfloor-engine/src/coin_ops/mod.rs index 6a60e272..b4903c43 100644 --- a/greenfloor-engine/src/coin_ops/mod.rs +++ b/greenfloor-engine/src/coin_ops/mod.rs @@ -40,8 +40,8 @@ pub use gate::{ }; pub use input_selection::{ plan_cli_auto_split_selection, plan_daemon_auto_split_selection, - plan_daemon_low_watermark_split, plan_exact_amount_combine_inputs, plan_largest_combine_inputs, - CliSplitSelection, DaemonAutoSplitParams, SplitAutoSelectPlan, SplitCoinPlan, SplitSkipReason, + plan_daemon_low_watermark_split, plan_exact_amount_combine_inputs, CliSplitSelection, + DaemonAutoSplitParams, SplitAutoSelectPlan, SplitCoinPlan, SplitSkipReason, SubCatChangeSkipData, }; pub use inventory::compute_bucket_counts_from_coins; diff --git a/greenfloor-engine/src/coin_ops/selection.rs b/greenfloor-engine/src/coin_ops/selection.rs index 86d2eacd..2e66eab5 100644 --- a/greenfloor-engine/src/coin_ops/selection.rs +++ b/greenfloor-engine/src/coin_ops/selection.rs @@ -139,8 +139,6 @@ pub fn select_exact_amount_coin_ids( pub enum FundingSelectionMode { /// Accumulate smallest coins until the running total covers `target_amount`. SmallestFirst, - /// Subset-sum cover of `target_amount` (shape / bootstrap combine-first). - TargetCover, /// Pick coins whose amount equals `target_amount` (exact-denomination combine). ExactDenom, /// Use every listed coin in input order (explicit coin-id sets). @@ -162,18 +160,6 @@ pub fn select_funding_coin_ids( FundingSelectionMode::ExactDenom => { select_exact_amount_coin_ids(coins, target_amount, &excluded, cap) } - FundingSelectionMode::TargetCover => { - let options = TargetAmountSelectionOptions { - max_input_count: cap, - ..TargetAmountSelectionOptions::default() - }; - let (ids, _, _) = select_spendable_coins_for_target_amount_with_options( - coins, - target_amount, - options, - ); - ids - } FundingSelectionMode::SmallestFirst => { select_smallest_first_spendable_ids(coins, target_amount, &excluded, cap) } @@ -603,14 +589,6 @@ mod tests { let smallest = select_funding_coin_ids(FundingSelectionMode::SmallestFirst, &list, 1000, None, None); assert_eq!(smallest, vec!["exact"]); - let cover = select_funding_coin_ids( - FundingSelectionMode::TargetCover, - &list, - 2600, - None, - Some(3), - ); - assert!(!cover.is_empty()); let listed = select_funding_coin_ids(FundingSelectionMode::AllListed, &list, 2600, None, None); assert_eq!(listed, vec!["tiny", "exact", "big"]); diff --git a/greenfloor-engine/src/daemon/stale_sweep.rs b/greenfloor-engine/src/daemon/stale_sweep.rs index 0459c1ac..4131fa87 100644 --- a/greenfloor-engine/src/daemon/stale_sweep.rs +++ b/greenfloor-engine/src/daemon/stale_sweep.rs @@ -76,7 +76,7 @@ pub async fn detect_stale_open_offers_for_requeue( reason: reason.to_string(), }) } - Ok(DexieOfferFetch::Missing(_)) => Some(StaleSweepHit { + Ok(DexieOfferFetch::Missing) => Some(StaleSweepHit { market_id: market_id.clone(), offer_id: offer_id.clone(), reason: "offer_missing_404".to_string(), diff --git a/greenfloor-engine/src/error/offer.rs b/greenfloor-engine/src/error/offer.rs index dfa5fa6d..b5c2a3cc 100644 --- a/greenfloor-engine/src/error/offer.rs +++ b/greenfloor-engine/src/error/offer.rs @@ -47,6 +47,18 @@ pub enum OfferError { #[error("offer_cancel_offer_file_missing")] OfferCancelOfferFileMissing, + #[error("dexie_offer_not_visible_after_publish")] + DexieOfferNotVisible, + + #[error("dexie_offer_visibility_payload_mismatch")] + DexieOfferVisibilityMismatch, + + #[error("dexie_offer_missing_id_after_publish")] + DexieOfferMissingIdAfterPublish, + + #[error("{0}")] + DexieOfferAssetMismatch(String), + #[error("offer_cancel_no_spendable_input")] OfferCancelNoSpendableInput, diff --git a/greenfloor-engine/src/offer/lifecycle/market_reconcile/augment.rs b/greenfloor-engine/src/offer/lifecycle/market_reconcile/augment.rs index 78f9f097..0856c6da 100644 --- a/greenfloor-engine/src/offer/lifecycle/market_reconcile/augment.rs +++ b/greenfloor-engine/src/offer/lifecycle/market_reconcile/augment.rs @@ -91,13 +91,9 @@ async fn fetch_missing_watched_offers( Ok(DexieOfferFetch::Found(body)) => { augmented_by_local_id.insert(watched_offer_id.clone(), body); } - Ok(DexieOfferFetch::Missing(error_text)) => { - let transition = reconciler.apply_missing( - market_id, - watched_offer_id, - current_state, - Some(&error_text), - )?; + Ok(DexieOfferFetch::Missing) => { + let transition = + reconciler.apply_missing(market_id, watched_offer_id, current_state, None)?; note_reconcile_transition_side_effects( &transition, watched_offer_id, diff --git a/greenfloor-engine/src/offer/lifecycle/reconcile_prep/dexie_fetch.rs b/greenfloor-engine/src/offer/lifecycle/reconcile_prep/dexie_fetch.rs index 76aaa95a..db417b65 100644 --- a/greenfloor-engine/src/offer/lifecycle/reconcile_prep/dexie_fetch.rs +++ b/greenfloor-engine/src/offer/lifecycle/reconcile_prep/dexie_fetch.rs @@ -12,11 +12,22 @@ pub enum DexieOfferFetch { /// Nested `offer` object whose lookup keys match the requested local id. Found(serde_json::Value), /// Dexie reported the offer missing (HTTP 404 or explicit `success: false`). - Missing(String), + Missing, /// Response succeeded but did not match the requested local offer id. Mismatch, } +async fn get_dexie_offer( + dexie: &DexieClient, + offer_id: &str, +) -> SignerResult> { + match dexie.get_offer(offer_id).await { + Ok(response) => Ok(Some(response)), + Err(err) if err.is_http_not_found() => Ok(None), + Err(err) => Err(err), + } +} + fn parse_dexie_get_offer_response(offer_id: &str, response: &DexieResponse) -> DexieOfferFetch { if let Some(single) = response.offer_payload() { if offer_matches_local_id(single, offer_id) { @@ -24,7 +35,7 @@ fn parse_dexie_get_offer_response(offer_id: &str, response: &DexieResponse) -> D } } if response.is_explicit_failure() { - return DexieOfferFetch::Missing(response.error_text().to_string()); + return DexieOfferFetch::Missing; } DexieOfferFetch::Mismatch } @@ -36,11 +47,10 @@ pub async fn fetch_dexie_offer( dexie: &DexieClient, offer_id: &str, ) -> SignerResult { - match dexie.get_offer(offer_id).await { - Ok(response) => Ok(parse_dexie_get_offer_response(offer_id, &response)), - Err(err) if err.is_http_not_found() => Ok(DexieOfferFetch::Missing(err.to_string())), - Err(err) => Err(err), - } + Ok(match get_dexie_offer(dexie, offer_id).await? { + Some(response) => parse_dexie_get_offer_response(offer_id, &response), + None => DexieOfferFetch::Missing, + }) } /// Fetch Dexie offer-file text for cancel fallback. @@ -50,17 +60,16 @@ pub async fn fetch_dexie_offer_file_text( dexie: &DexieClient, offer_id: &str, ) -> SignerResult { - let response = match dexie.get_offer(offer_id).await { - Ok(response) => response, - Err(err) if err.is_http_not_found() => { - return Err(SignerError::Offer(OfferError::OfferCancelOfferFileNotFound)); - } - Err(err) => return Err(err), + let Some(response) = get_dexie_offer(dexie, offer_id).await? else { + return Err(SignerError::Offer(OfferError::OfferCancelOfferFileNotFound)); }; - if response.is_explicit_failure() { + if matches!( + parse_dexie_get_offer_response(offer_id, &response), + DexieOfferFetch::Missing + ) { return Err(SignerError::Offer(OfferError::OfferCancelOfferFileNotFound)); } - if let Some(text) = DexieOfferPayload::new(response.body().clone()).offer_file_text() { + if let Some(text) = DexieOfferPayload::new(response.into_value()).offer_file_text() { return Ok(text.to_string()); } Err(SignerError::Offer(OfferError::OfferCancelOfferFileMissing)) @@ -80,7 +89,7 @@ mod tests { })); assert_eq!( parse_dexie_get_offer_response("ab", &response), - DexieOfferFetch::Missing("HTTP Error 404: Not Found".into()) + DexieOfferFetch::Missing ); } @@ -125,7 +134,7 @@ mod tests { .create(); let dexie = DexieClient::new(server.url()); let fetch = fetch_dexie_offer(&dexie, "missing").await.expect("fetch"); - assert!(matches!(fetch, DexieOfferFetch::Missing(_))); + assert_eq!(fetch, DexieOfferFetch::Missing); } #[tokio::test] diff --git a/greenfloor-engine/src/offer/lifecycle/reconcile_prep/watch_plan.rs b/greenfloor-engine/src/offer/lifecycle/reconcile_prep/watch_plan.rs index db311ec0..e05949d7 100644 --- a/greenfloor-engine/src/offer/lifecycle/reconcile_prep/watch_plan.rs +++ b/greenfloor-engine/src/offer/lifecycle/reconcile_prep/watch_plan.rs @@ -176,7 +176,7 @@ async fn fetch_dexie_offer_body_for_heal( ) -> SignerResult> { match fetch_dexie_offer(dexie, offer_id).await { Ok(DexieOfferFetch::Found(body)) => Ok(Some(body)), - Ok(DexieOfferFetch::Missing(_) | DexieOfferFetch::Mismatch) => Ok(None), + Ok(DexieOfferFetch::Missing | DexieOfferFetch::Mismatch) => Ok(None), Err(err) => { on_lookup_error(market_id, offer_id, &err.to_string())?; Ok(None) diff --git a/greenfloor-engine/src/offer/lifecycle/signal_apply.rs b/greenfloor-engine/src/offer/lifecycle/signal_apply.rs index c3876232..f84d5849 100644 --- a/greenfloor-engine/src/offer/lifecycle/signal_apply.rs +++ b/greenfloor-engine/src/offer/lifecycle/signal_apply.rs @@ -212,9 +212,8 @@ impl<'a> WatchedOfferReconciler<'a> { Ok(DexieOfferFetch::Found(offer_body)) => { self.apply_dexie_payload(market_id, offer_id, current_state, &offer_body, env) } - Ok(DexieOfferFetch::Missing(error_text)) => { - let transition = - self.apply_missing(market_id, offer_id, current_state, Some(&error_text))?; + Ok(DexieOfferFetch::Missing) => { + let transition = self.apply_missing(market_id, offer_id, current_state, None)?; Ok((transition, None)) } Ok(DexieOfferFetch::Mismatch) => self.persist_lookup_unchanged( diff --git a/greenfloor-engine/src/offer/operator/build_and_post/publish.rs b/greenfloor-engine/src/offer/operator/build_and_post/publish.rs index 474d9da5..50703317 100644 --- a/greenfloor-engine/src/offer/operator/build_and_post/publish.rs +++ b/greenfloor-engine/src/offer/operator/build_and_post/publish.rs @@ -41,23 +41,26 @@ pub(super) async fn publish_offer( let dexie = dexie.ok_or_else(|| { SignerError::Other("dexie adapter missing for dexie publish".to_string()) })?; - Ok(PublishResult::from_dexie_response( - post_offer_phase_dexie(PostOfferPhaseDexieParams { - dexie, - offer_text, - drop_only, - claim_rewards, - expected, - }) - .await?, + Ok(post_offer_phase_dexie(PostOfferPhaseDexieParams { + dexie, + offer_text, + drop_only, + claim_rewards, + expected, + }) + .await + .map_or_else( + |err| PublishResult::from_error(&err), + PublishResult::from_dexie_response, )) } crate::config::Venue::Splash => { let splash = splash.ok_or_else(|| { SignerError::Other("splash adapter missing for splash publish".to_string()) })?; - Ok(PublishResult::from_splash_response( - splash.post_offer(offer_text).await?, + Ok(splash.post_offer(offer_text).await.map_or_else( + |err| PublishResult::from_error(&err), + PublishResult::from_splash_response, )) } } diff --git a/greenfloor-engine/src/offer/operator/build_and_post/types.rs b/greenfloor-engine/src/offer/operator/build_and_post/types.rs index 0c29dd3c..eb809243 100644 --- a/greenfloor-engine/src/offer/operator/build_and_post/types.rs +++ b/greenfloor-engine/src/offer/operator/build_and_post/types.rs @@ -94,6 +94,14 @@ impl PublishResult { } } + pub fn from_error(err: &crate::error::SignerError) -> Self { + Self { + success: false, + offer_id: None, + body: json!({"success": false, "error": err.to_string()}), + } + } + /// Map Coinset `push_offer` JSON into a publish result. /// /// Canonical `offer_id` must be a 64-hex trade id (Dexie `trade_id`), matching diff --git a/greenfloor-engine/src/offer/publish/dexie/mod.rs b/greenfloor-engine/src/offer/publish/dexie/mod.rs index f3eae40d..7bdeee2d 100644 --- a/greenfloor-engine/src/offer/publish/dexie/mod.rs +++ b/greenfloor-engine/src/offer/publish/dexie/mod.rs @@ -1,8 +1,6 @@ -use serde_json::json; - use crate::adapters::{DexieClient, DexieResponse}; use crate::cycle::{dexie_invalid_offer_retry_sleep, dexie_invalid_offer_should_retry}; -use crate::error::{SignerError, SignerResult, TransportError}; +use crate::error::{OfferError, SignerResult}; use crate::offer::lifecycle::reconcile_prep::{fetch_dexie_offer, DexieOfferFetch}; use super::{dexie_offer_asset_expectation_error, ExpectedPublishAssetFields}; @@ -23,12 +21,9 @@ pub struct PostOfferPhaseDexieParams<'a> { pub expected: &'a ExpectedPublishAssetFields, } -#[derive(Debug)] -enum OfferVisibilityWait { +enum DexiePostVisibility { Visible, - Failed(String), - Missing(String), - Unresolved(String), + Missing, } async fn sleep_for_publish(seconds: f64) { @@ -43,40 +38,6 @@ async fn sleep_for_publish(seconds: f64) { } } -fn dexie_publish_failure(response: DexieResponse, error: impl Into) -> DexieResponse { - let error = error.into(); - let offer_id = response.offer_id().map(str::to_string); - let mut body = response.into_value(); - match &mut body { - serde_json::Value::Object(obj) => { - obj.insert("success".to_string(), serde_json::Value::Bool(false)); - obj.insert("error".to_string(), serde_json::Value::String(error)); - } - _ => { - body = json!({ - "success": false, - "error": error, - "id": offer_id, - }); - } - } - DexieResponse::from_value(body) -} - -fn failed_dexie_from_http_status(err: SignerError) -> SignerResult { - if matches!( - &err, - SignerError::Transport(TransportError::HttpStatus { .. }) - ) { - Ok(DexieResponse::from_value(json!({ - "success": false, - "error": err.to_string(), - }))) - } else { - Err(err) - } -} - async fn post_dexie_offer_with_invalid_offer_retry( dexie: &DexieClient, offer_text: &str, @@ -101,7 +62,7 @@ async fn post_dexie_offer_with_invalid_offer_retry( sleep_for_publish(sleep_seconds).await; attempt += 1; } - Err(err) => return failed_dexie_from_http_status(err), + Err(err) => return Err(err), } } } @@ -110,13 +71,13 @@ async fn wait_for_dexie_offer_visible( dexie: &DexieClient, offer_id: &str, expected: &ExpectedPublishAssetFields, -) -> OfferVisibilityWait { +) -> SignerResult { let clean_offer_id = offer_id.trim(); if clean_offer_id.is_empty() { - return OfferVisibilityWait::Unresolved("dexie_offer_missing_id_after_publish".to_string()); + return Err(OfferError::DexieOfferMissingIdAfterPublish.into()); } - let mut last: Result = - Err("dexie_offer_not_visible_after_publish".to_string()); + let mut last = DexieOfferFetch::Mismatch; + let mut last_err = None; for attempt in 1..=DEXIE_VISIBILITY_POLL_ATTEMPTS { match fetch_dexie_offer(dexie, clean_offer_id).await { Ok(DexieOfferFetch::Found(offer_obj)) => { @@ -127,23 +88,28 @@ async fn wait_for_dexie_offer_visible( .unwrap_or(&serde_json::Value::Null), expected, ) { - return OfferVisibilityWait::Failed(asset_error); + return Err(OfferError::DexieOfferAssetMismatch(asset_error).into()); } - return OfferVisibilityWait::Visible; + return Ok(DexiePostVisibility::Visible); } - Ok(fetch) => last = Ok(fetch), - Err(err) => last = Err(format!("dexie_get_offer_error:{err}")), + Ok(fetch) => { + last = fetch; + last_err = None; + } + Err(err) => last_err = Some(err), } if attempt < DEXIE_VISIBILITY_POLL_ATTEMPTS { sleep_for_publish(DEXIE_VISIBILITY_POLL_DELAY_SECONDS).await; } } + if let Some(err) = last_err { + return Err(err); + } match last { - Ok(DexieOfferFetch::Missing(error)) => OfferVisibilityWait::Missing(error), - Ok(_) => { - OfferVisibilityWait::Unresolved("dexie_offer_visibility_payload_mismatch".to_string()) + DexieOfferFetch::Missing => Ok(DexiePostVisibility::Missing), + DexieOfferFetch::Found(_) | DexieOfferFetch::Mismatch => { + Err(OfferError::DexieOfferVisibilityMismatch.into()) } - Err(error) => OfferVisibilityWait::Unresolved(error), } } @@ -151,7 +117,7 @@ async fn wait_for_dexie_offer_visible( /// /// # Errors /// -/// Returns an error if the operation fails. +/// Returns an error if the post, visibility poll, or asset check fails. pub async fn post_offer_phase_dexie( params: PostOfferPhaseDexieParams<'_>, ) -> SignerResult { @@ -162,41 +128,24 @@ pub async fn post_offer_phase_dexie( claim_rewards, expected, } = params; - let mut last_result = DexieResponse::from_value(json!({ - "success": false, - "error": "dexie_offer_not_visible_after_publish", - })); - let mut last_missing_error = String::new(); for attempt in 1..=DEXIE_VISIBILITY_REPOST_MAX_ATTEMPTS { let result = post_dexie_offer_with_invalid_offer_retry(dexie, offer_text, drop_only, claim_rewards) .await?; - last_result = result.clone(); if !result.success() { return Ok(result); } let posted_offer_id = result.offer_id().unwrap_or("").to_string(); - match wait_for_dexie_offer_visible(dexie, &posted_offer_id, expected).await { - OfferVisibilityWait::Visible => return Ok(result), - OfferVisibilityWait::Failed(error) | OfferVisibilityWait::Unresolved(error) => { - return Ok(dexie_publish_failure(result, error)); - } - OfferVisibilityWait::Missing(error) => { - last_missing_error = error; + match wait_for_dexie_offer_visible(dexie, &posted_offer_id, expected).await? { + DexiePostVisibility::Visible => return Ok(result), + DexiePostVisibility::Missing => { if attempt < DEXIE_VISIBILITY_REPOST_MAX_ATTEMPTS { sleep_for_publish(DEXIE_VISIBILITY_REPOST_DELAY_SECONDS).await; } } } } - Ok(dexie_publish_failure( - last_result, - if last_missing_error.is_empty() { - "dexie_offer_not_visible_after_publish".to_string() - } else { - last_missing_error - }, - )) + Err(OfferError::DexieOfferNotVisible.into()) } #[cfg(test)] diff --git a/greenfloor-engine/src/offer/publish/dexie/tests.rs b/greenfloor-engine/src/offer/publish/dexie/tests.rs index 016bcf0b..a771046f 100644 --- a/greenfloor-engine/src/offer/publish/dexie/tests.rs +++ b/greenfloor-engine/src/offer/publish/dexie/tests.rs @@ -3,6 +3,7 @@ use serde_json::json; use super::{post_offer_phase_dexie, PostOfferPhaseDexieParams}; use crate::adapters::DexieClient; +use crate::error::{OfferError, SignerError}; use crate::offer::publish::{ExpectedPublishAssetFields, PublishAssetSide}; fn expected_fields() -> ExpectedPublishAssetFields { @@ -136,7 +137,7 @@ async fn post_offer_phase_fails_on_asset_mismatch() { let dexie = DexieClient::new(server.url()); let expected = expected_fields(); - let result = post_offer_phase_dexie(PostOfferPhaseDexieParams { + let err = post_offer_phase_dexie(PostOfferPhaseDexieParams { dexie: &dexie, offer_text: "offer1test", drop_only: true, @@ -144,12 +145,12 @@ async fn post_offer_phase_fails_on_asset_mismatch() { expected: &expected, }) .await - .expect("post"); - assert!(!result.success()); - assert!(result - .error_text() - .starts_with("dexie_offer_offered_asset_missing:")); - assert_eq!(result.offer_id(), Some(offer_id)); + .expect_err("asset mismatch"); + assert!(matches!( + err, + SignerError::Offer(OfferError::DexieOfferAssetMismatch(ref detail)) + if detail.starts_with("dexie_offer_offered_asset_missing:") + )); } #[tokio::test] @@ -231,7 +232,7 @@ async fn post_offer_phase_mismatch_does_not_repost() { let dexie = DexieClient::new(server.url()); let expected = expected_fields(); - let result = post_offer_phase_dexie(PostOfferPhaseDexieParams { + let err = post_offer_phase_dexie(PostOfferPhaseDexieParams { dexie: &dexie, offer_text: "offer1test", drop_only: true, @@ -239,26 +240,9 @@ async fn post_offer_phase_mismatch_does_not_repost() { expected: &expected, }) .await - .expect("post"); - assert!(!result.success()); - assert_eq!( - result.error_text(), - "dexie_offer_visibility_payload_mismatch" - ); -} - -#[test] -fn dexie_publish_failure_overwrites_success_and_error() { - use crate::adapters::DexieResponse; - - let failed = super::dexie_publish_failure( - DexieResponse::from_value(json!({"success": true, "id": "offer-1"})), - "dexie_offer_offered_asset_missing:expected_asset=cat:expected_symbol=cat", - ); - assert!(!failed.success()); - assert_eq!( - failed.error_text(), - "dexie_offer_offered_asset_missing:expected_asset=cat:expected_symbol=cat" - ); - assert_eq!(failed.offer_id(), Some("offer-1")); + .expect_err("mismatch"); + assert!(matches!( + err, + SignerError::Offer(OfferError::DexieOfferVisibilityMismatch) + )); } From 8161963afd723d61ddbfa1181c713234ad7ad9da Mon Sep 17 00:00:00 2001 From: Gene Hoffman Date: Thu, 13 Aug 2026 14:34:56 -0700 Subject: [PATCH 4/7] Map Coinset reqwest failures through typed transport instead of scraping strings. Retryability is a variant match, Coinset(String) is RPC success=false only, and mixed-split unspendable coins map at the driver boundary. --- greenfloor-engine/src/cli_util.rs | 71 +------------- .../src/coin_ops/execution/context.rs | 3 +- greenfloor-engine/src/coinset/api/rpc.rs | 2 +- greenfloor-engine/src/coinset/api/tests.rs | 17 +++- greenfloor-engine/src/coinset/cats/resolve.rs | 2 +- greenfloor-engine/src/coinset/retry.rs | 11 +-- .../src/coinset_cli/tests/dispatch_tests.rs | 10 +- .../offer_dispatch/tests/classify_tests.rs | 30 ++++++ greenfloor-engine/src/error/mod.rs | 97 +++++++++++++------ greenfloor-engine/src/error/transport.rs | 35 ++++++- .../signer_denomination/split_submit.rs | 3 +- .../src/offer/presplit/orphan_discover.rs | 18 +++- 12 files changed, 179 insertions(+), 120 deletions(-) diff --git a/greenfloor-engine/src/cli_util.rs b/greenfloor-engine/src/cli_util.rs index 5b85c379..3814022b 100644 --- a/greenfloor-engine/src/cli_util.rs +++ b/greenfloor-engine/src/cli_util.rs @@ -3,54 +3,14 @@ use serde::Serialize; use serde_json::{json, Value}; -use crate::error::{SignerError, SignerResult, TransportError}; - -const RETRYABLE_COINSET_TRANSPORT_MARKERS: &[&str] = &[ - "operation timed out", - "connection refused", - "connection reset", - "remote end closed connection", - "error sending request", - "temporary failure", - "temporarily unavailable", - "broken pipe", - "http status server error (502", - "http status server error (503", - "http status server error (504", - "http status client error (429", - "too many requests", - "bad gateway", - "service unavailable", - "error decoding response body", - "ssl", - "handshake", - "cloudflare", -]; - -#[must_use] -pub fn script_coinset_transport_retryable(message: &str) -> bool { - let lower = message.to_ascii_lowercase(); - RETRYABLE_COINSET_TRANSPORT_MARKERS - .iter() - .any(|marker| lower.contains(marker)) -} - -#[must_use] -pub fn script_engine_error_retryable(err: &SignerError) -> bool { - match err { - SignerError::Transport(TransportError::Coinset(message)) => { - script_coinset_transport_retryable(message) - } - _ => false, - } -} +use crate::error::{SignerError, SignerResult}; pub fn emit_engine_cli_error(err: &SignerError, json_mode: bool) { if json_mode { let payload = json!({ "success": false, "error": err.to_string(), - "retryable": script_engine_error_retryable(err), + "retryable": err.is_retryable_upstream(), }); eprintln!( "{}", @@ -146,34 +106,9 @@ pub fn print_json_pretty(value: &impl Serialize) -> SignerResult<()> { #[cfg(test)] mod tests { - use super::{ - format_json, format_json_value, optional_str, optional_trimmed, - script_coinset_transport_retryable, script_engine_error_retryable, - }; - use crate::error::SignerError; + use super::{format_json, format_json_value, optional_str, optional_trimmed}; use serde_json::json; - #[test] - fn script_coinset_transport_retryable_matches_decode_and_refused() { - assert!(script_coinset_transport_retryable( - "error decoding response body" - )); - assert!(script_coinset_transport_retryable( - "error sending request for url (http://127.0.0.1:1/): connection refused" - )); - assert!(!script_coinset_transport_retryable("invalid puzzle hash")); - } - - #[test] - fn script_engine_error_retryable_classifies_coinset_and_parse_errors() { - assert!(script_engine_error_retryable(&SignerError::coinset( - "error decoding response body".to_string() - ))); - assert!(!script_engine_error_retryable(&SignerError::Other( - "parse body json: expected value at line 1 column 1".to_string() - ))); - } - #[test] fn optional_str_trims_and_rejects_blank() { assert_eq!(optional_str(" value "), Some("value")); diff --git a/greenfloor-engine/src/coin_ops/execution/context.rs b/greenfloor-engine/src/coin_ops/execution/context.rs index 596455e3..6667fa41 100644 --- a/greenfloor-engine/src/coin_ops/execution/context.rs +++ b/greenfloor-engine/src/coin_ops/execution/context.rs @@ -195,8 +195,7 @@ impl CoinOpExecContext { request, true, ) - .await - .map_err(SignerError::normalize_mixed_split_error)?; + .await?; spend_bundle_hash_from_hex(&result.spend_bundle_hex) } } diff --git a/greenfloor-engine/src/coinset/api/rpc.rs b/greenfloor-engine/src/coinset/api/rpc.rs index e77699a5..064c48b0 100644 --- a/greenfloor-engine/src/coinset/api/rpc.rs +++ b/greenfloor-engine/src/coinset/api/rpc.rs @@ -52,7 +52,7 @@ async fn post_coinset_rpc_with( client .make_post_request(endpoint, body) .await - .map_err(|err| SignerError::coinset(err.to_string())) + .map_err(|err| SignerError::from_reqwest("coinset", &err)) } /// Script/scan Coinset RPC via the direct API host (`api.coinset.org` defaults). diff --git a/greenfloor-engine/src/coinset/api/tests.rs b/greenfloor-engine/src/coinset/api/tests.rs index dfd3eedd..1e89331b 100644 --- a/greenfloor-engine/src/coinset/api/tests.rs +++ b/greenfloor-engine/src/coinset/api/tests.rs @@ -6,6 +6,7 @@ use super::{ conservative_fee_from_payload, get_all_mempool_tx_ids, get_fee_estimate, post_coinset_coin_records, post_coinset_record, post_coinset_rpc, push_tx_hex, }; +use crate::error::{SignerError, TransportError}; #[test] fn conservative_fee_uses_max_estimate() { @@ -197,7 +198,7 @@ async fn post_coinset_coin_records_fails_on_success_false() { } #[tokio::test] -async fn post_coinset_rpc_surfaces_http_503_as_coinset_error() { +async fn post_coinset_rpc_surfaces_http_503_as_decode_error() { let mut server = mockito::Server::new_async().await; let _mock = server .mock("POST", "/get_blockchain_state") @@ -215,11 +216,21 @@ async fn post_coinset_rpc_surfaces_http_503_as_coinset_error() { .await .expect_err("503 should fail"); let message = err.to_string(); - assert!(message.starts_with("coinset error:"), "{message}"); + assert!( + matches!( + err, + SignerError::Transport(TransportError::Decode { + layer: "coinset", + .. + }) + ), + "{message}" + ); assert_eq!( - message, "coinset error: error decoding response body", + message, "http decode (coinset): error decoding response body", "unexpected coinset 503 error text" ); + assert!(err.is_retryable_upstream()); } #[tokio::test] diff --git a/greenfloor-engine/src/coinset/cats/resolve.rs b/greenfloor-engine/src/coinset/cats/resolve.rs index ecffc40a..1223684c 100644 --- a/greenfloor-engine/src/coinset/cats/resolve.rs +++ b/greenfloor-engine/src/coinset/cats/resolve.rs @@ -24,7 +24,7 @@ fn unparseable_cat_lineage(detail: impl Into) -> SignerError { /// /// # Errors /// -/// Returns [`crate::error::TransportError::Coinset`] on transport/API failure. +/// Returns a transport error on HTTP/RPC failure. pub async fn fetch_parent_coin_spend( client: &CoinsetClient, parent_coin_info: Bytes32, diff --git a/greenfloor-engine/src/coinset/retry.rs b/greenfloor-engine/src/coinset/retry.rs index 6e0b56e6..2da6404d 100644 --- a/greenfloor-engine/src/coinset/retry.rs +++ b/greenfloor-engine/src/coinset/retry.rs @@ -5,7 +5,6 @@ use std::time::Duration; use rand::Rng; -use crate::cli_util::script_engine_error_retryable; use crate::error::{SignerError, SignerResult}; /// Backoff policy for [`with_script_retries`]. @@ -86,7 +85,7 @@ where for attempt in 1..=policy.max_attempts { match operation().await { Ok(value) => return Ok(value), - Err(err) if attempt < policy.max_attempts && script_engine_error_retryable(&err) => { + Err(err) if attempt < policy.max_attempts && err.is_retryable_upstream() => { tokio::time::sleep(retry_sleep_duration(policy, delay)).await; delay = (delay * 2.0).min(8.0); } @@ -129,7 +128,7 @@ where async move { future .await - .map_err(|err| SignerError::coinset(err.to_string())) + .map_err(|err| SignerError::from_reqwest("coinset", &err)) } }) .await @@ -155,9 +154,9 @@ mod tests { attempts += 1; async move { if attempts == 1 { - Err(SignerError::coinset( - "error sending request for url (http://127.0.0.1:1/): connection refused" - .to_string(), + Err(SignerError::http_connect( + "coinset", + "error sending request for url (http://127.0.0.1:1/): connection refused", )) } else { Ok("ok") diff --git a/greenfloor-engine/src/coinset_cli/tests/dispatch_tests.rs b/greenfloor-engine/src/coinset_cli/tests/dispatch_tests.rs index 28cf0da6..8275cf0e 100644 --- a/greenfloor-engine/src/coinset_cli/tests/dispatch_tests.rs +++ b/greenfloor-engine/src/coinset_cli/tests/dispatch_tests.rs @@ -1,4 +1,3 @@ -use crate::cli_util::script_engine_error_retryable; use crate::coinset::{ coin_id_from_record, ensure_coinset_rpc_success, post_coinset_coin_records, post_coinset_record, post_coinset_rpc, push_tx_hex, resolve_direct_client, @@ -100,7 +99,7 @@ async fn coin_records_fails_on_success_false() { .await .expect_err("success=false"); assert_eq!(err.to_string(), "coinset error: invalid puzzle hash"); - assert!(!script_engine_error_retryable(&err)); + assert!(!err.is_retryable_upstream()); } #[tokio::test] @@ -124,9 +123,12 @@ async fn coin_records_surfaces_retryable_error_on_http_503() { .expect_err("503"); assert!(matches!( err, - SignerError::Transport(TransportError::Coinset(_)) + SignerError::Transport(TransportError::Decode { + layer: "coinset", + .. + }) )); - assert!(script_engine_error_retryable(&err)); + assert!(err.is_retryable_upstream()); } #[tokio::test] diff --git a/greenfloor-engine/src/daemon/offer_dispatch/tests/classify_tests.rs b/greenfloor-engine/src/daemon/offer_dispatch/tests/classify_tests.rs index 15025153..16b35242 100644 --- a/greenfloor-engine/src/daemon/offer_dispatch/tests/classify_tests.rs +++ b/greenfloor-engine/src/daemon/offer_dispatch/tests/classify_tests.rs @@ -67,6 +67,11 @@ fn parallel_transient_signer_error_classifies_reservation_and_upstream() { assert!(contention.is_parallel_dispatch_transient()); let upstream = SignerError::http_timeout("dexie", "timed out"); assert!(upstream.is_parallel_dispatch_transient()); + let coinset = SignerError::http_connect( + "coinset", + "error sending request for url (https://api.coinset.org/): connection refused", + ); + assert!(coinset.is_parallel_dispatch_transient()); let locked: SignerError = PersistenceError::DatabaseLocked.into(); assert!(locked.is_parallel_dispatch_transient()); let fatal = SignerError::Other("permanent_offer_build_failure: bad puzzle".to_string()); @@ -96,6 +101,31 @@ fn classify_parallel_dispatch_transient_error_falls_back() { } } +#[test] +fn classify_parallel_dispatch_retryable_coinset_falls_back() { + let err = SignerError::http_connect( + "coinset", + "error sending request for url (https://api.coinset.org/): connection refused", + ); + match classify_parallel_dispatch(Err(err)) { + ParallelDispatchDecision::FallbackTransient(message) => { + assert!(message.to_string().contains("connection refused")); + } + _ => panic!("expected Coinset transport fallback"), + } +} + +#[test] +fn classify_parallel_dispatch_non_retryable_coinset_is_fatal() { + let err = SignerError::coinset("invalid puzzle hash"); + match classify_parallel_dispatch(Err(err)) { + ParallelDispatchDecision::Fatal(message) => { + assert!(message.to_string().contains("invalid puzzle hash")); + } + _ => panic!("expected fatal Coinset error"), + } +} + #[test] fn classify_parallel_dispatch_fatal_error_propagates() { let err = SignerError::Other("permanent_offer_build_failure: bad puzzle".to_string()); diff --git a/greenfloor-engine/src/error/mod.rs b/greenfloor-engine/src/error/mod.rs index d8f47bc3..227360cb 100644 --- a/greenfloor-engine/src/error/mod.rs +++ b/greenfloor-engine/src/error/mod.rs @@ -37,12 +37,8 @@ pub enum SignerError { const MIXED_SPLIT_SELECTED_COINS_NOT_SPENDABLE: &str = "Some selected coins are not spendable"; -fn mixed_split_selected_coins_not_spendable_message(message: &str) -> bool { - message.contains(MIXED_SPLIT_SELECTED_COINS_NOT_SPENDABLE) -} - impl SignerError { - /// Coinset HTTP/RPC transport failure. + /// Coinset RPC application failure (`success: false`). HTTP/transport uses [`Self::from_reqwest`]. #[must_use] pub fn coinset(message: impl Into) -> Self { Self::Transport(TransportError::Coinset(message.into())) @@ -75,6 +71,24 @@ impl SignerError { }) } + /// HTTP decode failure (`reqwest::Error::is_decode`). + #[must_use] + pub fn http_decode(layer: &'static str, message: impl Into) -> Self { + Self::Transport(TransportError::Decode { + layer, + message: message.into(), + }) + } + + /// HTTP request/body failure (`reqwest::Error::is_request` / `is_body`). + #[must_use] + pub fn http_request(layer: &'static str, message: impl Into) -> Self { + Self::Transport(TransportError::Request { + layer, + message: message.into(), + }) + } + #[must_use] pub fn from_reqwest(layer: &'static str, err: &reqwest::Error) -> Self { Self::Transport(TransportError::from_reqwest(layer, err)) @@ -109,21 +123,6 @@ impl SignerError { ) } - #[must_use] - pub fn normalize_mixed_split_error(err: Self) -> Self { - if matches!( - err, - Self::Vault(VaultError::MixedSplitSelectedCoinsNotSpendable) - ) { - return err; - } - if mixed_split_selected_coins_not_spendable_message(&err.to_string()) { - VaultError::MixedSplitSelectedCoinsNotSpendable.into() - } else { - err - } - } - #[must_use] pub fn is_sqlite_fatal(&self) -> bool { match self { @@ -132,6 +131,11 @@ impl SignerError { } } + #[must_use] + pub fn is_retryable_upstream(&self) -> bool { + matches!(self, Self::Transport(err) if err.is_retryable_upstream()) + } + #[must_use] pub fn is_parallel_dispatch_transient(&self) -> bool { if self.is_sqlite_fatal() { @@ -139,7 +143,7 @@ impl SignerError { } match self { Self::Persistence(err) => err.is_parallel_dispatch_transient(), - Self::Transport(err) => err.is_parallel_dispatch_transient(), + Self::Transport(err) => err.is_retryable_upstream(), _ => false, } } @@ -149,6 +153,11 @@ pub type SignerResult = Result; #[must_use] pub fn driver_error(err: &chia_sdk_driver::DriverError) -> SignerError { + if let chia_sdk_driver::DriverError::Custom(message) = err { + if message.contains(MIXED_SPLIT_SELECTED_COINS_NOT_SPENDABLE) { + return VaultError::MixedSplitSelectedCoinsNotSpendable.into(); + } + } SignerError::driver(err.to_string()) } @@ -227,6 +236,14 @@ mod tests { SignerError::http_connect("dexie", "connection refused"), "http connect (dexie): connection refused", ), + ( + SignerError::http_decode("coinset", "error decoding response body"), + "http decode (coinset): error decoding response body", + ), + ( + SignerError::http_request("coinset", "error sending request"), + "http request (coinset): error sending request", + ), ( SignerError::http_status("dexie_http_error", 404, "missing"), "http status 404 (dexie_http_error): missing", @@ -298,11 +315,33 @@ mod tests { } #[test] - fn parallel_dispatch_transient_rejects_non_transient_variants() { + fn parallel_dispatch_transient_classifies_coinset_and_http_status() { assert!(!SignerError::driver("invalid mod hash").is_parallel_dispatch_transient()); assert!(!SignerError::http("dexie", "bad json").is_parallel_dispatch_transient()); assert!(!SignerError::CoinOps(CoinOpsError::InsufficientCatCoins) .is_parallel_dispatch_transient()); + assert!(!SignerError::coinset("connection refused").is_retryable_upstream()); + assert!(!SignerError::coinset("invalid puzzle hash").is_retryable_upstream()); + assert!(SignerError::http_connect("coinset", "connection refused").is_retryable_upstream()); + assert!( + SignerError::http_decode("coinset", "error decoding response body") + .is_retryable_upstream() + ); + assert!( + SignerError::http_request("coinset", "error sending request").is_retryable_upstream() + ); + assert!( + SignerError::http_status("dexie_http_error", 503, "unavailable") + .is_retryable_upstream() + ); + assert!( + !SignerError::http_status("dexie_http_error", 400, "Invalid Offer") + .is_retryable_upstream() + ); + assert!(!SignerError::Other( + "parse body json: expected value at line 1 column 1".to_string() + ) + .is_retryable_upstream()); } #[test] @@ -315,12 +354,6 @@ mod tests { !SignerError::Other("upstream: Some selected coins are not spendable".to_string()) .is_mixed_split_selected_coins_not_spendable() ); - assert!(matches!( - SignerError::normalize_mixed_split_error(SignerError::Other( - "Some selected coins are not spendable".to_string() - )), - SignerError::Vault(VaultError::MixedSplitSelectedCoinsNotSpendable) - )); } #[test] @@ -337,5 +370,13 @@ mod tests { let from_impl: SignerError = DriverError::InvalidModHash.into(); assert_eq!(from_impl.to_string(), mapped.to_string()); + + let unspendable = driver_error(&DriverError::Custom( + "Some selected coins are not spendable".to_string(), + )); + assert!(matches!( + unspendable, + SignerError::Vault(VaultError::MixedSplitSelectedCoinsNotSpendable) + )); } } diff --git a/greenfloor-engine/src/error/transport.rs b/greenfloor-engine/src/error/transport.rs index 6c2ca619..721cda43 100644 --- a/greenfloor-engine/src/error/transport.rs +++ b/greenfloor-engine/src/error/transport.rs @@ -15,6 +15,16 @@ pub enum TransportError { layer: &'static str, message: String, }, + #[error("http decode ({layer}): {message}")] + Decode { + layer: &'static str, + message: String, + }, + #[error("http request ({layer}): {message}")] + Request { + layer: &'static str, + message: String, + }, #[error("http error ({layer}): {message}")] Http { layer: &'static str, @@ -38,6 +48,16 @@ impl TransportError { Self::Timeout { layer, message } } else if err.is_connect() { Self::Connect { layer, message } + } else if let Some(status) = err.status() { + Self::HttpStatus { + layer, + status: status.as_u16(), + message, + } + } else if err.is_decode() { + Self::Decode { layer, message } + } else if err.is_request() || err.is_body() { + Self::Request { layer, message } } else { Self::Http { layer, message } } @@ -48,8 +68,19 @@ impl TransportError { matches!(self, Self::HttpStatus { status: 404, .. }) } + /// True when Coinset/HTTP can be retried or parallel dispatch can fall back. #[must_use] - pub fn is_parallel_dispatch_transient(&self) -> bool { - matches!(self, Self::Timeout { .. } | Self::Connect { .. }) + pub fn is_retryable_upstream(&self) -> bool { + matches!( + self, + Self::Timeout { .. } + | Self::Connect { .. } + | Self::Decode { .. } + | Self::Request { .. } + | Self::HttpStatus { + status: 429 | 502 | 503 | 504, + .. + } + ) } } diff --git a/greenfloor-engine/src/offer/operator/signer_denomination/split_submit.rs b/greenfloor-engine/src/offer/operator/signer_denomination/split_submit.rs index 01ab13a2..9183a9a6 100644 --- a/greenfloor-engine/src/offer/operator/signer_denomination/split_submit.rs +++ b/greenfloor-engine/src/offer/operator/signer_denomination/split_submit.rs @@ -52,8 +52,7 @@ async fn submit_bootstrap_vault_mixed_split( request, true, ) - .await - .map_err(crate::error::SignerError::normalize_mixed_split_error)?; + .await?; Ok(mixed_split_result_json(&result)) } diff --git a/greenfloor-engine/src/offer/presplit/orphan_discover.rs b/greenfloor-engine/src/offer/presplit/orphan_discover.rs index 10235cec..513f33c8 100644 --- a/greenfloor-engine/src/offer/presplit/orphan_discover.rs +++ b/greenfloor-engine/src/offer/presplit/orphan_discover.rs @@ -184,8 +184,11 @@ async fn recover_fixed_hash_for_row( fn soft_fail_detail(err: SignerError) -> SignerResult { match err { - // Transport / API failures should fail the whole discover, not hide as per-row noise. - SignerError::Transport(TransportError::Coinset(_)) => Err(err), + // Driver parse/spend misses stay per-row; HTTP/RPC failures abort discovery. + SignerError::Transport(TransportError::Driver(message)) => { + Ok(format!("driver error: {message}")) + } + err @ SignerError::Transport(_) => Err(err), other => Ok(other.to_string()), } } @@ -198,7 +201,7 @@ fn soft_fail_detail(err: SignerError) -> SignerResult { /// /// # Errors /// -/// Returns [`crate::error::TransportError::Coinset`] when Coinset access fails for a candidate. +/// Returns a transport error when Coinset access fails for a candidate. pub async fn discover_orphan_presplit_candidates( client: &CoinsetClient, launcher_id: Bytes32, @@ -322,6 +325,15 @@ mod tests { err, SignerError::Transport(TransportError::Coinset(_)) )); + let connect = soft_fail_detail(SignerError::http_connect("coinset", "connection refused")) + .expect_err("connect"); + assert!(matches!( + connect, + SignerError::Transport(TransportError::Connect { + layer: "coinset", + .. + }) + )); let detail = soft_fail_detail(SignerError::driver("bad puzzle".to_string())).expect("soft"); assert!(detail.contains("bad puzzle")); } From 9990245499f8f470867820e84232bca6c7a5ccd4 Mon Sep 17 00:00:00 2001 From: Gene Hoffman Date: Thu, 13 Aug 2026 14:55:06 -0700 Subject: [PATCH 5/7] Nest Dexie get_offer test fixtures under the offer object. Reconcile Found requires a nested payload whose id matches, so the flat mocks left offer-ok unchanged and failed CI. --- greenfloor-engine/src/manager_cli/offers/tests.rs | 4 +++- .../src/offer/lifecycle/reconcile_watched_offers.rs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/greenfloor-engine/src/manager_cli/offers/tests.rs b/greenfloor-engine/src/manager_cli/offers/tests.rs index a49caf15..61e196c6 100644 --- a/greenfloor-engine/src/manager_cli/offers/tests.rs +++ b/greenfloor-engine/src/manager_cli/offers/tests.rs @@ -110,7 +110,9 @@ async fn offers_reconcile_updates_states_from_dexie() { let _ok = server .mock("GET", "/v1/offers/offer-ok") .with_status(200) - .with_body(json!({"id":"offer-ok","status":4,"tx_id": confirmed_tx_id}).to_string()) + .with_body( + json!({"offer":{"id":"offer-ok","status":4,"tx_id": confirmed_tx_id}}).to_string(), + ) .create_async() .await; let _missing = server diff --git a/greenfloor-engine/src/offer/lifecycle/reconcile_watched_offers.rs b/greenfloor-engine/src/offer/lifecycle/reconcile_watched_offers.rs index af09657c..1ce953d8 100644 --- a/greenfloor-engine/src/offer/lifecycle/reconcile_watched_offers.rs +++ b/greenfloor-engine/src/offer/lifecycle/reconcile_watched_offers.rs @@ -222,7 +222,9 @@ mod tests { let _ok = server .mock("GET", "/v1/offers/offer-ok") .with_status(200) - .with_body(json!({"id":"offer-ok","status":4,"tx_id":confirmed_tx_id}).to_string()) + .with_body( + json!({"offer":{"id":"offer-ok","status":4,"tx_id":confirmed_tx_id}}).to_string(), + ) .create(); let _missing = server .mock("GET", "/v1/offers/offer-missing") From 53cc53dc70df1d2c0a6d9f0c2e8aab52ce3a5da4 Mon Sep 17 00:00:00 2001 From: Gene Hoffman Date: Thu, 13 Aug 2026 15:04:22 -0700 Subject: [PATCH 6/7] Require signer_key_id on enabled-market config fixtures. Enabled markets now reject an empty signer_key_id, so the integration sample rows need the same field as production YAML. --- greenfloor-engine/tests/config/markets.rs | 1 + greenfloor-engine/tests/config/smoke.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/greenfloor-engine/tests/config/markets.rs b/greenfloor-engine/tests/config/markets.rs index f52a258e..85e88e5d 100644 --- a/greenfloor-engine/tests/config/markets.rs +++ b/greenfloor-engine/tests/config/markets.rs @@ -26,6 +26,7 @@ fn sample_markets() -> MarketsConfig { "base_symbol": "A1", "quote_asset": "xch", "receive_address": "xch1test", + "signer_key_id": "key-main-1", "pricing": {"min_price_quote_per_base": 0.0031} }] })) diff --git a/greenfloor-engine/tests/config/smoke.rs b/greenfloor-engine/tests/config/smoke.rs index fb89b5cc..7119e078 100644 --- a/greenfloor-engine/tests/config/smoke.rs +++ b/greenfloor-engine/tests/config/smoke.rs @@ -30,6 +30,7 @@ fn parse_markets_config_parses_cancel_move_threshold_bps() { "base_asset": "a1", "quote_asset": "xch", "receive_address": "xch1test", + "signer_key_id": "key-main-1", "pricing": {"cancel_move_threshold_bps": 250} }] })) From dc3083852d987bbf80033bc85cff620a7b79eea0 Mon Sep 17 00:00:00 2001 From: Gene Hoffman Date: Thu, 13 Aug 2026 15:08:42 -0700 Subject: [PATCH 7/7] Expect a non-zero daemon-once exit when a forced market error is isolated. Partial cycle errors now fail the process while the other market still runs, matching compute_cycle_exit_code. --- greenfloor-engine/tests/daemon_once_integration.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/greenfloor-engine/tests/daemon_once_integration.rs b/greenfloor-engine/tests/daemon_once_integration.rs index 33102cf8..7d83e7b6 100644 --- a/greenfloor-engine/tests/daemon_once_integration.rs +++ b/greenfloor-engine/tests/daemon_once_integration.rs @@ -193,7 +193,7 @@ fn daemon_once_isolates_forced_market_error() { }), }); let result = run_daemon_once(&request, DAEMON_ENV); - assert_eq!(result.exit_code, 0); + assert_eq!(result.exit_code, 1); let summary = cycle_summary(result.response.as_ref().expect("response")); assert_eq!(summary.get("markets_attempted"), Some(&json!(2))); assert_eq!(summary.get("markets_processed"), Some(&json!(1)));