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/dexie/client.rs b/greenfloor-engine/src/adapters/dexie/client.rs index 27cdbe14..3bbf67fa 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, } } @@ -133,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, @@ -150,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, @@ -196,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/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/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 a3e541b7..7602da6f 100644 --- a/greenfloor-engine/src/adapters/mod.rs +++ b/greenfloor-engine/src/adapters/mod.rs @@ -1,6 +1,8 @@ mod dexie; +mod http_client; mod http_json; mod splash; 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 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..3814022b 100644 --- a/greenfloor-engine/src/cli_util.rs +++ b/greenfloor-engine/src/cli_util.rs @@ -5,50 +5,12 @@ use serde_json::{json, Value}; use crate::error::{SignerError, SignerResult}; -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::Coinset(message) => script_coinset_transport_retryable(message), - _ => false, - } -} - 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!( "{}", @@ -144,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/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..6667fa41 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; @@ -51,7 +53,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, @@ -168,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() { @@ -189,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/coin_ops/execution/managed/tests.rs b/greenfloor-engine/src/coin_ops/execution/managed/tests.rs index 624a70a6..4e74480e 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), @@ -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/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/input_selection/combine_inputs.rs b/greenfloor-engine/src/coin_ops/input_selection/combine_inputs.rs index 0156d2f8..dcbb25eb 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,31 +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)), ) } - -/// 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 mut eligible: Vec<&SpendableCoin> = spendable_coins - .iter() - .filter(|coin| !coin.id.is_empty() && !excluded.contains(&coin.id.to_ascii_lowercase())) - .collect(); - eligible.sort_by_key(|coin| std::cmp::Reverse(coin.amount)); - eligible - .iter() - .take(capped_count(number_of_coins, max_count)) - .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/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..b4903c43 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, @@ -34,11 +40,14 @@ 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; +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/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/selection.rs b/greenfloor-engine/src/coin_ops/selection.rs index 942adf54..2e66eab5 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,102 @@ 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, + /// 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::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 +579,18 @@ 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 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..747ca0a4 --- /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::{OfferError, 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::Offer(OfferError::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..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(SignerError::from) + .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/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/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 f619b882..1223684c 100644 --- a/greenfloor-engine/src/coinset/cats/resolve.rs +++ b/greenfloor-engine/src/coinset/cats/resolve.rs @@ -13,18 +13,18 @@ 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). /// /// # Errors /// -/// Returns [`SignerError::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, @@ -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 08c94fbf..5f42927a 100644 --- a/greenfloor-engine/src/coinset/coin_select/mod.rs +++ b/greenfloor-engine/src/coinset/coin_select/mod.rs @@ -1,15 +1,17 @@ //! 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_protocol::{Bytes32, Coin}; use chia_sdk_coinset::{CoinRecord, CoinsetClient}; 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::error::{SignerError, SignerResult}; +use crate::coin_ops::{select_funding_coin_ids, FundingSelectionMode, SpendableCoin}; +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). pub const MIN_CAT_OUTPUT_MOJOS: u64 = 1000; @@ -21,129 +23,84 @@ 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; +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, } } - Vec::new() } -#[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 amount_i64(amount: u64) -> i64 { + i64::try_from(amount).unwrap_or(i64::MAX) } -/// 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 coin_spendable_id(coin_id: Bytes32) -> String { + normalize_hex_id(&bytes32_to_hex(coin_id)) } -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 coin_to_spendable(coin: &Coin) -> SpendableCoin { + SpendableCoin::with_puzzle_hash( + coin_spendable_id(coin.coin_id()), + amount_i64(coin.amount), + normalize_hex_id(&bytes32_to_hex(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_items( items: Vec, target_amount: u64, - mode: CoinSelectionMode, - amount: impl Fn(&T) -> u64, - empty_list_err: SignerError, - insufficient_err: SignerError, + mode: FundingSelectionMode, + coin_of: impl Fn(&T) -> &Coin, ) -> SignerResult> { if items.is_empty() { - return Err(empty_list_err); + return Err(SignerError::CoinOps(CoinOpsError::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(|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::CoinOps(CoinOpsError::InsufficientCatCoins)); } - 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(insufficient_err); + return Err(SignerError::CoinOps(CoinOpsError::InsufficientCatCoins)); } Ok(selected) } -fn finalize_amount_selection( - items: Vec, - explicit_coin_ids: &[Bytes32], - target_amount: u64, - amount: impl Fn(&T) -> u64, -) -> SignerResult<(Vec, u64)> { - let mode = CoinSelectionMode::from_explicit_ids(explicit_coin_ids); - let selected = select_from_list( - items, - target_amount, - mode, - &amount, - SignerError::NoUnspentCatCoins, - SignerError::InsufficientCatCoins, - )?; - 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| { - 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( @@ -152,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( @@ -163,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(()) @@ -213,10 +180,12 @@ 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 = select_items( + available, + target_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(); for record in selected_records { @@ -226,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 463eb9ee..eee742a2 100644 --- a/greenfloor-engine/src/coinset/coin_select/tests.rs +++ b/greenfloor-engine/src/coinset/coin_select/tests.rs @@ -1,18 +1,27 @@ 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, + 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( + cats: Vec, + target_amount: u64, + mode: FundingSelectionMode, +) -> Result, SignerError> { + select_items(cats, target_amount, mode, |cat| &cat.coin) +} + 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 +40,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 +53,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 +66,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,43 +75,34 @@ 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"); - assert!(matches!(err, SignerError::NoUnspentCatCoins)); + let err = select_cats_for_mode(Vec::::new(), 1000, FundingSelectionMode::SmallestFirst) + .expect_err("empty"); + assert!(matches!( + err, + SignerError::CoinOps(CoinOpsError::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)); + assert!(matches!( + err, + SignerError::CoinOps(CoinOpsError::InsufficientCatCoins) + )); } #[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,16 +114,16 @@ 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)); + assert!(matches!( + err, + SignerError::CoinOps(CoinOpsError::InsufficientCatCoins) + )); } #[test] @@ -153,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] @@ -168,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 15f37962..560f47fe 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::{ @@ -44,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/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/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/retry.rs b/greenfloor-engine/src/coinset/retry.rs index 5db7073c..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); } @@ -126,7 +125,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::from_reqwest("coinset", &err)) + } }) .await } @@ -151,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/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/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/coinset_cli/tests/dispatch_tests.rs b/greenfloor-engine/src/coinset_cli/tests/dispatch_tests.rs index 6d812c18..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, @@ -8,7 +7,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; @@ -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] @@ -122,8 +121,14 @@ async fn coin_records_surfaces_retryable_error_on_http_503() { ) .await .expect_err("503"); - assert!(matches!(err, SignerError::Coinset(_))); - assert!(script_engine_error_retryable(&err)); + assert!(matches!( + err, + SignerError::Transport(TransportError::Decode { + layer: "coinset", + .. + }) + )); + assert!(err.is_retryable_upstream()); } #[tokio::test] 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..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; @@ -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, @@ -62,7 +63,7 @@ impl ManagerProgramConfig { if self.signer_offer_path_configured() { return Ok(()); } - Err(SignerError::SignerPathNotConfigured) + Err(SignerError::Config(ConfigError::SignerPathNotConfigured)) } } @@ -109,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"))) } } @@ -119,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(), } } @@ -148,6 +153,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/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/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..91843c44 100644 --- a/greenfloor-engine/src/cycle/mod.rs +++ b/greenfloor-engine/src/cycle/mod.rs @@ -42,15 +42,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..909c510b 100644 --- a/greenfloor-engine/src/cycle/orchestration.rs +++ b/greenfloor-engine/src/cycle/orchestration.rs @@ -239,17 +239,6 @@ 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")) -} - #[must_use] pub fn record_stale_sweep_check( progress: &StaleSweepProgress, @@ -324,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/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/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/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/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/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..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; } @@ -224,8 +217,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/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 5d54cf4f..16b35242 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,17 @@ 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 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()); assert!(!fatal.is_parallel_dispatch_transient()); @@ -86,15 +92,40 @@ 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 = PersistenceError::ReservationContention("busy".to_string()).into(); 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"), } } +#[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()); @@ -113,7 +144,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::Other("ReservationContentionError: 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/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/stale_sweep.rs b/greenfloor-engine/src/daemon/stale_sweep.rs index 50342f24..4131fa87 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/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/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 c668cde3..227360cb 100644 --- a/greenfloor-engine/src/error/mod.rs +++ b/greenfloor-engine/src/error/mod.rs @@ -1,301 +1,139 @@ 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("coinset error: {0}")] - Coinset(String), - - #[error("driver error: {0}")] - Driver(String), - - #[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(transparent)] + Vault(#[from] VaultError), + #[error(transparent)] + CoinOps(#[from] CoinOpsError), + #[error(transparent)] + Offer(#[from] OfferError), + #[error(transparent)] + Reconcile(#[from] crate::cycle::ReconcileStateError), + #[error(transparent)] + Persistence(#[from] PersistenceError), + #[error(transparent)] + Transport(#[from] TransportError), + #[error(transparent)] + Config(#[from] ConfigError), + #[error("{0}")] + Other(String), +} - #[error("reservation contention: {0}")] - ReservationContention(String), +const MIXED_SPLIT_SELECTED_COINS_NOT_SPENDABLE: &str = "Some selected coins are not spendable"; - #[error("managed upstream transient: {0}")] - ManagedUpstreamTransient(String), +impl SignerError { + /// 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())) + } - #[error("database is locked")] - DatabaseLocked, + /// 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(), + }) + } - #[error("failed to open sqlite db {path}: {open_error}")] - SqliteOpenFailed { path: String, open_error: String }, + /// 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(), + }) + } - #[error("offer execution requires signer.kms_key_id and vault.launcher_id in program config")] - SignerPathNotConfigured, + /// 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(), + }) + } - #[error("daemon_already_running:{path}{detail}")] - DaemonAlreadyRunning { path: String, detail: String }, + /// 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(), + }) + } - #[error("{0}")] - Other(String), -} + /// 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(), + }) + } -fn is_parallel_dispatch_transient_class(exception_class: &str) -> bool { - matches!( - exception_class.trim(), - "ReservationContentionError" | "ManagedUpstreamTransientError" | "TimeoutError" - ) -} + #[must_use] + pub fn from_reqwest(layer: &'static str, err: &reqwest::Error) -> Self { + Self::Transport(TransportError::from_reqwest(layer, err)) + } -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)) -} + /// 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(), + }) + } -const MIXED_SPLIT_SELECTED_COINS_NOT_SPENDABLE: &str = "Some selected coins are not spendable"; + #[must_use] + pub fn is_http_not_found(&self) -> bool { + matches!(self, Self::Transport(err) if err.is_http_not_found()) + } -fn mixed_split_selected_coins_not_spendable_message(message: &str) -> bool { - message.contains(MIXED_SPLIT_SELECTED_COINS_NOT_SPENDABLE) -} + /// chia-wallet-sdk driver failure. + #[must_use] + pub fn driver(message: impl Into) -> Self { + Self::Transport(TransportError::Driver(message.into())) + } -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) { - return err; - } - if mixed_split_selected_coins_not_spendable_message(&err.to_string()) { - Self::MixedSplitSelectedCoinsNotSpendable - } else { - err + pub fn is_sqlite_fatal(&self) -> bool { + match self { + Self::Persistence(err) => err.is_sqlite_fatal(), + _ => false, } } #[must_use] - pub fn is_sqlite_fatal(&self) -> bool { - matches!(self, Self::SqliteOpenFailed { .. }) + pub fn is_retryable_upstream(&self) -> bool { + matches!(self, Self::Transport(err) if err.is_retryable_upstream()) } #[must_use] @@ -304,17 +142,8 @@ impl SignerError { return false; } match self { - Self::ReservationContention(_) - | Self::ManagedUpstreamTransient(_) - | Self::DatabaseLocked => true, - 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) - } + Self::Persistence(err) => err.is_parallel_dispatch_transient(), + Self::Transport(err) => err.is_retryable_upstream(), _ => false, } } @@ -324,7 +153,12 @@ pub type SignerResult = Result; #[must_use] pub fn driver_error(err: &chia_sdk_driver::DriverError) -> SignerError { - SignerError::Driver(err.to_string()) + 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()) } impl From for SignerError { @@ -335,49 +169,89 @@ impl From for SignerError { impl From for SignerError { fn from(err: reqwest::Error) -> Self { - SignerError::Coinset(err.to_string()) + SignerError::from_reqwest("http", &err) } } #[cfg(test)] mod tests { - use super::SignerError; + 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", "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_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", + ), + ( + SignerError::driver("invalid mod hash"), + "driver error: invalid mod hash", + ), ]; for (err, expected) in cases { assert_eq!(err.to_string(), expected); @@ -385,26 +259,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::Other("invalid offer".to_string()).is_parallel_dispatch_transient()); - } - - #[test] - fn parallel_dispatch_transient_matches_upstream_and_contention_classes() { - assert!(SignerError::Other("TimeoutError: timed out".to_string()) - .is_parallel_dispatch_transient()); assert!( - SignerError::Other("ManagedUpstreamTransientError: timeout".to_string()) + SignerError::http_connect("dexie_network_error", "connection refused") .is_parallel_dispatch_transient() ); + assert!(!SignerError::http("dexie", "timeout").is_parallel_dispatch_transient()); assert!( - SignerError::Other("ReservationContentionError: busy".to_string()) + !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::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() @@ -413,42 +295,65 @@ 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() { + 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::Driver("invalid mod hash".to_string()).is_parallel_dispatch_transient() + SignerError::http_request("coinset", "error sending request").is_retryable_upstream() ); - assert!(!SignerError::InsufficientCatCoins.is_parallel_dispatch_transient()); + 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] 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() ); - assert!(matches!( - SignerError::normalize_mixed_split_error(SignerError::Other( - "Some selected coins are not spendable".to_string() - )), - SignerError::MixedSplitSelectedCoinsNotSpendable - )); } #[test] @@ -457,10 +362,21 @@ 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(); 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/offer.rs b/greenfloor-engine/src/error/offer.rs new file mode 100644 index 00000000..b5c2a3cc --- /dev/null +++ b/greenfloor-engine/src/error/offer.rs @@ -0,0 +1,92 @@ +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("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, + + #[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 new file mode 100644 index 00000000..721cda43 --- /dev/null +++ b/greenfloor-engine/src/error/transport.rs @@ -0,0 +1,86 @@ +use thiserror::Error; + +/// HTTP, Coinset, and wallet-sdk driver failures. +#[derive(Debug, 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 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, + 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 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 } + } + } + + #[must_use] + pub fn is_http_not_found(&self) -> bool { + matches!(self, Self::HttpStatus { status: 404, .. }) + } + + /// True when Coinset/HTTP can be retried or parallel dispatch can fall back. + #[must_use] + 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/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/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/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/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/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/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/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/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/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 aa29d9e3..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 @@ -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, }; @@ -24,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, @@ -77,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/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/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/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..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; @@ -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, } @@ -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, @@ -165,7 +166,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), }) } @@ -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 0dfdf108..428b8b2e 100644 --- a/greenfloor-engine/src/offer/assets.rs +++ b/greenfloor-engine/src/offer/assets.rs @@ -5,7 +5,8 @@ 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). #[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 { @@ -185,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(()) } @@ -198,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); @@ -257,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/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..d922bdc9 100644 --- a/greenfloor-engine/src/offer/cancel_input.rs +++ b/greenfloor-engine/src/offer/cancel_input.rs @@ -1,13 +1,15 @@ //! 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, 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 @@ -135,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), } } @@ -154,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) @@ -200,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)? @@ -230,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), @@ -254,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), @@ -262,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( @@ -274,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(()) } @@ -305,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, @@ -337,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( @@ -369,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 23bef458..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}; +use crate::error::{OfferError, 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); @@ -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,21 +161,25 @@ 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::Driver(msg) => format!("wallet_sdk_offer_validate_failed:driver:{msg}"), + SignerError::Offer(OfferError::OfferMissingExpiration) => { + "wallet_sdk_offer_missing_expiration".to_string() + } + 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..0856c6da 100644 --- a/greenfloor-engine/src/offer/lifecycle/market_reconcile/augment.rs +++ b/greenfloor-engine/src/offer/lifecycle/market_reconcile/augment.rs @@ -12,9 +12,9 @@ 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::{persist_missing_watched_offer, ReconcilePersistOptions}; +use super::super::{ReconcilePersistOptions, WatchedOfferReconciler}; use super::transition::{note_reconcile_transition_side_effects, ReconcileMarketCycleMetrics}; pub struct AugmentedDexieOffers { @@ -22,37 +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 transition = persist_missing_watched_offer( - store, - 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, - 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. @@ -105,36 +74,51 @@ 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( + 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, + metrics, + state_by_offer_id, + ); + } + Ok(DexieOfferFetch::Mismatch) => { + record_watchlist_augment_error( store, market_id, watched_offer_id, - &error_text, - state_by_offer_id, + "dexie get_offer payload did not match local offer id", metrics, )?; } - DexieOfferFetch::Mismatch => { + Err(err) => { record_watchlist_augment_error( store, market_id, watched_offer_id, - "dexie get_offer payload did not match local offer id", + &err.to_string(), metrics, )?; } - DexieOfferFetch::LookupError(err) => { - record_watchlist_augment_error(store, market_id, watched_offer_id, &err, metrics)?; - } } } Ok(()) 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..ca261f8c 100644 --- a/greenfloor-engine/src/offer/lifecycle/mod.rs +++ b/greenfloor-engine/src/offer/lifecycle/mod.rs @@ -53,18 +53,11 @@ 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, }; -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/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..db417b65 100644 --- a/greenfloor-engine/src/offer/lifecycle/reconcile_prep/dexie_fetch.rs +++ b/greenfloor-engine/src/offer/lifecycle/reconcile_prep/dexie_fetch.rs @@ -1,140 +1,211 @@ //! Metrics-agnostic Dexie `get_offer` parsing shared by reconcile prepare, augment, and CLI. -use serde_json::Value; - -use crate::adapters::DexieClient; -use crate::cycle::is_dexie_offer_missing_error_text; +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). - Missing(String), - /// Response succeeded but did not match the requested local offer id - /// (only produced for [`DexieFetchMode::HealStrict`]). + /// 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, + /// 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( +async fn get_dexie_offer( + dexie: &DexieClient, offer_id: &str, - payload: &Value, - mode: DexieFetchMode, -) -> DexieOfferFetch { - if let Some(error_text) = missing_offer_error_from_payload(payload) { - return DexieOfferFetch::Missing(error_text); +) -> 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), } - 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; } + 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 { - 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()), +) -> SignerResult { + 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. +/// +/// 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 Some(response) = get_dexie_offer(dexie, offer_id).await? else { + return Err(SignerError::Offer(OfferError::OfferCancelOfferFileNotFound)); + }; + 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.into_value()).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), - DexieOfferFetch::Missing("HTTP Error 404: Not Found".into()) + parse_dexie_get_offer_response("ab", &response), + DexieOfferFetch::Missing ); } #[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_eq!(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) + )); + } + + #[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/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..6301e297 100644 --- a/greenfloor-engine/src/offer/lifecycle/reconcile_prep/mod.rs +++ b/greenfloor-engine/src/offer/lifecycle/reconcile_prep/mod.rs @@ -2,14 +2,13 @@ //! //! 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, 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..e05949d7 100644 --- a/greenfloor-engine/src/offer/lifecycle/reconcile_prep/watch_plan.rs +++ b/greenfloor-engine/src/offer/lifecycle/reconcile_prep/watch_plan.rs @@ -11,13 +11,13 @@ 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; 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)] @@ -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,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); - // 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, - _ => 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(()) } @@ -202,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) } } @@ -464,7 +436,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 +450,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 +466,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 +479,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..1ce953d8 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; @@ -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") diff --git a/greenfloor-engine/src/offer/lifecycle/signal_apply.rs b/greenfloor-engine/src/offer/lifecycle/signal_apply.rs index fcbf7873..f84d5849 100644 --- a/greenfloor-engine/src/offer/lifecycle/signal_apply.rs +++ b/greenfloor-engine/src/offer/lifecycle/signal_apply.rs @@ -1,14 +1,17 @@ -//! 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, }; -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}; @@ -17,15 +20,10 @@ use super::cancel_context::{ preload_cancel_submitted_contexts, }; use super::persist::{persist_offer_lifecycle_transition, ReconcilePersistOptions}; +use super::reconcile_prep::{fetch_dexie_offer, DexieOfferFetch}; use super::transition::{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,342 @@ 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, + + /// 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). + /// + /// `dexie_error` is recorded on the persist audit when present. + /// + /// # 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, + dexie_error: Option<&str>, + ) -> SignerResult { + let transition = resolve_missing_watched_offer_transition(current_state)?; + 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, - CoinsetTxSignals::default(), - Some(&cancel_by_offer), - options, - now, - )? { - changed += 1; + &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 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)> { + 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) => { + let transition = self.apply_missing(market_id, offer_id, current_state, None)?; + 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, + current_state, + format!("dexie_lookup_error:{err}"), + ), } } - Ok(changed) + + #[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) + } + + 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 7b60dfde..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,23 +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(|err| crate::error::SignerError::Other(err.to_string())) -} - /// Resolve lifecycle + Dexie status from an offer body (list row or `get_offer.offer`). /// /// # Errors @@ -147,79 +122,6 @@ 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)) } - -/// 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}")) - .map_err(|parse_err| crate::error::SignerError::Other(parse_err.to_string()))?; - 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/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..53f03202 --- /dev/null +++ b/greenfloor-engine/src/offer/maker_shape.rs @@ -0,0 +1,183 @@ +//! Maker watch-seed policy (ADR 0019). Shape classification lives on [`PostedOfferShape`]. + +use crate::hex::canonical_tx_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, +} + +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 { + 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(), + ), + } + } + + /// 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 { + 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), + } + } +} + +#[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)]); + } + + #[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/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..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, )) } } @@ -65,7 +68,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 +92,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 +107,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 +136,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 +172,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..eb809243 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, } @@ -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/operator/ensure_size/mod.rs b/greenfloor-engine/src/offer/operator/ensure_size/mod.rs index 35640b85..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}; @@ -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,11 +148,11 @@ 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 = - 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); } @@ -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..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, @@ -33,6 +40,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, } @@ -149,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 { @@ -201,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) = @@ -226,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, @@ -269,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/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/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..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)) } @@ -65,7 +64,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 +97,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 b64fecfb..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); @@ -122,7 +126,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( @@ -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] @@ -332,6 +339,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/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 e6243446..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}; @@ -18,7 +18,6 @@ pub(crate) enum OfferPlan { ExistingPresplit { offer_nonce: Bytes32, }, - RequiresSplitFlag, Direct { selection: SelectedCats, offer_nonce: Bytes32, @@ -32,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( @@ -50,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)) } @@ -90,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 +106,9 @@ pub(crate) async fn plan_vault_cat_offer( selection, offer_nonce, }, - OfferInput::Direct { .. } => OfferPlan::RequiresSplitFlag, + OfferInput::Direct { .. } => { + return Err(SignerError::Offer(OfferError::OfferInputRequiresPresplit)); + } OfferInput::PresplitExisting { .. } => unreachable!(), }) } @@ -206,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), - Ok(DirectPlanKind::RequiresSplitFlag) + Err(SignerError::Offer(OfferError::OfferInputRequiresPresplit)) )); assert!(matches!( direct_plan_kind_for_amounts(1000, 1000, 1), @@ -214,7 +219,9 @@ mod tests { )); assert!(matches!( direct_plan_kind_for_amounts(1000, 1000, 2), - Err(SignerError::DirectOfferRequiresSingleInputCoin) + Err(SignerError::Offer( + OfferError::DirectOfferRequiresSingleInputCoin + )) )); } @@ -223,7 +230,7 @@ mod tests { let err = offer_nonce_for_existing_presplit(&[], None).unwrap_err(); assert!(matches!( err, - SignerError::PresplitOfferRequiresSourceCoinIds + SignerError::Offer(OfferError::PresplitOfferRequiresSourceCoinIds) )); } @@ -245,12 +252,14 @@ 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 { Direct, - RequiresSplitFlag, } fn direct_plan_kind_for_amounts( @@ -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 { - Ok(DirectPlanKind::RequiresSplitFlag) + 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/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/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/orphan_discover.rs b/greenfloor-engine/src/offer/presplit/orphan_discover.rs index ca071b0b..513f33c8 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, @@ -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::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 [`SignerError::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, @@ -317,9 +320,21 @@ 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 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")); } diff --git a/greenfloor-engine/src/offer/presplit/split.rs b/greenfloor-engine/src/offer/presplit/split.rs index 0ef4a0e7..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(()) } @@ -108,7 +110,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( @@ -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 ed24f5e1..7bdeee2d 100644 --- a/greenfloor-engine/src/offer/publish/dexie/mod.rs +++ b/greenfloor-engine/src/offer/publish/dexie/mod.rs @@ -1,15 +1,10 @@ -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::error::{OfferError, SignerResult}; +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; @@ -26,11 +21,9 @@ pub struct PostOfferPhaseDexieParams<'a> { pub expected: &'a ExpectedPublishAssetFields, } -#[derive(Debug)] -pub(super) enum OfferVisibilityPoll { - Ready, - Retry(String), - Failed(String), +enum DexiePostVisibility { + Visible, + Missing, } async fn sleep_for_publish(seconds: f64) { @@ -45,26 +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) -} - async fn post_dexie_offer_with_invalid_offer_retry( dexie: &DexieClient, offer_text: &str, @@ -73,98 +46,78 @@ 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 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() - }; - 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); + 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; + } + Err(err) => return Err(err), } } - OfferVisibilityPoll::Ready } async fn wait_for_dexie_offer_visible( dexie: &DexieClient, offer_id: &str, expected: &ExpectedPublishAssetFields, -) -> Option { +) -> SignerResult { let clean_offer_id = offer_id.trim(); if clean_offer_id.is_empty() { - return Some("dexie_offer_missing_id_after_publish".to_string()); + return Err(OfferError::DexieOfferMissingIdAfterPublish.into()); } - let mut last_error = "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 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 Err(OfferError::DexieOfferAssetMismatch(asset_error).into()); } + return Ok(DexiePostVisibility::Visible); + } + 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 { + DexieOfferFetch::Missing => Ok(DexiePostVisibility::Missing), + DexieOfferFetch::Found(_) | DexieOfferFetch::Mismatch => { + Err(OfferError::DexieOfferVisibilityMismatch.into()) } } - Some(last_error) } /// Post offer to Dexie with invalid-offer retry and post-publish visibility checks. /// /// # 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 { @@ -175,42 +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_visibility_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(); - 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)); - } - if attempt < DEXIE_VISIBILITY_REPOST_MAX_ATTEMPTS { - sleep_for_publish(DEXIE_VISIBILITY_REPOST_DELAY_SECONDS).await; + 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; + } } - continue; } - return Ok(result); } - Ok(dexie_publish_failure( - last_result, - if last_visibility_error.is_empty() { - "dexie_offer_not_visible_after_publish".to_string() - } else { - last_visibility_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 01290ad4..a771046f 100644 --- a/greenfloor-engine/src/offer/publish/dexie/tests.rs +++ b/greenfloor-engine/src/offer/publish/dexie/tests.rs @@ -1,8 +1,9 @@ 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::error::{OfferError, SignerError}; use crate::offer::publish::{ExpectedPublishAssetFields, PublishAssetSide}; fn expected_fields() -> ExpectedPublishAssetFields { @@ -59,6 +60,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; @@ -87,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, @@ -95,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] @@ -154,39 +204,45 @@ 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:?}"), - } -} - -#[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")); + let err = post_offer_phase_dexie(PostOfferPhaseDexieParams { + dexie: &dexie, + offer_text: "offer1test", + drop_only: true, + claim_rewards: false, + expected: &expected, + }) + .await + .expect_err("mismatch"); + assert!(matches!( + err, + SignerError::Offer(OfferError::DexieOfferVisibilityMismatch) + )); } 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 39b251ab..00000000 --- a/greenfloor-engine/src/offer/publish/dexie/visibility.rs +++ /dev/null @@ -1,24 +0,0 @@ -#[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") -} - -#[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 ff547b3d..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, @@ -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( @@ -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 6fd71c58..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, @@ -256,6 +258,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 +327,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 +363,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, @@ -438,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 + )) )); } @@ -453,22 +481,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 +502,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..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) @@ -148,6 +159,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)] @@ -197,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}")) @@ -229,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/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/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 6a948bd9..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}; @@ -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(), )); } @@ -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/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/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/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/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/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/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/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" 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} }] })) 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))); 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);