rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

commit 8fc86d2255de5844c25e04f50c60240a9e8e3a79
parent 654283c48b01b34c371f69fe36a692d2f5273083
Author: triesap <tyson@radroots.org>
Date:   Fri, 26 Jun 2026 07:16:41 +0000

trade: use shared workflow finality in RHI

- replace RHI order status transitions with the shared trade workflow reducer
- keep validation receipts as service artifacts while preserving seen-event state
- require pending RHI agreement before publishing trade validation receipts
- validate with cargo test --manifest-path domains/radroots/rhi/Cargo.toml -- --test-threads=1

Diffstat:
Msrc/features/trade_listing/handlers/dvm.rs | 605+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Msrc/features/trade_listing/state.rs | 39++++++++++-----------------------------
Msrc/features/trade_validation_receipt.rs | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
3 files changed, 519 insertions(+), 326 deletions(-)

diff --git a/src/features/trade_listing/handlers/dvm.rs b/src/features/trade_listing/handlers/dvm.rs @@ -5,16 +5,12 @@ use std::{sync::Arc, time::Duration}; use radroots_events::farm::RadrootsFarmRef; use radroots_events::kinds::{ - KIND_FARM, KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_FULFILLMENT_UPDATE, - KIND_ORDER_PAYMENT_RECORD, KIND_ORDER_RECEIPT, KIND_ORDER_REQUEST, - KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL, KIND_ORDER_SETTLEMENT_DECISION, + KIND_FARM, KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST, + KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL, KIND_TRADE_LISTING_VALIDATION_REQUEST, KIND_TRADE_LISTING_VALIDATION_RESULT, - KIND_TRADE_TRANSITION_PROOF_REQUEST, KIND_TRADE_TRANSITION_PROOF_RESULT, is_listing_kind, - is_order_event_kind, is_trade_validation_service_event_kind, -}; -use radroots_events::order::{ - RadrootsOrderDecisionOutcome, RadrootsOrderFulfillmentState, RadrootsOrderReceipt, - RadrootsOrderRevisionOutcome, + KIND_TRADE_TRANSITION_PROOF_REQUEST, KIND_TRADE_TRANSITION_PROOF_RESULT, + KIND_TRADE_VALIDATION_RECEIPT, is_listing_kind, is_order_event_kind, + is_trade_validation_service_event_kind, }; use radroots_events::trade_validation::{ RadrootsTradeValidationListingError as TradeListingValidationError, @@ -22,11 +18,7 @@ use radroots_events::trade_validation::{ RadrootsTradeValidationListingResult as TradeListingValidateResult, }; use radroots_events_codec::order::{ - RadrootsOrderEnvelopeParseError, order_cancellation_from_event, order_decision_from_event, - order_fulfillment_update_from_event, order_payment_record_from_event, order_receipt_from_event, - order_request_from_event, order_revision_decision_from_event, - order_revision_proposal_from_event, order_settlement_decision_from_event, - parse_order_listing_event_tag, parse_order_prev_tag, parse_order_root_tag, + RadrootsOrderEnvelopeParseError, parse_order_listing_event_tag, }; use radroots_nostr::prelude::{ RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrFilter, @@ -37,10 +29,15 @@ use radroots_nostr::prelude::{ use radroots_trade::listing::{ parse_listing_address, parse_public_listing_address, validation::validate_listing_event, }; +use radroots_trade::order::{ + RadrootsOrderEventRecord, RadrootsOrderProjection, order_event_record_from_event, + reduce_order_event_records, +}; +use radroots_trade::workflow::RadrootsTradeWorkflowState; use thiserror::Error; use crate::features::trade_listing::state::{ - TradeListingState, TradeListingStateError, TradeOrderState, TradeOrderStatus, + TradeListingState, TradeListingStateError, TradeOrderState, }; use crate::features::trade_validation_receipt::{ TradeValidationReceiptJobError, TradeValidationReceiptProverPolicy, @@ -65,6 +62,8 @@ pub enum TradeListingDvmError { InvalidListingAddr, #[error("invalid order request payload")] InvalidOrder, + #[error("shared workflow rejected trade transition: {0}")] + Workflow(String), #[error("state error: {0}")] State(#[from] TradeListingStateError), #[error("nostr error: {0}")] @@ -266,7 +265,10 @@ pub async fn handle_event_with_policy( ensure_service_recipient(&event, &keys)?; return handle_listing_validate_request(&event, &client, &state).await; } - if kind == KIND_TRADE_LISTING_VALIDATION_RESULT || kind == KIND_TRADE_TRANSITION_PROOF_RESULT { + if kind == KIND_TRADE_LISTING_VALIDATION_RESULT + || kind == KIND_TRADE_TRANSITION_PROOF_RESULT + || kind == KIND_TRADE_VALIDATION_RECEIPT + { state .lock() .await @@ -472,14 +474,10 @@ async fn handle_order_event( ) -> Result<(), TradeListingDvmError> { match kind { KIND_ORDER_REQUEST => handle_order_request(event, client, state).await, - KIND_ORDER_DECISION => handle_order_decision(event, state).await, - KIND_ORDER_REVISION_PROPOSAL => handle_order_revision_proposal(event, state).await, - KIND_ORDER_REVISION_DECISION => handle_order_revision_decision(event, state).await, - KIND_ORDER_CANCELLATION => handle_order_cancellation(event, state).await, - KIND_ORDER_FULFILLMENT_UPDATE => handle_order_fulfillment_update(event, state).await, - KIND_ORDER_RECEIPT => handle_order_receipt(event, state).await, - KIND_ORDER_PAYMENT_RECORD => handle_order_payment_record(event, state).await, - KIND_ORDER_SETTLEMENT_DECISION => handle_order_settlement_decision(event, state).await, + KIND_ORDER_DECISION + | KIND_ORDER_REVISION_PROPOSAL + | KIND_ORDER_REVISION_DECISION + | KIND_ORDER_CANCELLATION => handle_order_workflow_event(event, client, state).await, _ => Err(TradeListingDvmError::UnsupportedKind), } } @@ -490,30 +488,41 @@ async fn handle_order_request( state: &Arc<tokio::sync::Mutex<TradeListingState>>, ) -> Result<(), TradeListingDvmError> { let rr_event = radroots_event_from_nostr(event); - let envelope = order_request_from_event(&rr_event).map_err(map_order_parse_error)?; - let listing_addr = parse_public_listing_address(&envelope.listing_addr) + let record = order_event_record_from_event(&rr_event).map_err(map_order_decode_error)?; + let order_id = record.order_id().clone(); + let projection = reduce_order_event_records(&order_id, [record.clone()]); + if projection.status != RadrootsTradeWorkflowState::Requested || !projection.issues.is_empty() { + return Err(TradeListingDvmError::Workflow(workflow_rejection_message( + &projection, + ))); + } + let RadrootsOrderEventRecord::Request(request) = record else { + return Err(TradeListingDvmError::UnsupportedKind); + }; + let listing_addr = parse_public_listing_address(&request.payload.listing_addr) .map_err(|_| TradeListingDvmError::InvalidListingAddr)?; - if envelope.payload.seller_pubkey != listing_addr.seller_pubkey { + if request.payload.seller_pubkey != listing_addr.seller_pubkey { return Err(TradeListingDvmError::InvalidListingAddr); } let listing_event = parse_order_listing_event_tag(&rr_event.tags) .map_err(|error| TradeListingDvmError::InvalidPayload(error.to_string()))? .ok_or(TradeListingDvmError::MissingTag("listing_event"))?; let listing_snapshot_event_id = - ensure_listing_snapshot(&envelope.listing_addr, &listing_event, client, state).await?; + ensure_listing_snapshot(&request.payload.listing_addr, &listing_event, client, state) + .await?; let event_id = event.id.to_string(); let mut state = state.lock().await; - if state.order_exists(&envelope.order_id) { + if state.order_exists(order_id.as_str()) { return Ok(()); } let mut seen = std::collections::HashSet::new(); seen.insert(event_id.clone()); state.insert_order(TradeOrderState { - order_id: envelope.order_id, - listing_addr: envelope.payload.listing_addr.to_string(), - buyer_pubkey: envelope.payload.buyer_pubkey.to_string(), - seller_pubkey: envelope.payload.seller_pubkey.to_string(), - status: TradeOrderStatus::Requested, + order_id: order_id.to_string(), + listing_addr: request.payload.listing_addr.to_string(), + buyer_pubkey: request.payload.buyer_pubkey.to_string(), + seller_pubkey: request.payload.seller_pubkey.to_string(), + status: projection.status, listing_snapshot_event_id: Some(listing_snapshot_event_id), root_event_id: Some(event_id.clone()), last_event_id: Some(event_id), @@ -546,268 +555,116 @@ async fn ensure_listing_snapshot( Ok(listing_event.id.clone()) } -async fn handle_order_decision( - event: &RadrootsNostrEvent, - state: &Arc<tokio::sync::Mutex<TradeListingState>>, -) -> Result<(), TradeListingDvmError> { - let rr_event = radroots_event_from_nostr(event); - let envelope = order_decision_from_event(&rr_event).map_err(map_order_parse_error)?; - let next_status = match envelope.payload.decision { - RadrootsOrderDecisionOutcome::Accepted { .. } => TradeOrderStatus::Accepted, - RadrootsOrderDecisionOutcome::Declined { .. } => TradeOrderStatus::Declined, - }; - update_existing_order(event, &rr_event.tags, state, &envelope.order_id, |order| { - ensure_order_binding( - order, - &envelope.payload.listing_addr, - &envelope.payload.buyer_pubkey, - &envelope.payload.seller_pubkey, - )?; - ensure_transition(&order.status, &next_status)?; - order.status = next_status; - Ok(()) - }) - .await -} - -async fn handle_order_revision_proposal( - event: &RadrootsNostrEvent, - state: &Arc<tokio::sync::Mutex<TradeListingState>>, -) -> Result<(), TradeListingDvmError> { - let rr_event = radroots_event_from_nostr(event); - let envelope = order_revision_proposal_from_event(&rr_event).map_err(map_order_parse_error)?; - update_existing_order(event, &rr_event.tags, state, &envelope.order_id, |order| { - ensure_order_binding( - order, - &envelope.payload.listing_addr, - &envelope.payload.buyer_pubkey, - &envelope.payload.seller_pubkey, - )?; - ensure_transition(&order.status, &TradeOrderStatus::Accepted)?; - Ok(()) - }) - .await -} - -async fn handle_order_revision_decision( - event: &RadrootsNostrEvent, - state: &Arc<tokio::sync::Mutex<TradeListingState>>, -) -> Result<(), TradeListingDvmError> { - let rr_event = radroots_event_from_nostr(event); - let envelope = order_revision_decision_from_event(&rr_event).map_err(map_order_parse_error)?; - update_existing_order(event, &rr_event.tags, state, &envelope.order_id, |order| { - ensure_order_binding( - order, - &envelope.payload.listing_addr, - &envelope.payload.buyer_pubkey, - &envelope.payload.seller_pubkey, - )?; - match envelope.payload.decision { - RadrootsOrderRevisionOutcome::Accepted - | RadrootsOrderRevisionOutcome::Declined { .. } => { - ensure_transition(&order.status, &TradeOrderStatus::Accepted)?; - Ok(()) - } - } - }) - .await -} - -async fn handle_order_cancellation( - event: &RadrootsNostrEvent, - state: &Arc<tokio::sync::Mutex<TradeListingState>>, -) -> Result<(), TradeListingDvmError> { - let rr_event = radroots_event_from_nostr(event); - let envelope = order_cancellation_from_event(&rr_event).map_err(map_order_parse_error)?; - update_existing_order(event, &rr_event.tags, state, &envelope.order_id, |order| { - ensure_order_binding( - order, - &envelope.payload.listing_addr, - &envelope.payload.buyer_pubkey, - &envelope.payload.seller_pubkey, - )?; - ensure_transition(&order.status, &TradeOrderStatus::Cancelled)?; - order.status = TradeOrderStatus::Cancelled; - Ok(()) - }) - .await -} - -async fn handle_order_fulfillment_update( - event: &RadrootsNostrEvent, - state: &Arc<tokio::sync::Mutex<TradeListingState>>, -) -> Result<(), TradeListingDvmError> { - let rr_event = radroots_event_from_nostr(event); - let envelope = order_fulfillment_update_from_event(&rr_event).map_err(map_order_parse_error)?; - update_existing_order(event, &rr_event.tags, state, &envelope.order_id, |order| { - ensure_order_binding( - order, - &envelope.payload.listing_addr, - &envelope.payload.buyer_pubkey, - &envelope.payload.seller_pubkey, - )?; - ensure_transition(&order.status, &TradeOrderStatus::Accepted)?; - if envelope.payload.status == RadrootsOrderFulfillmentState::SellerCancelled { - order.status = TradeOrderStatus::Cancelled; - } - Ok(()) - }) - .await -} - -async fn handle_order_receipt( - event: &RadrootsNostrEvent, - state: &Arc<tokio::sync::Mutex<TradeListingState>>, -) -> Result<(), TradeListingDvmError> { - let rr_event = radroots_event_from_nostr(event); - let envelope = order_receipt_from_event(&rr_event).map_err(map_order_parse_error)?; - let next_status = receipt_status(&envelope.payload); - update_existing_order(event, &rr_event.tags, state, &envelope.order_id, |order| { - ensure_order_binding( - order, - &envelope.payload.listing_addr, - &envelope.payload.buyer_pubkey, - &envelope.payload.seller_pubkey, - )?; - ensure_transition(&order.status, &next_status)?; - order.status = next_status; - Ok(()) - }) - .await -} - -async fn handle_order_payment_record( - event: &RadrootsNostrEvent, - state: &Arc<tokio::sync::Mutex<TradeListingState>>, -) -> Result<(), TradeListingDvmError> { - let rr_event = radroots_event_from_nostr(event); - let envelope = order_payment_record_from_event(&rr_event).map_err(map_order_parse_error)?; - update_existing_order(event, &rr_event.tags, state, &envelope.order_id, |order| { - ensure_order_binding( - order, - &envelope.payload.listing_addr, - &envelope.payload.buyer_pubkey, - &envelope.payload.seller_pubkey, - ) - }) - .await -} - -async fn handle_order_settlement_decision( +async fn handle_order_workflow_event( event: &RadrootsNostrEvent, + client: &RadrootsNostrClient, state: &Arc<tokio::sync::Mutex<TradeListingState>>, ) -> Result<(), TradeListingDvmError> { let rr_event = radroots_event_from_nostr(event); - let envelope = - order_settlement_decision_from_event(&rr_event).map_err(map_order_parse_error)?; - update_existing_order(event, &rr_event.tags, state, &envelope.order_id, |order| { - ensure_order_binding( - order, - &envelope.payload.listing_addr, - &envelope.payload.buyer_pubkey, - &envelope.payload.seller_pubkey, - ) - }) - .await -} - -async fn update_existing_order<F>( - event: &RadrootsNostrEvent, - tags: &[Vec<String>], - state: &Arc<tokio::sync::Mutex<TradeListingState>>, - order_id: &str, - update: F, -) -> Result<(), TradeListingDvmError> -where - F: FnOnce(&mut TradeOrderState) -> Result<(), TradeListingDvmError>, -{ + let current_record = + order_event_record_from_event(&rr_event).map_err(map_order_decode_error)?; + let order_id = current_record.order_id().clone(); let event_id = event.id.to_string(); + let order_snapshot = { + let state = state.lock().await; + if state.is_event_seen(order_id.as_str(), &event_id) { + return Ok(()); + } + state + .get_order(order_id.as_str()) + .cloned() + .ok_or(TradeListingStateError::MissingOrder)? + }; + let mut records = + fetch_seen_order_records(client, &order_snapshot, event, current_record).await?; + let projection = reduce_order_event_records(&order_id, records.drain(..)); + if projection.status != RadrootsTradeWorkflowState::Invalid && !projection.issues.is_empty() { + return Err(TradeListingDvmError::Workflow(workflow_rejection_message( + &projection, + ))); + } let mut state = state.lock().await; - if state.is_event_seen(order_id, &event_id) { + if state.is_event_seen(order_id.as_str(), &event_id) { return Ok(()); } let order = state - .get_order_mut(order_id) + .get_order_mut(order_id.as_str()) .ok_or(TradeListingStateError::MissingOrder)?; - ensure_order_chain(order, tags)?; - update(order)?; - order.last_event_id = Some(event_id.clone()); + ensure_projection_binding(order, &projection)?; + order.status = projection.status; + order.last_event_id = projection + .last_event_id + .map(|last_event_id| last_event_id.to_string()) + .or_else(|| Some(event_id.clone())); order.seen_event_ids.insert(event_id); Ok(()) } -fn ensure_order_binding( +async fn fetch_seen_order_records( + client: &RadrootsNostrClient, order: &TradeOrderState, - listing_addr: &str, - buyer_pubkey: &str, - seller_pubkey: &str, -) -> Result<(), TradeListingDvmError> { - if order.listing_addr == listing_addr - && order.buyer_pubkey == buyer_pubkey - && order.seller_pubkey == seller_pubkey - { - Ok(()) - } else { - Err(TradeListingDvmError::InvalidOrder) + current_event: &RadrootsNostrEvent, + current_record: RadrootsOrderEventRecord, +) -> Result<Vec<RadrootsOrderEventRecord>, TradeListingDvmError> { + let current_event_id = current_event.id.to_string(); + let mut event_ids = order + .seen_event_ids + .iter() + .filter(|event_id| event_id.as_str() != current_event_id) + .cloned() + .collect::<Vec<_>>(); + event_ids.sort(); + let mut records = Vec::with_capacity(event_ids.len() + 1); + for event_id in event_ids { + let event = fetch_event_by_id_io(client, &event_id).await?; + let rr_event = radroots_event_from_nostr(&event); + let record = order_event_record_from_event(&rr_event).map_err(map_order_decode_error)?; + if record.order_id().as_str() != order.order_id { + return Err(TradeListingDvmError::InvalidOrder); + } + records.push(record); } + records.push(current_record); + Ok(records) } -fn ensure_order_chain( +fn ensure_projection_binding( order: &TradeOrderState, - tags: &[Vec<String>], + projection: &RadrootsOrderProjection, ) -> Result<(), TradeListingDvmError> { - let root_event_id = parse_order_root_tag(tags) - .map_err(|error| TradeListingDvmError::InvalidPayload(error.to_string()))? - .ok_or(TradeListingDvmError::MissingTag("e:root"))?; - let prev_event_id = parse_order_prev_tag(tags) - .map_err(|error| TradeListingDvmError::InvalidPayload(error.to_string()))? - .ok_or(TradeListingDvmError::MissingTag("e:prev"))?; - if order.root_event_id.as_deref() == Some(root_event_id.as_str()) - && order.last_event_id.as_deref() == Some(prev_event_id.as_str()) + if projection + .listing_addr + .as_ref() + .is_some_and(|listing_addr| listing_addr.to_string() != order.listing_addr) + || projection + .buyer_pubkey + .as_ref() + .is_some_and(|buyer_pubkey| buyer_pubkey.to_string() != order.buyer_pubkey) + || projection + .seller_pubkey + .as_ref() + .is_some_and(|seller_pubkey| seller_pubkey.to_string() != order.seller_pubkey) { - Ok(()) - } else { - Err(TradeListingDvmError::InvalidOrder) + return Err(TradeListingDvmError::InvalidOrder); } + Ok(()) } -fn receipt_status(payload: &RadrootsOrderReceipt) -> TradeOrderStatus { - if payload.received { - TradeOrderStatus::Completed - } else { - TradeOrderStatus::Disputed - } +fn workflow_rejection_message(projection: &RadrootsOrderProjection) -> String { + format!("{:?}:{:?}", projection.status, projection.issues) } -fn ensure_transition( - from: &TradeOrderStatus, - to: &TradeOrderStatus, -) -> Result<(), TradeListingStateError> { - if from == to { - return Ok(()); - } - let allowed = match from { - TradeOrderStatus::Requested => matches!( - to, - TradeOrderStatus::Accepted | TradeOrderStatus::Declined | TradeOrderStatus::Cancelled - ), - TradeOrderStatus::Accepted => matches!( - to, - TradeOrderStatus::Cancelled | TradeOrderStatus::Completed | TradeOrderStatus::Disputed - ), - TradeOrderStatus::Declined - | TradeOrderStatus::Cancelled - | TradeOrderStatus::Completed - | TradeOrderStatus::Disputed - | TradeOrderStatus::Invalid => false, - }; - if allowed { - Ok(()) - } else { - Err(TradeListingStateError::InvalidTransition { - from: from.clone(), - to: to.clone(), - }) +fn map_order_decode_error( + error: radroots_trade::order::RadrootsOrderEventDecodeError, +) -> TradeListingDvmError { + match error { + radroots_trade::order::RadrootsOrderEventDecodeError::Envelope(error) => { + map_order_parse_error(error) + } + radroots_trade::order::RadrootsOrderEventDecodeError::UnsupportedKind { .. } => { + TradeListingDvmError::UnsupportedKind + } + other => TradeListingDvmError::InvalidPayload(other.to_string()), } } @@ -937,32 +794,37 @@ pub async fn handle_error( #[cfg_attr(coverage_nightly, coverage(off))] mod tests { use super::{ - DvmTestHooks, TradeListingDvmError, dvm_test_hooks, ensure_transition, handle_error, - handle_event, tag_has_value, + DvmTestHooks, TradeListingDvmError, dvm_test_hooks, handle_error, handle_event, + tag_has_value, }; - use crate::features::trade_listing::state::{TradeListingState, TradeOrderStatus}; + use crate::features::trade_listing::state::TradeListingState; use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, }; use radroots_events::RadrootsNostrEventPtr; use radroots_events::farm::RadrootsFarmRef; use radroots_events::ids::{ - RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId, RadrootsOrderQuoteId, - RadrootsPublicKey, + RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId, + RadrootsOrderQuoteId, RadrootsPublicKey, }; use radroots_events::kinds::{ KIND_LISTING, KIND_ORDER_REQUEST, KIND_TRADE_LISTING_VALIDATION_REQUEST, }; use radroots_events::order::{ + RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome, RadrootsOrderEconomicItem, RadrootsOrderEconomicLine, RadrootsOrderEconomics, - RadrootsOrderItem, RadrootsOrderPricingBasis, RadrootsOrderRequest, + RadrootsOrderInventoryCommitment, RadrootsOrderItem, RadrootsOrderPricingBasis, + RadrootsOrderRequest, }; use radroots_events::trade_validation::RadrootsTradeValidationListingRequest; - use radroots_events_codec::order::order_request_event_build; + use radroots_events_codec::order::{ + order_cancellation_event_build, order_decision_event_build, order_request_event_build, + }; use radroots_nostr::prelude::{ RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrKeys, RadrootsNostrKind, radroots_nostr_build_event, }; + use radroots_trade::workflow::RadrootsTradeWorkflowState; use std::sync::Arc; use tokio::sync::{Mutex, MutexGuard}; @@ -1006,6 +868,10 @@ mod tests { RadrootsPublicKey::parse(keys.public_key().to_string()).expect("public key") } + fn typed_event_id(event: &RadrootsNostrEvent) -> RadrootsEventId { + RadrootsEventId::parse(event.id.to_string()).expect("event id") + } + fn listing_event_ptr() -> RadrootsNostrEventPtr { RadrootsNostrEventPtr { id: listing_event_id().to_string(), @@ -1070,6 +936,39 @@ mod tests { } } + fn order_decision( + order_id: &str, + buyer: &RadrootsNostrKeys, + seller: &RadrootsNostrKeys, + ) -> RadrootsOrderDecision { + RadrootsOrderDecision { + order_id: typed_order_id(order_id), + listing_addr: typed_listing_addr(seller), + buyer_pubkey: typed_pubkey(buyer), + seller_pubkey: typed_pubkey(seller), + decision: RadrootsOrderDecisionOutcome::Accepted { + inventory_commitments: vec![RadrootsOrderInventoryCommitment { + bin_id: typed_bin_id(), + bin_count: 2, + }], + }, + } + } + + fn order_cancellation( + order_id: &str, + buyer: &RadrootsNostrKeys, + seller: &RadrootsNostrKeys, + ) -> RadrootsOrderCancellation { + RadrootsOrderCancellation { + order_id: typed_order_id(order_id), + listing_addr: typed_listing_addr(seller), + buyer_pubkey: typed_pubkey(buyer), + seller_pubkey: typed_pubkey(seller), + reason: "cancel after agreement".to_string(), + } + } + fn signed_order_request_event( buyer: &RadrootsNostrKeys, seller: &RadrootsNostrKeys, @@ -1082,6 +981,38 @@ mod tests { .expect("event") } + fn signed_order_decision_event( + buyer: &RadrootsNostrKeys, + seller: &RadrootsNostrKeys, + request_event: &RadrootsNostrEvent, + ) -> RadrootsNostrEvent { + let payload = order_decision("order-1", buyer, seller); + let root_event_id = typed_event_id(request_event); + let wire = + order_decision_event_build(&root_event_id, &root_event_id, &payload).expect("wire"); + radroots_nostr_build_event(wire.kind, wire.content, wire.tags) + .expect("builder") + .sign_with_keys(seller) + .expect("event") + } + + fn signed_order_cancellation_event( + buyer: &RadrootsNostrKeys, + seller: &RadrootsNostrKeys, + request_event: &RadrootsNostrEvent, + decision_event: &RadrootsNostrEvent, + ) -> RadrootsNostrEvent { + let payload = order_cancellation("order-1", buyer, seller); + let root_event_id = typed_event_id(request_event); + let prev_event_id = typed_event_id(decision_event); + let wire = + order_cancellation_event_build(&root_event_id, &prev_event_id, &payload).expect("wire"); + radroots_nostr_build_event(wire.kind, wire.content, wire.tags) + .expect("builder") + .sign_with_keys(buyer) + .expect("event") + } + fn listing_event(seller: &RadrootsNostrKeys) -> RadrootsNostrEvent { RadrootsNostrEventBuilder::new(RadrootsNostrKind::Custom(KIND_LISTING as u16), "{}") .tags(vec![radroots_nostr::prelude::RadrootsNostrTag::identifier( @@ -1105,21 +1036,118 @@ mod tests { KIND_LISTING, ); + let request_event = signed_order_request_event(&buyer, &seller); + handle_event(request_event, Vec::new(), worker, client, state.clone()) + .await + .expect("order request"); + + let mut state = state.lock().await; + let order = state.get_order_mut("order-1").expect("order"); + assert_eq!(order.status, RadrootsTradeWorkflowState::Requested); + assert_eq!(order.buyer_pubkey, buyer.public_key().to_string()); + assert_eq!(order.seller_pubkey, seller.public_key().to_string()); + } + + #[tokio::test] + async fn order_decision_uses_shared_workflow_pending_rhi_state() { + let _guard = test_guard().await; + let worker = RadrootsNostrKeys::generate(); + let buyer = RadrootsNostrKeys::generate(); + let seller = RadrootsNostrKeys::generate(); + let client = RadrootsNostrClient::new(worker.clone()); + let state = Arc::new(Mutex::new(TradeListingState::default())); + state.lock().await.upsert_listing_event( + &listing_addr(&seller), + listing_event_id(), + KIND_LISTING, + ); + let request_event = signed_order_request_event(&buyer, &seller); + handle_event( + request_event.clone(), + Vec::new(), + worker.clone(), + client.clone(), + state.clone(), + ) + .await + .expect("order request"); + let decision_event = signed_order_decision_event(&buyer, &seller, &request_event); + dvm_test_hooks() + .lock() + .expect("hooks") + .fetch_event_by_id_results + .push_back(Ok(request_event)); + + handle_event(decision_event, Vec::new(), worker, client, state.clone()) + .await + .expect("order decision"); + + let mut state = state.lock().await; + let order = state.get_order_mut("order-1").expect("order"); + assert_eq!(order.status, RadrootsTradeWorkflowState::AgreedPendingRhi); + } + + #[tokio::test] + async fn cancellation_after_pending_agreement_uses_shared_workflow_invalid_state() { + let _guard = test_guard().await; + let worker = RadrootsNostrKeys::generate(); + let buyer = RadrootsNostrKeys::generate(); + let seller = RadrootsNostrKeys::generate(); + let client = RadrootsNostrClient::new(worker.clone()); + let state = Arc::new(Mutex::new(TradeListingState::default())); + state.lock().await.upsert_listing_event( + &listing_addr(&seller), + listing_event_id(), + KIND_LISTING, + ); + let request_event = signed_order_request_event(&buyer, &seller); handle_event( - signed_order_request_event(&buyer, &seller), + request_event.clone(), + Vec::new(), + worker.clone(), + client.clone(), + state.clone(), + ) + .await + .expect("order request"); + let decision_event = signed_order_decision_event(&buyer, &seller, &request_event); + dvm_test_hooks() + .lock() + .expect("hooks") + .fetch_event_by_id_results + .push_back(Ok(request_event.clone())); + handle_event( + decision_event.clone(), + Vec::new(), + worker.clone(), + client.clone(), + state.clone(), + ) + .await + .expect("order decision"); + let cancellation_event = + signed_order_cancellation_event(&buyer, &seller, &request_event, &decision_event); + { + let mut hooks = dvm_test_hooks().lock().expect("hooks"); + hooks.fetch_event_by_id_results.push_back(Ok(request_event)); + hooks + .fetch_event_by_id_results + .push_back(Ok(decision_event)); + } + + handle_event( + cancellation_event, Vec::new(), worker, client, state.clone(), ) .await - .expect("order request"); + .expect("order cancellation"); let mut state = state.lock().await; let order = state.get_order_mut("order-1").expect("order"); - assert_eq!(order.status, TradeOrderStatus::Requested); - assert_eq!(order.buyer_pubkey, buyer.public_key().to_string()); - assert_eq!(order.seller_pubkey, seller.public_key().to_string()); + assert_eq!(order.status, RadrootsTradeWorkflowState::Invalid); } #[tokio::test] @@ -1184,18 +1212,17 @@ mod tests { } #[test] - fn transition_and_tag_helpers_cover_core_paths() { - assert!( - ensure_transition(&TradeOrderStatus::Requested, &TradeOrderStatus::Accepted).is_ok() - ); - assert!( - ensure_transition(&TradeOrderStatus::Declined, &TradeOrderStatus::Accepted).is_err() - ); + fn tag_helpers_cover_core_paths() { assert!(tag_has_value( &[vec!["p".to_string(), "pubkey".to_string()]], "p", "pubkey" )); + assert!(!tag_has_value( + &[vec!["p".to_string(), "pubkey".to_string()]], + "p", + "other" + )); } #[tokio::test] diff --git a/src/features/trade_listing/state.rs b/src/features/trade_listing/state.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use radroots_nostr::prelude::{RadrootsNostrFilter, RadrootsNostrKind, RadrootsNostrTimestamp}; +use radroots_trade::workflow::RadrootsTradeWorkflowState; use serde::{Deserialize, Serialize}; use thiserror::Error; use tokio::sync::Mutex; @@ -13,24 +14,13 @@ pub type SharedTradeListingState = Arc<Mutex<TradeListingState>>; const TRADE_LISTING_STATE_VERSION: u32 = 1; -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum TradeOrderStatus { - Requested, - Accepted, - Declined, - Cancelled, - Completed, - Disputed, - Invalid, -} - #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TradeOrderState { pub order_id: String, pub listing_addr: String, pub buyer_pubkey: String, pub seller_pubkey: String, - pub status: TradeOrderStatus, + pub status: RadrootsTradeWorkflowState, #[serde(default)] pub listing_snapshot_event_id: Option<String>, #[serde(default)] @@ -214,6 +204,10 @@ impl TradeListingState { self.orders.get_mut(order_id) } + pub fn get_order(&self, order_id: &str) -> Option<&TradeOrderState> { + self.orders.get(order_id) + } + pub fn insert_order(&mut self, order: TradeOrderState) { self.orders.insert(order.order_id.clone(), order); } @@ -314,19 +308,12 @@ fn temp_state_path(path: &Path) -> Result<PathBuf, TradeListingRuntimeError> { #[derive(Debug, Clone, PartialEq, Eq)] pub enum TradeListingStateError { MissingOrder, - InvalidTransition { - from: TradeOrderStatus, - to: TradeOrderStatus, - }, } impl core::fmt::Display for TradeListingStateError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { TradeListingStateError::MissingOrder => write!(f, "missing order state"), - TradeListingStateError::InvalidTransition { from, to } => { - write!(f, "invalid order transition: {from:?} -> {to:?}") - } } } } @@ -351,8 +338,9 @@ mod tests { use super::{ ListingEventState, PersistedTradeListingState, TradeListingRuntime, TradeListingRuntimeConfig, TradeListingRuntimeError, TradeListingState, - TradeListingStateError, TradeOrderState, TradeOrderStatus, ValidatedListingState, + TradeListingStateError, TradeOrderState, ValidatedListingState, }; + use radroots_trade::workflow::RadrootsTradeWorkflowState; use std::collections::{HashMap, HashSet}; fn unique_state_path(suffix: &str) -> std::path::PathBuf { @@ -379,7 +367,7 @@ mod tests { listing_addr: "addr".into(), buyer_pubkey: "buyer".into(), seller_pubkey: "seller".into(), - status: TradeOrderStatus::Requested, + status: RadrootsTradeWorkflowState::Requested, listing_snapshot_event_id: Some("evt-listing-1".into()), root_event_id: Some("evt-root-1".into()), last_event_id: Some("evt-root-1".into()), @@ -415,14 +403,7 @@ mod tests { "missing order state" ); - let invalid = TradeListingStateError::InvalidTransition { - from: TradeOrderStatus::Requested, - to: TradeOrderStatus::Completed, - }; - assert_eq!( - invalid.to_string(), - "invalid order transition: Requested -> Completed" - ); + assert!(state.get_order("missing").is_none()); } #[tokio::test] diff --git a/src/features/trade_validation_receipt.rs b/src/features/trade_validation_receipt.rs @@ -1,6 +1,7 @@ #![forbid(unsafe_code)] #![cfg_attr(coverage_nightly, coverage(off))] +use radroots_events::ids::RadrootsEventId; use radroots_events::kinds::{ KIND_TRADE_TRANSITION_PROOF_REQUEST, KIND_TRADE_TRANSITION_PROOF_RESULT, KIND_TRADE_VALIDATION_RECEIPT, is_listing_kind, @@ -35,6 +36,14 @@ use radroots_trade::validation_receipt::{ RadrootsValidationReceiptError, RadrootsValidationReceiptExpectedBinding, validation_receipt_event_build, verify_validation_receipt_event, }; +use radroots_trade::{ + order::{ + RadrootsGroupedOrderEventRecords, RadrootsOrderEventRecord, order_event_record_from_event, + }, + workflow::{ + RadrootsTradeWorkflowRecords, RadrootsTradeWorkflowState, reduce_trade_workflow_records, + }, +}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; #[cfg(feature = "sp1_verify")] @@ -172,9 +181,6 @@ impl TradeValidationReceiptProverPolicy { if self.proof_mode == RadrootsSp1TradeProofMode::None { return Err(TradeValidationReceiptJobError::ProverBackendRequiresSp1Proof); } - if self.proof_mode != RadrootsSp1TradeProofMode::Core { - return Err(TradeValidationReceiptJobError::UnsupportedProofMode); - } if self.expected_sp1_program_hash.is_none() || self.expected_sp1_verifying_key_hash.is_none() { @@ -194,9 +200,6 @@ impl TradeValidationReceiptProverPolicy { if self.proof_mode == RadrootsSp1TradeProofMode::None { return Err(TradeValidationReceiptJobError::ProverBackendRequiresSp1Proof); } - if self.proof_mode != RadrootsSp1TradeProofMode::Core { - return Err(TradeValidationReceiptJobError::UnsupportedProofMode); - } if self.expected_sp1_program_hash.is_none() || self.expected_sp1_verifying_key_hash.is_none() { @@ -531,6 +534,12 @@ pub async fn handle_trade_validation_receipt_job_request( return Err(TradeValidationReceiptJobError::EventSetMismatch); } + validate_shared_workflow_pending_agreement( + &request.listing_event_id, + &request_rr, + &decision_rr, + )?; + let witness = RadrootsSp1TradeOrderAcceptanceWitness { witness_version: RADROOTS_SP1_TRADE_WITNESS_VERSION, proof_target: RADROOTS_SP1_TRADE_ORDER_ACCEPTANCE_PROOF_TARGET.to_string(), @@ -577,6 +586,8 @@ pub async fn handle_trade_validation_receipt_job_request( proof_system: Some(receipt.proof.system), public_values_hash: Some(&receipt.public_values_hash), reducer_output_root: Some(&receipt.new_state_root), + root_event_id: Some(&request.request_event_id), + target_event_id: Some(&request.decision_event_id), verifying_key_hash: prover_policy.expected_sp1_verifying_key_hash.as_deref(), }, )?; @@ -648,6 +659,52 @@ fn canonical_event_evidence_from_events( ]) } +fn validate_shared_workflow_pending_agreement( + listing_event_id: &str, + request_event: &radroots_events::RadrootsNostrEvent, + decision_event: &radroots_events::RadrootsNostrEvent, +) -> Result<(), TradeValidationReceiptJobError> { + let request_record = match order_event_record_from_event(request_event).map_err(|error| { + TradeValidationReceiptJobError::InvalidActiveTradeEvent(error.to_string()) + })? { + RadrootsOrderEventRecord::Request(record) => record, + _ => return Err(TradeValidationReceiptJobError::EventSetMismatch), + }; + let decision_record = match order_event_record_from_event(decision_event).map_err(|error| { + TradeValidationReceiptJobError::InvalidActiveTradeEvent(error.to_string()) + })? { + RadrootsOrderEventRecord::Decision(record) => record, + _ => return Err(TradeValidationReceiptJobError::EventSetMismatch), + }; + let listing_event_id = RadrootsEventId::parse(listing_event_id) + .map_err(|_| TradeValidationReceiptJobError::EventSetMismatch)?; + let order_id = request_record.payload.order_id.clone(); + let projection = reduce_trade_workflow_records( + &order_id, + RadrootsTradeWorkflowRecords { + order_events: RadrootsGroupedOrderEventRecords { + requests: vec![request_record], + decisions: vec![decision_record], + revision_proposals: Vec::new(), + revision_decisions: Vec::new(), + cancellations: Vec::new(), + }, + validation_receipts: Vec::new(), + deterministic_failures: Vec::new(), + expected_listing_event_id: Some(listing_event_id.clone()), + current_listing_event_id: Some(listing_event_id), + }, + ); + if projection.status == RadrootsTradeWorkflowState::AgreedPendingRhi + && projection.issues.is_empty() + { + return Ok(()); + } + Err(TradeValidationReceiptJobError::InvalidActiveTradeEvent( + format!("{:?}:{:?}", projection.status, projection.issues), + )) +} + fn canonical_event_evidence( event: &RadrootsNostrEvent, role: RadrootsSp1TradeEventEvidenceRole, @@ -1655,6 +1712,23 @@ mod tests { } } + fn declined_decision_payload( + order_id: &str, + listing_addr: &str, + buyer: &RadrootsNostrKeys, + seller: &RadrootsNostrKeys, + ) -> RadrootsOrderDecision { + RadrootsOrderDecision { + order_id: typed_order_id(order_id), + listing_addr: typed_listing_addr(listing_addr), + buyer_pubkey: typed_pubkey(buyer), + seller_pubkey: typed_pubkey(seller), + decision: RadrootsOrderDecisionOutcome::Declined { + reason: "not available".to_string(), + }, + } + } + fn economics(order_id: &str, bin_count: u32) -> RadrootsOrderEconomics { let subtotal = RadrootsCoreDecimal::from(5u32) * RadrootsCoreDecimal::from(bin_count); let money = RadrootsCoreMoney::new(subtotal, RadrootsCoreCurrency::USD); @@ -1718,6 +1792,43 @@ mod tests { (request_event, decision_event) } + fn signed_declined_order_events( + buyer: &RadrootsNostrKeys, + seller: &RadrootsNostrKeys, + listing_event: &RadrootsNostrEvent, + ) -> (RadrootsNostrEvent, RadrootsNostrEvent) { + let listing_addr = listing_addr_for_seller(seller); + let order_id = "order-1"; + let listing_ptr = RadrootsNostrEventPtr { + id: listing_event.id.to_hex(), + relays: None, + }; + let request_wire = order_request_event_build( + &listing_ptr, + &request_payload(order_id, &listing_addr, buyer, seller), + ) + .expect("request wire"); + let request_event = signed_event( + buyer, + request_wire.kind, + request_wire.content, + request_wire.tags, + ); + let decision_wire = order_decision_event_build( + &typed_event_id(&request_event), + &typed_event_id(&request_event), + &declined_decision_payload(order_id, &listing_addr, buyer, seller), + ) + .expect("decision wire"); + let decision_event = signed_event( + seller, + decision_wire.kind, + decision_wire.content, + decision_wire.tags, + ); + (request_event, decision_event) + } + fn job_request( requester: &RadrootsNostrKeys, worker: &RadrootsNostrKeys, @@ -2057,18 +2168,36 @@ mod tests { assert!(remote_http_policy().validate().is_ok()); } + #[cfg(not(feature = "sp1_verify"))] #[test] - fn remote_http_policy_rejects_non_core_sp1_mode_before_remote_work() { + fn remote_http_policy_recognizes_non_core_sp1_mode_before_backend_availability() { let mut policy = remote_http_policy(); policy.proof_mode = RadrootsSp1TradeProofMode::Compressed; assert!(matches!( policy.validate(), - Err(TradeValidationReceiptJobError::UnsupportedProofMode) + Err(TradeValidationReceiptJobError::ProverBackendUnavailable( + "remote_http_prove" + )) )); } #[cfg(feature = "sp1_verify")] + #[test] + fn remote_http_policy_accepts_all_explicit_sp1_modes_when_configured() { + for proof_mode in [ + RadrootsSp1TradeProofMode::Core, + RadrootsSp1TradeProofMode::Compressed, + RadrootsSp1TradeProofMode::Groth16, + RadrootsSp1TradeProofMode::Plonk, + ] { + let mut policy = remote_http_policy(); + policy.proof_mode = proof_mode; + assert!(policy.validate().is_ok()); + } + } + + #[cfg(feature = "sp1_verify")] #[tokio::test] async fn remote_http_prove_publishes_only_after_remote_artifact_verification() { let (published, requests) = run_remote_http_job_with_policy_and_requests( @@ -2662,6 +2791,62 @@ mod tests { } #[tokio::test] + async fn proof_job_rejects_non_pending_shared_workflow_before_publication() { + let _guard = test_guard(); + let worker = RadrootsNostrKeys::generate(); + let requester = RadrootsNostrKeys::generate(); + let buyer = RadrootsNostrKeys::generate(); + let seller = RadrootsNostrKeys::generate(); + let listing_event = listing_event(&seller); + let (request_event, decision_event) = + signed_declined_order_events(&buyer, &seller, &listing_event); + let job = job_request( + &requester, + &worker, + &listing_event, + &request_event, + &decision_event, + RadrootsSp1TradeProofMode::None, + None, + None, + ); + + { + let mut hooks = trade_validation_receipt_test_hooks() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + hooks.fetch_event_by_id_results.push_back(Ok(listing_event)); + hooks.fetch_event_by_id_results.push_back(Ok(request_event)); + hooks + .fetch_event_by_id_results + .push_back(Ok(decision_event)); + hooks + .publish_event_results + .push_back(Ok(publish_result_id(1))); + } + + let error = handle_trade_validation_receipt_job_request( + &job, + &worker, + &client_for(&worker), + &deterministic_policy(), + ) + .await + .expect_err("declined decision is not pending RHI"); + assert!(matches!( + error, + TradeValidationReceiptJobError::InvalidActiveTradeEvent(_) + )); + assert!( + trade_validation_receipt_test_hooks() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .published_events + .is_empty() + ); + } + + #[tokio::test] async fn proof_job_rejects_unverified_proof_before_publication() { let _guard = test_guard(); let worker = RadrootsNostrKeys::generate();