From 4d707048b776866619a41af30fb308e3b0b1a952 Mon Sep 17 00:00:00 2001 From: Gene Hoffman Date: Tue, 11 Aug 2026 14:53:43 -0700 Subject: [PATCH 1/2] Fix coin-ops combine blocked by shared Direct maker p2 watches. Direct maker_puzzle_hash is the vault inventory CAT outer / receive inner, so seeding it as a per-offer p2 watch locked every coin of that asset. Seed p2 watches only for presplit, and exclude spendable coins by coin-id only. --- .../0019-coinset-ws-local-watches.md | 28 ++++---- .../src/coin_ops/execution/context.rs | 12 ++-- .../src/coin_ops/execution/helpers.rs | 38 +++------- .../src/coin_ops/execution/managed/runner.rs | 6 -- .../src/coin_ops/execution/managed/tests.rs | 67 ++++++++--------- .../src/daemon/coin_ops_phase/mod.rs | 17 +++-- .../src/manager_cli/coin_op_loop/context.rs | 28 ++++---- .../lifecycle/reconcile_prep/watch_plan.rs | 72 ++++++++++++++++--- .../offer/operator/build_and_post/publish.rs | 21 +++--- .../offer/operator/build_and_post/tests.rs | 7 +- greenfloor-engine/src/offer/types.rs | 14 +++- 11 files changed, 173 insertions(+), 137 deletions(-) diff --git a/docs/decisions/0019-coinset-ws-local-watches.md b/docs/decisions/0019-coinset-ws-local-watches.md index 848b0495..1d53bcf4 100644 --- a/docs/decisions/0019-coinset-ws-local-watches.md +++ b/docs/decisions/0019-coinset-ws-local-watches.md @@ -31,13 +31,14 @@ never the operator transport. HTTP `get_transaction` polling supplements WS by confirming prepared `cancel_submitted` transaction ids during recovery and every cycle preamble. 3. **Watches:** durable SQLite `offer_coin_watches` registered atomically at post - (maker coins + on-chain maker puzzle hashes — CAT outer or XCH p2 — not the - fixed delegated CONDITIONS hash), sourced from required `OfferCancelFields` on - every successful create (Direct: single exact-size maker coin; presplit: split - coin + fixed CONDITIONS hash). Schema migration backfills missing watches - from cancel metadata for pre-upgrade rows. Shared market inventory - receive/CAT outer p2s are **not** stored on per-offer watches; `InventoryP2Index` - still drives WS filters and inventory freshness. Optional coin-id fields on + (maker **coin ids** always; per-offer maker **p2** only for presplit CONDITIONS + puzzle hashes via `OfferExecutionMode::seeds_per_offer_maker_p2_watch`). Direct + `maker_puzzle_hash` (CAT outer / XCH receive inner) is cancel metadata only — + shared inventory hashes stay on `InventoryP2Index`, not per-offer watches. + Sourced from required `OfferCancelFields` on every successful create (Direct: + single exact-size maker coin; presplit: split coin + fixed CONDITIONS hash). + Schema migration backfills missing watches from cancel metadata for pre-upgrade + rows. Optional coin-id fields on transaction frames are matched when present. WS offer events and watch hits drive lifecycle transitions directly. Cancel submit prepares `cancel_submitted` before broadcast (watches kept), then observes the cancel tx after successful @@ -62,8 +63,8 @@ never the operator transport. `watch_venue_backfill_v2`). Missing watches are healed each reconcile via a single `prepare_market_reconcile_local` scan: cancel-submitted collection, cancel-metadata heal, and Dexie role classify (`DexieWatchRoles`), then Dexie - payloads for heal-only NULL-venue gaps (`fetch_and_ensure_watches` seeds both - maker coin ids and on-chain maker p2s from cancellable offer inputs; when a + payloads for heal-only NULL-venue gaps (`fetch_and_ensure_watches` seeds maker + coin ids always and maker p2s only when local `execution_mode` is presplit; when a list row lacks a decodable `offer1…`, heal calls `get_offer` so watches are not stuck coin-only; no Dexie lifecycle). Dexie lifecycle remains `publish_venue=dexie` only and applies through the same @@ -97,9 +98,12 @@ never the operator transport. registered atomically at post, backfilled once on schema open via `schema_meta` (`watch_venue_backfill_v2`), and healed each reconcile via `prepare_market_reconcile_local` + heal-only Dexie fetch. Coin-ops excludes durable - `kind='coin'` watch ids and durable `kind='p2'` maker puzzle hashes inside - `list_spendable_coins` (local on-chain `puzzle_hash`; no network expand). - Explicit CLI coin ids are refused when they match durable maker coin watches. + `kind='coin'` watch ids only inside `list_spendable_coins`. Per-offer `kind='p2'` + watches are WS lifecycle / inventory-stale signals: Direct offers never seed them + (`OfferExecutionMode::seeds_per_offer_maker_p2_watch` is false) because + `maker_puzzle_hash` is the shared vault inventory hash; presplit may seed the + unique CONDITIONS puzzle hash. Explicit CLI coin ids are refused when they match + durable maker coin watches. ## Consequences diff --git a/greenfloor-engine/src/coin_ops/execution/context.rs b/greenfloor-engine/src/coin_ops/execution/context.rs index f50ad5c1..0fa85db5 100644 --- a/greenfloor-engine/src/coin_ops/execution/context.rs +++ b/greenfloor-engine/src/coin_ops/execution/context.rs @@ -24,8 +24,6 @@ pub struct CoinOpExecContext { pub base_unit_mojo_multiplier: i64, pub combine_input_cap: i64, pub watched_coin_ids: HashSet, - /// Durable maker puzzle hashes (`kind='p2'` watches) for local spend exclusion. - pub watched_p2s: HashSet, #[cfg(test)] pub test_overrides: CoinOpTestOverrides, } @@ -33,6 +31,10 @@ pub struct CoinOpExecContext { impl CoinOpExecContext { /// Build execution context from an owned gated operator market. /// + /// Coin-ops spendable exclusion uses durable **coin-id** watches only. + /// Per-offer `kind='p2'` watches are for WS lifecycle; Direct inventory + /// hashes must never lock coin-ops selection (ADR 0019). + /// /// # Errors /// /// Returns an error if asset resolution fails. @@ -40,7 +42,6 @@ impl CoinOpExecContext { gated: GatedOperatorMarket, canonical_base_asset: Option<&str>, watched_coin_ids: HashSet, - watched_p2s: HashSet, #[cfg(test)] test_overrides: CoinOpTestOverrides, ) -> SignerResult { let resolver = gated.asset_resolver(); @@ -54,7 +55,6 @@ impl CoinOpExecContext { gated, resolved_base_asset_id, watched_coin_ids, - watched_p2s, #[cfg(test)] test_overrides, }) @@ -87,7 +87,7 @@ impl CoinOpExecContext { .await } - /// List spendable coins, excluding durable maker coin-id and p2 watches. + /// List spendable coins, excluding durable maker **coin-id** watches. /// /// # Errors /// @@ -98,7 +98,6 @@ impl CoinOpExecContext { return Ok(exclude_watched_spendable( coins.iter().cloned(), &self.watched_coin_ids, - &self.watched_p2s, )); } let coins = list_wallet_unspent_coins_for_signer( @@ -111,7 +110,6 @@ impl CoinOpExecContext { Ok(exclude_watched_spendable( wallet_coins_to_spendable(&coins, self.gated.market_row.base_asset.trim()), &self.watched_coin_ids, - &self.watched_p2s, )) } diff --git a/greenfloor-engine/src/coin_ops/execution/helpers.rs b/greenfloor-engine/src/coin_ops/execution/helpers.rs index cb7a3eec..76f738ca 100644 --- a/greenfloor-engine/src/coin_ops/execution/helpers.rs +++ b/greenfloor-engine/src/coin_ops/execution/helpers.rs @@ -2,7 +2,6 @@ use std::collections::HashSet; use crate::coin_ops::{coin_op_min_amount_mojos, SpendableCoin}; use crate::coinset::WalletUnspentCoin; -use crate::hex::normalize_hex_id; pub(crate) fn wallet_coins_to_spendable( coins: &[WalletUnspentCoin], @@ -22,28 +21,18 @@ pub(crate) fn wallet_coins_to_spendable( .collect() } -/// Drop durable maker watches (coin id or on-chain p2) from a spendable set. +/// Drop durable maker **coin-id** watches from a spendable set. /// -/// When `watched_p2s` is non-empty, coins with an empty `puzzle_hash` are excluded -/// (fail closed) until the wallet path populates on-chain puzzle hashes. +/// Coin-ops does not exclude by `kind='p2'` watches: Direct maker puzzle hashes +/// are shared vault inventory hashes and would lock the entire asset balance. +/// Offer locks are coin-id watches registered at post (ADR 0019). pub(crate) fn exclude_watched_spendable( coins: impl IntoIterator, watched_coin_ids: &HashSet, - watched_p2s: &HashSet, ) -> Vec { coins .into_iter() - .filter(|coin| { - let id = coin.id.to_ascii_lowercase(); - if watched_coin_ids.contains(&id) { - return false; - } - if watched_p2s.is_empty() { - return true; - } - let p2 = normalize_hex_id(&coin.puzzle_hash); - !p2.is_empty() && !watched_p2s.contains(&p2) - }) + .filter(|coin| !watched_coin_ids.contains(&coin.id.to_ascii_lowercase())) .collect() } @@ -89,26 +78,17 @@ mod tests { } #[test] - fn exclude_watched_spendable_drops_coin_id_and_p2_matches() { + fn exclude_watched_spendable_drops_coin_id_matches_only() { let watched_coins = HashSet::from(["aa".repeat(32)]); - let watched_p2s = HashSet::from(["bb".repeat(32)]); let coins = vec![ SpendableCoin::new("aa".repeat(32), 1000), SpendableCoin::with_puzzle_hash("cc".repeat(32), 2000, "bb".repeat(32)), SpendableCoin::with_puzzle_hash("dd".repeat(32), 3000, "ee".repeat(32)), SpendableCoin::new("ff".repeat(32), 4000), ]; - let kept = exclude_watched_spendable(coins, &watched_coins, &watched_p2s); - assert_eq!(kept.len(), 1); - assert_eq!(kept[0].id, "dd".repeat(32)); - } - - #[test] - fn exclude_watched_spendable_empty_p2_fails_closed_when_p2_watches_exist() { - let watched_p2s = HashSet::from(["bb".repeat(32)]); - let coins = vec![SpendableCoin::new("ff".repeat(32), 4000)]; - let kept = exclude_watched_spendable(coins, &HashSet::default(), &watched_p2s); - assert!(kept.is_empty()); + let kept = exclude_watched_spendable(coins, &watched_coins); + assert_eq!(kept.len(), 3); + assert!(kept.iter().all(|coin| coin.id != "aa".repeat(32))); } #[test] diff --git a/greenfloor-engine/src/coin_ops/execution/managed/runner.rs b/greenfloor-engine/src/coin_ops/execution/managed/runner.rs index ac1587e6..8913595c 100644 --- a/greenfloor-engine/src/coin_ops/execution/managed/runner.rs +++ b/greenfloor-engine/src/coin_ops/execution/managed/runner.rs @@ -54,13 +54,11 @@ pub fn execute_managed_coin_op_plans<'a>( gated: GatedOperatorMarket, plans: &'a [CoinOpPlan], watched_coin_ids: &'a HashSet, - watched_p2s: &'a HashSet, ) -> ManagedCoinOpPlansFuture<'a> { Box::pin(execute_managed_coin_op_plans_async( gated, plans, watched_coin_ids, - watched_p2s, #[cfg(test)] CoinOpTestOverrides::default(), )) @@ -73,14 +71,12 @@ pub fn execute_managed_coin_op_plans_with_test_overrides<'a>( gated: GatedOperatorMarket, plans: &'a [CoinOpPlan], watched_coin_ids: &'a HashSet, - watched_p2s: &'a HashSet, test_overrides: CoinOpTestOverrides, ) -> ManagedCoinOpPlansFuture<'a> { Box::pin(execute_managed_coin_op_plans_async( gated, plans, watched_coin_ids, - watched_p2s, test_overrides, )) } @@ -89,7 +85,6 @@ async fn execute_managed_coin_op_plans_async( gated: GatedOperatorMarket, plans: &[CoinOpPlan], watched_coin_ids: &HashSet, - watched_p2s: &HashSet, #[cfg(test)] test_overrides: CoinOpTestOverrides, ) -> CoinOpExecutionResult { if gated.market_row.receive_address.trim().is_empty() { @@ -110,7 +105,6 @@ async fn execute_managed_coin_op_plans_async( gated, None, watched_coin_ids.iter().cloned().collect(), - watched_p2s.iter().cloned().collect(), #[cfg(test)] test_overrides, ) diff --git a/greenfloor-engine/src/coin_ops/execution/managed/tests.rs b/greenfloor-engine/src/coin_ops/execution/managed/tests.rs index 5bc72455..624a70a6 100644 --- a/greenfloor-engine/src/coin_ops/execution/managed/tests.rs +++ b/greenfloor-engine/src/coin_ops/execution/managed/tests.rs @@ -68,7 +68,6 @@ fn test_exec_context( base_unit_mojo_multiplier: 1_000, combine_input_cap: resolve_combine_input_cap(), watched_coin_ids: HashSet::new(), - watched_p2s: HashSet::new(), test_overrides: CoinOpTestOverrides::new( Some(spendable), mixed_split_operation_id.map(str::to_string), @@ -112,7 +111,6 @@ async fn execute_managed_coin_op_plans_skips_when_receive_address_missing() { sample_gated_market(bundle.program, bundle.signer, &market, empty_index), &plans, &HashSet::::default(), - &HashSet::::default(), ) .await; @@ -142,7 +140,6 @@ async fn execute_managed_coin_op_plans_dry_run_plans_without_execution() { sample_gated_market(bundle.program, bundle.signer, &market, empty_index), &plans, &HashSet::::default(), - &HashSet::::default(), ) .await; @@ -169,7 +166,6 @@ async fn execute_managed_coin_op_plans_skips_invalid_plans() { sample_gated_market(bundle.program, bundle.signer, &market, empty_index), &plans, &HashSet::::default(), - &HashSet::::default(), ) .await; @@ -194,7 +190,6 @@ async fn execute_managed_coin_op_plans_executes_split_and_combine_via_runner_ove sample_gated_market(bundle.program, bundle.signer, &market, empty_index), &plans, &HashSet::::default(), - &HashSet::::default(), CoinOpTestOverrides::new( Some(vec![ SpendableCoin::new(test_coin_id('a'), 100_000), @@ -223,7 +218,7 @@ async fn execute_managed_coin_op_plans_executes_split_and_combine_via_runner_ove } #[tokio::test] -async fn execute_managed_coin_op_plans_skips_when_spendable_matches_watched_p2() { +async fn execute_managed_coin_op_plans_ignores_p2_watches_for_spendable_exclusion() { let dir = tempfile::tempdir().expect("tempdir"); let bundle = minimal_program_bundle(&dir); let mut market = sample_market("xch1test"); @@ -232,71 +227,70 @@ async fn execute_managed_coin_op_plans_skips_when_spendable_matches_watched_p2() sample_plan(CoinOpKind::Split), sample_plan(CoinOpKind::Combine), ]; - let maker_p2 = "ef".repeat(32); - let watched_p2s = HashSet::from([maker_p2.clone()]); + // Shared inventory-style p2 must not lock every coin; coin-id watches do. + let shared_p2 = "ef".repeat(32); let empty_index = empty_cat_ticker_index(); let result = execute_managed_coin_op_plans_with_test_overrides( sample_gated_market(bundle.program, bundle.signer, &market, empty_index), &plans, &HashSet::::default(), - &watched_p2s, CoinOpTestOverrides::new( Some(vec![ - SpendableCoin::with_puzzle_hash(test_coin_id('a'), 100_000, maker_p2.clone()), - SpendableCoin::with_puzzle_hash(test_coin_id('b'), 10_000, maker_p2.clone()), - SpendableCoin::with_puzzle_hash(test_coin_id('c'), 10_000, maker_p2), + SpendableCoin::with_puzzle_hash(test_coin_id('a'), 100_000, shared_p2.clone()), + SpendableCoin::with_puzzle_hash(test_coin_id('b'), 10_000, shared_p2.clone()), + SpendableCoin::with_puzzle_hash(test_coin_id('c'), 10_000, shared_p2), ]), Some("managed-op-test".to_string()), ), ) .await; - assert_eq!(result.executed_count, 0); - assert!(result.items.iter().any(|item| { - item.op_type == "split" && item.reason == "no_spendable_split_coin_available" - })); - assert!(result.items.iter().any(|item| { - item.op_type == "combine" && item.reason == "no_spendable_combine_coin_available" - })); + assert_eq!(result.executed_count, 2); + assert!(result + .items + .iter() + .any(|item| item.reason == "signer_split_submitted")); + assert!(result + .items + .iter() + .any(|item| item.reason == "signer_combine_submitted")); } #[tokio::test] -async fn execute_managed_coin_op_plans_excludes_empty_puzzle_hash_when_p2_watches_exist() { +async fn execute_managed_combine_locks_only_watched_coin_ids() { let dir = tempfile::tempdir().expect("tempdir"); let bundle = minimal_program_bundle(&dir); let mut market = sample_market("xch1test"); market.base_asset = test_coin_id('f'); - let plans = vec![ - sample_plan(CoinOpKind::Split), - sample_plan(CoinOpKind::Combine), - ]; - let watched_p2s = HashSet::from(["ef".repeat(32)]); + let locked = test_coin_id('a'); + let free_b = test_coin_id('b'); + let free_c = test_coin_id('c'); + let watched_coins = HashSet::from([locked.clone()]); + let shared_p2 = "ef".repeat(32); + let plans = vec![sample_plan(CoinOpKind::Combine)]; let empty_index = empty_cat_ticker_index(); let result = execute_managed_coin_op_plans_with_test_overrides( sample_gated_market(bundle.program, bundle.signer, &market, empty_index), &plans, - &HashSet::::default(), - &watched_p2s, + &watched_coins, CoinOpTestOverrides::new( Some(vec![ - // Empty puzzle_hash must fail closed when maker p2 watches exist. - SpendableCoin::new(test_coin_id('a'), 100_000), - SpendableCoin::new(test_coin_id('b'), 10_000), - SpendableCoin::new(test_coin_id('c'), 10_000), + SpendableCoin::with_puzzle_hash(locked, 10_000, shared_p2.clone()), + SpendableCoin::with_puzzle_hash(free_b, 10_000, shared_p2.clone()), + SpendableCoin::with_puzzle_hash(free_c, 10_000, shared_p2), ]), Some("managed-op-test".to_string()), ), ) .await; - assert_eq!(result.executed_count, 0); - assert!(result.items.iter().any(|item| { - item.op_type == "split" && item.reason == "no_spendable_split_coin_available" - })); + assert_eq!(result.executed_count, 1); assert!(result.items.iter().any(|item| { - item.op_type == "combine" && item.reason == "no_spendable_combine_coin_available" + item.op_type == "combine" + && item.status == "executed" + && item.reason == "signer_combine_submitted" })); } @@ -317,7 +311,6 @@ async fn execute_managed_coin_op_plans_skips_when_all_spendable_coins_are_watche sample_gated_market(bundle.program, bundle.signer, &market, empty_index), &plans, &watched, - &HashSet::::default(), CoinOpTestOverrides::new( Some(vec![ SpendableCoin::new(test_coin_id('a'), 100_000), diff --git a/greenfloor-engine/src/daemon/coin_ops_phase/mod.rs b/greenfloor-engine/src/daemon/coin_ops_phase/mod.rs index df4bc6b6..a32aede0 100644 --- a/greenfloor-engine/src/daemon/coin_ops_phase/mod.rs +++ b/greenfloor-engine/src/daemon/coin_ops_phase/mod.rs @@ -149,18 +149,17 @@ async fn execute_coin_ops_plans( }); } - // Durable watches: coin ids + on-chain maker puzzle hashes (local exclude). + // Durable maker coin-id watches (local spend exclude). Per-offer p2 watches + // are WS-only; Direct inventory hashes must not lock coin-ops selection. let watched_coin_ids = store.list_watched_coin_ids_for_market(&market.market_id)?; - let watched_p2s = store.list_watched_p2s_for_market(&market.market_id)?; match ctx.gated_market(market) { - Ok(gated) => Ok(execute_managed_coin_op_plans( - gated, - &planning.executable_plans, - &watched_coin_ids, - &watched_p2s, - ) - .await), + Ok(gated) => { + Ok( + execute_managed_coin_op_plans(gated, &planning.executable_plans, &watched_coin_ids) + .await, + ) + } Err(err) => Ok(skipped_coin_ops_result( program, market, diff --git a/greenfloor-engine/src/manager_cli/coin_op_loop/context.rs b/greenfloor-engine/src/manager_cli/coin_op_loop/context.rs index 77825750..ccbb73f4 100644 --- a/greenfloor-engine/src/manager_cli/coin_op_loop/context.rs +++ b/greenfloor-engine/src/manager_cli/coin_op_loop/context.rs @@ -15,24 +15,21 @@ pub(super) const COIN_SPLIT_LOCKUP_ERROR: &str = "coin_split_lockup_guardrail_would_lock_all_spendable_coins"; pub(super) const COIN_SPLIT_NO_SPENDABLE_ERROR: &str = "no_spendable_split_coin_available"; -/// Load durable maker watches for CLI coin-ops (same sets as daemon). +/// Load durable maker coin-id watches for CLI coin-ops (same set as daemon). /// /// Exclusion is applied inside [`CoinOpExecContext::list_spendable_coins`], which /// both the until-ready loop and daemon runners use for selection. -fn load_market_watch_sets( +fn load_market_watched_coin_ids( home_dir: &std::path::Path, state_db_override: Option<&str>, market_id: &str, -) -> SignerResult<(HashSet, HashSet)> { +) -> SignerResult> { let db_path = resolve_state_db_path(home_dir, state_db_override); if !db_path.exists() { - return Ok((HashSet::default(), HashSet::default())); + return Ok(HashSet::default()); } let store = SqliteStore::open(&db_path)?; - Ok(( - store.list_watched_coin_ids_for_market(market_id)?, - store.list_watched_p2s_for_market(market_id)?, - )) + store.list_watched_coin_ids_for_market(market_id) } pub(super) async fn build_coin_op_exec_context( @@ -41,7 +38,7 @@ pub(super) async fn build_coin_op_exec_context( state_db_override: Option<&str>, ) -> SignerResult { let gated = load_gated_operator_market(request)?; - let (watched_coin_ids, watched_p2s) = load_market_watch_sets( + let watched_coin_ids = load_market_watched_coin_ids( &gated.program.home_dir, state_db_override, &gated.market_row.market_id, @@ -50,7 +47,6 @@ pub(super) async fn build_coin_op_exec_context( gated, asset_id_override, watched_coin_ids, - watched_p2s, #[cfg(test)] CoinOpTestOverrides::default(), ) @@ -107,7 +103,7 @@ mod tests { use tempfile::tempdir; #[test] - fn load_market_watch_sets_reads_durable_watches_when_db_exists() { + fn load_market_watched_coin_ids_reads_durable_watches_when_db_exists() { let dir = tempdir().expect("tempdir"); let home = dir.path(); let db_path = resolve_state_db_path(home, None); @@ -123,16 +119,16 @@ mod tests { std::slice::from_ref(&p2), ) .expect("ensure"); - let (coins, p2s) = load_market_watch_sets(home, None, "m1").expect("load"); + let coins = load_market_watched_coin_ids(home, None, "m1").expect("load"); assert!(coins.contains(&coin)); - assert!(p2s.contains(&p2)); + // Coin-ops ignores p2 watches; loader must not require them. + assert!(!coins.contains(&p2)); } #[test] - fn load_market_watch_sets_empty_when_db_missing() { + fn load_market_watched_coin_ids_empty_when_db_missing() { let dir = tempdir().expect("tempdir"); - let (coins, p2s) = load_market_watch_sets(dir.path(), None, "m1").expect("missing db ok"); + let coins = load_market_watched_coin_ids(dir.path(), None, "m1").expect("missing db ok"); assert!(coins.is_empty()); - assert!(p2s.is_empty()); } } 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 d9fc31b0..4210db96 100644 --- a/greenfloor-engine/src/offer/lifecycle/reconcile_prep/watch_plan.rs +++ b/greenfloor-engine/src/offer/lifecycle/reconcile_prep/watch_plan.rs @@ -13,6 +13,7 @@ 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::types::OfferExecutionMode; use crate::storage::OfferStateListRow; use crate::storage::SqliteStore; @@ -75,14 +76,20 @@ fn heal_watches_from_local_metadata( { coins.push(coin); } - if let Some(p2) = meta - .fields - .maker_puzzle_hash - .as_deref() - .map(normalize_hex_id) - .filter(|value| value.len() == 64) + // Direct maker_puzzle_hash is shared vault inventory — never per-offer p2. + if meta + .execution_mode + .is_some_and(OfferExecutionMode::seeds_per_offer_maker_p2_watch) { - p2s.push(p2); + 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); @@ -174,7 +181,16 @@ pub fn ensure_watches_from_dexie_payload( offer_id: &str, raw: &Value, ) -> SignerResult<()> { - let (coin_ids, p2s) = maker_watch_keys_from_dexie_payload(raw); + let (coin_ids, payload_p2s) = maker_watch_keys_from_dexie_payload(raw); + // Seed p2 watches only for presplit offers. Direct cancellable inputs share + // vault inventory puzzle hashes (ADR 0019); coin-id watches are enough. + let p2s = match store + .offer_cancel_metadata_for_id(offer_id)? + .and_then(|meta| meta.execution_mode) + { + Some(mode) if mode.seeds_per_offer_maker_p2_watch() => payload_p2s, + _ => Vec::new(), + }; if !coin_ids.is_empty() || !p2s.is_empty() { store.ensure_offer_coin_watches(offer_id, market_id, &coin_ids, &p2s)?; } @@ -294,6 +310,46 @@ mod tests { .contains(&"ef".repeat(32))); } + #[test] + fn classify_heals_direct_coin_only_without_inventory_p2() { + let dir = tempdir().expect("tempdir"); + let store = SqliteStore::open(&dir.path().join("state.db")).expect("open"); + let offer_id = "ab".repeat(32); + let coin = "cd".repeat(32); + let inventory_p2 = "ef".repeat(32); + let fields = OfferCancelFields { + input_coin_id: Some(coin.clone()), + fixed_delegated_puzzle_hash: None, + maker_puzzle_hash: Some(inventory_p2.clone()), + }; + store + .upsert_offer_state_with_metadata_at( + &offer_id, + "m1", + "open", + None, + &chrono::Utc::now().to_rfc3339(), + OfferCancelWrite { + fields: Some(&fields), + execution_mode: Some(OfferExecutionMode::Direct), + listing: OfferListingWrite::venue(None), + ..OfferCancelWrite::default() + }, + ) + .expect("upsert"); + let local = prepare_market_reconcile_local(&store, "m1").expect("plan"); + assert!(local.dexie.heal_only.is_empty()); + assert!(store.offer_has_coin_watches(&offer_id).expect("healed")); + assert!(store + .list_watched_coin_ids_for_market("m1") + .expect("coins") + .contains(&coin)); + assert!(!store + .list_watched_p2s_for_market("m1") + .expect("p2s") + .contains(&inventory_p2)); + } + #[test] fn classify_skips_coinset_for_heal_only() { let dir = tempdir().expect("tempdir"); 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 39a45780..5cb2ca43 100644 --- a/greenfloor-engine/src/offer/operator/build_and_post/publish.rs +++ b/greenfloor-engine/src/offer/operator/build_and_post/publish.rs @@ -119,17 +119,16 @@ pub(super) fn offer_post_persist_record( } watched_coin_ids.sort(); watched_coin_ids.dedup(); - let mut watched_p2s = Vec::new(); - // On-chain maker puzzle hash only (not fixed_delegated CONDITIONS hash). - if let Some(p2) = cancel_fields.maker_puzzle_hash.clone() { - watched_p2s.push(p2); - } - // Do not seed shared market inventory receive/CAT outer p2s into per-offer - // watches: those hashes are common to every open offer on the market and would - // promote all of them on any deposit/spend. Inventory freshness uses - // InventoryP2Index separately; lifecycle watch hits need maker-specific keys. - watched_p2s.sort(); - watched_p2s.dedup(); + // Per-offer p2 watches: presplit CONDITIONS only. Direct maker_puzzle_hash is + // the shared vault inventory hash (ADR 0019) — InventoryP2Index owns those. + let watched_p2s = match execution_mode { + Some(mode) if mode.seeds_per_offer_maker_p2_watch() => cancel_fields + .maker_puzzle_hash + .clone() + .into_iter() + .collect(), + _ => Vec::new(), + }; Some(OfferPostPersistRecord { offer_id, market_id: ctx.gated.market_row.market_id.clone(), 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 755b56e4..1754fee1 100644 --- a/greenfloor-engine/src/offer/operator/build_and_post/tests.rs +++ b/greenfloor-engine/src/offer/operator/build_and_post/tests.rs @@ -210,7 +210,12 @@ fn offer_post_persist_record_requires_success_and_offer_id() { ); assert!(direct.cancel_fields.fixed_delegated_puzzle_hash.is_none()); assert_eq!(direct.watched_coin_ids, vec![direct_coin]); - assert_eq!(direct.watched_p2s, vec![direct_p2]); + // Direct maker_puzzle_hash is shared inventory — not a per-offer p2 watch. + assert!(direct.watched_p2s.is_empty()); + assert_eq!( + direct.cancel_fields.maker_puzzle_hash.as_deref(), + Some(direct_p2.as_str()) + ); assert!(crate::offer::metadata_sufficient_for_coinset_cancel(Some( &crate::offer::types::StoredOfferCancelMetadata { fields: direct.cancel_fields.clone(), diff --git a/greenfloor-engine/src/offer/types.rs b/greenfloor-engine/src/offer/types.rs index 024adbae..b70eccbf 100644 --- a/greenfloor-engine/src/offer/types.rs +++ b/greenfloor-engine/src/offer/types.rs @@ -256,6 +256,16 @@ impl OfferExecutionMode { _ => None, } } + + /// Whether per-offer `kind='p2'` watches should be seeded from `maker_puzzle_hash`. + /// + /// Direct receive coins share the vault inventory puzzle hash (CAT outer or XCH + /// inner). Those belong in `InventoryP2Index`, not per-offer watches (ADR 0019). + /// Presplit offer inputs use a unique CONDITIONS puzzle hash and may be watched. + #[must_use] + pub const fn seeds_per_offer_maker_p2_watch(self) -> bool { + matches!(self, Self::PresplitNew | Self::PresplitExisting) + } } /// Cancel hints persisted at offer post time (Direct and presplit execution modes). @@ -268,7 +278,9 @@ pub struct OfferCancelFields { pub input_coin_id: Option, /// Fixed CONDITIONS tree hash (cancel/reclaim verification). Not an on-chain coin p2. pub fixed_delegated_puzzle_hash: Option, - /// On-chain maker coin puzzle hash (CAT outer or XCH p2) for WS / coin-ops watches. + /// 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 + /// for presplit modes ([`OfferExecutionMode::seeds_per_offer_maker_p2_watch`]). pub maker_puzzle_hash: Option, } From c0c525f4e1e1538e6df5d90cd96e25e92046f126 Mon Sep 17 00:00:00 2001 From: Gene Hoffman Date: Tue, 11 Aug 2026 15:03:32 -0700 Subject: [PATCH 2/2] Unify presplit-like metadata for watch seeding and cancel. Share one is_presplit_like gate (including legacy NULL mode + fixed_delegated) so heal/Dexie/post seed CONDITIONS p2 watches consistently with cancel policy. --- .../0019-coinset-ws-local-watches.md | 16 ++-- greenfloor-engine/src/offer/cancel_input.rs | 11 +-- .../lifecycle/reconcile_prep/watch_plan.rs | 58 +++++++++++--- .../offer/operator/build_and_post/publish.rs | 16 ++-- greenfloor-engine/src/offer/types.rs | 77 ++++++++++++++++--- 5 files changed, 134 insertions(+), 44 deletions(-) diff --git a/docs/decisions/0019-coinset-ws-local-watches.md b/docs/decisions/0019-coinset-ws-local-watches.md index 1d53bcf4..940c1cf0 100644 --- a/docs/decisions/0019-coinset-ws-local-watches.md +++ b/docs/decisions/0019-coinset-ws-local-watches.md @@ -31,9 +31,10 @@ never the operator transport. HTTP `get_transaction` polling supplements WS by confirming prepared `cancel_submitted` transaction ids during recovery and every cycle preamble. 3. **Watches:** durable SQLite `offer_coin_watches` registered atomically at post - (maker **coin ids** always; per-offer maker **p2** only for presplit CONDITIONS - puzzle hashes via `OfferExecutionMode::seeds_per_offer_maker_p2_watch`). Direct - `maker_puzzle_hash` (CAT outer / XCH receive inner) is cancel metadata only — + (maker **coin ids** always; per-offer maker **p2** only when cancel metadata is + presplit-like via `StoredOfferCancelMetadata::is_presplit_like` — explicit + Presplit, or legacy NULL `execution_mode` + non-empty `fixed_delegated_puzzle_hash`). + Direct `maker_puzzle_hash` (CAT outer / XCH receive inner) is cancel metadata only — shared inventory hashes stay on `InventoryP2Index`, not per-offer watches. Sourced from required `OfferCancelFields` on every successful create (Direct: single exact-size maker coin; presplit: split coin + fixed CONDITIONS hash). @@ -99,11 +100,10 @@ never the operator transport. `schema_meta` (`watch_venue_backfill_v2`), and healed each reconcile via `prepare_market_reconcile_local` + heal-only Dexie fetch. Coin-ops excludes durable `kind='coin'` watch ids only inside `list_spendable_coins`. Per-offer `kind='p2'` - watches are WS lifecycle / inventory-stale signals: Direct offers never seed them - (`OfferExecutionMode::seeds_per_offer_maker_p2_watch` is false) because - `maker_puzzle_hash` is the shared vault inventory hash; presplit may seed the - unique CONDITIONS puzzle hash. Explicit CLI coin ids are refused when they match - durable maker coin watches. + watches are WS lifecycle / inventory-stale signals: seeded only when metadata is + `is_presplit_like` (unique CONDITIONS puzzle hash). Direct never seeds them because + `maker_puzzle_hash` is the shared vault inventory hash. Explicit CLI coin ids are + refused when they match durable maker coin watches. ## Consequences diff --git a/greenfloor-engine/src/offer/cancel_input.rs b/greenfloor-engine/src/offer/cancel_input.rs index 3c73f1c8..3fe063cf 100644 --- a/greenfloor-engine/src/offer/cancel_input.rs +++ b/greenfloor-engine/src/offer/cancel_input.rs @@ -74,6 +74,9 @@ pub(crate) fn stored_presplit_fields( metadata: Option<&StoredOfferCancelMetadata>, ) -> Option<&OfferCancelFields> { let metadata = metadata?; + if !metadata.is_presplit_like() { + return None; + } let hash = metadata .fields .fixed_delegated_puzzle_hash @@ -82,13 +85,7 @@ pub(crate) fn stored_presplit_fields( if hash.is_empty() { return None; } - match metadata.execution_mode { - Some(OfferExecutionMode::Direct) => None, - Some(OfferExecutionMode::PresplitNew | OfferExecutionMode::PresplitExisting) => { - Some(&metadata.fields) - } - None => Some(&metadata.fields), - } + Some(&metadata.fields) } /// Whether stored cancel metadata can drive Coinset-primary cancel without an offer file. 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 4210db96..14d21442 100644 --- a/greenfloor-engine/src/offer/lifecycle/reconcile_prep/watch_plan.rs +++ b/greenfloor-engine/src/offer/lifecycle/reconcile_prep/watch_plan.rs @@ -13,7 +13,6 @@ 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::types::OfferExecutionMode; use crate::storage::OfferStateListRow; use crate::storage::SqliteStore; @@ -77,10 +76,8 @@ fn heal_watches_from_local_metadata( coins.push(coin); } // Direct maker_puzzle_hash is shared vault inventory — never per-offer p2. - if meta - .execution_mode - .is_some_and(OfferExecutionMode::seeds_per_offer_maker_p2_watch) - { + // 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 @@ -182,13 +179,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 offers. Direct cancellable inputs share - // vault inventory puzzle hashes (ADR 0019); coin-id watches are enough. - let p2s = match store - .offer_cancel_metadata_for_id(offer_id)? - .and_then(|meta| meta.execution_mode) - { - Some(mode) if mode.seeds_per_offer_maker_p2_watch() => payload_p2s, + // 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() { @@ -350,6 +344,46 @@ mod tests { .contains(&inventory_p2)); } + #[test] + fn classify_heals_null_mode_presplit_seeds_maker_p2() { + let dir = tempdir().expect("tempdir"); + let store = SqliteStore::open(&dir.path().join("state.db")).expect("open"); + let offer_id = "ab".repeat(32); + let coin = "cd".repeat(32); + let conditions_p2 = "ef".repeat(32); + let fields = OfferCancelFields { + input_coin_id: Some(coin.clone()), + fixed_delegated_puzzle_hash: Some("aa".repeat(32)), + maker_puzzle_hash: Some(conditions_p2.clone()), + }; + store + .upsert_offer_state_with_metadata_at( + &offer_id, + "m1", + "open", + None, + &chrono::Utc::now().to_rfc3339(), + OfferCancelWrite { + fields: Some(&fields), + execution_mode: None, + listing: OfferListingWrite::venue(None), + ..OfferCancelWrite::default() + }, + ) + .expect("upsert"); + let local = prepare_market_reconcile_local(&store, "m1").expect("plan"); + assert!(local.dexie.heal_only.is_empty()); + assert!(store.offer_has_coin_watches(&offer_id).expect("healed")); + assert!(store + .list_watched_coin_ids_for_market("m1") + .expect("coins") + .contains(&coin)); + assert!(store + .list_watched_p2s_for_market("m1") + .expect("p2s") + .contains(&conditions_p2)); + } + #[test] fn classify_skips_coinset_for_heal_only() { let dir = tempdir().expect("tempdir"); 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 5cb2ca43..60f59dae 100644 --- a/greenfloor-engine/src/offer/operator/build_and_post/publish.rs +++ b/greenfloor-engine/src/offer/operator/build_and_post/publish.rs @@ -119,15 +119,19 @@ pub(super) fn offer_post_persist_record( } watched_coin_ids.sort(); watched_coin_ids.dedup(); - // Per-offer p2 watches: presplit CONDITIONS only. Direct maker_puzzle_hash is - // the shared vault inventory hash (ADR 0019) — InventoryP2Index owns those. - let watched_p2s = match execution_mode { - Some(mode) if mode.seeds_per_offer_maker_p2_watch() => cancel_fields + // 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(), - _ => Vec::new(), + .collect() + } else { + Vec::new() }; Some(OfferPostPersistRecord { offer_id, diff --git a/greenfloor-engine/src/offer/types.rs b/greenfloor-engine/src/offer/types.rs index b70eccbf..6fd71c58 100644 --- a/greenfloor-engine/src/offer/types.rs +++ b/greenfloor-engine/src/offer/types.rs @@ -256,16 +256,6 @@ impl OfferExecutionMode { _ => None, } } - - /// Whether per-offer `kind='p2'` watches should be seeded from `maker_puzzle_hash`. - /// - /// Direct receive coins share the vault inventory puzzle hash (CAT outer or XCH - /// inner). Those belong in `InventoryP2Index`, not per-offer watches (ADR 0019). - /// Presplit offer inputs use a unique CONDITIONS puzzle hash and may be watched. - #[must_use] - pub const fn seeds_per_offer_maker_p2_watch(self) -> bool { - matches!(self, Self::PresplitNew | Self::PresplitExisting) - } } /// Cancel hints persisted at offer post time (Direct and presplit execution modes). @@ -280,7 +270,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 - /// for presplit modes ([`OfferExecutionMode::seeds_per_offer_maker_p2_watch`]). + /// when [`StoredOfferCancelMetadata::is_presplit_like`] is true. pub maker_puzzle_hash: Option, } @@ -316,6 +306,37 @@ 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, @@ -431,6 +452,40 @@ mod tests { assert!(OfferExecutionMode::parse_db("unknown").is_none()); } + #[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( + 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 + )); + + let legacy = StoredOfferCancelMetadata { + fields: OfferCancelFields::from_presplit_build( + "coin".into(), + "aa".repeat(32), + "bb".repeat(32), + ), + execution_mode: None, + }; + assert!(legacy.is_presplit_like()); + let direct = StoredOfferCancelMetadata { + fields: OfferCancelFields::from_direct_build("coin".into(), "bb".repeat(32)), + execution_mode: Some(OfferExecutionMode::Direct), + }; + assert!(!direct.is_presplit_like()); + } + #[test] fn from_direct_build_omits_fixed_delegated_hash() { let fields = OfferCancelFields::from_direct_build("aa".repeat(32), "bb".repeat(32));