sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit c56df8b75d8c2728555a463e13778a61533abaae
parent f57001e3f45ed07e1fa0e4ac6456abc997c72dc4
Author: triesap <tyson@radroots.org>
Date:   Fri, 19 Jun 2026 01:42:54 -0700

sdk: harden order revision status preflight

Diffstat:
Mcrates/sdk/src/orders_runtime.rs | 36++++++++++++++++++++----------------
Mcrates/sdk/tests/orders_runtime.rs | 324+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 344 insertions(+), 16 deletions(-)

diff --git a/crates/sdk/src/orders_runtime.rs b/crates/sdk/src/orders_runtime.rs @@ -1362,6 +1362,8 @@ pub struct OrderStatusReceipt { pub event_ids: Vec<RadrootsEventId>, pub request_event_id: Option<RadrootsEventId>, pub decision_event_id: Option<RadrootsEventId>, + pub agreement_event_id: Option<RadrootsEventId>, + pub pending_revision_event_id: Option<RadrootsEventId>, pub fulfillment_event_id: Option<RadrootsEventId>, pub cancellation_event_id: Option<RadrootsEventId>, pub receipt_event_id: Option<RadrootsEventId>, @@ -2494,6 +2496,8 @@ impl OrderStatusReceipt { event_ids: query_result.event_ids, request_event_id: projection.request_event_id, decision_event_id: projection.decision_event_id, + agreement_event_id: projection.agreement_event_id, + pending_revision_event_id: projection.pending_revision_event_id, fulfillment_event_id: projection.fulfillment_event_id, cancellation_event_id: projection.cancellation_event_id, receipt_event_id: projection.receipt_event_id, @@ -3278,14 +3282,23 @@ fn require_pending_revision( refs: &OrderLifecycleReferences<'_>, projection: &RadrootsOrderProjection, ) -> Result<(), RadrootsSdkError> { - if has_pending_revision(projection) { - Ok(()) - } else { - Err(lifecycle_invalid( + match projection.pending_revision_event_id.as_ref() { + Some(pending_revision_event_id) if pending_revision_event_id == refs.previous_event_id => { + Ok(()) + } + Some(pending_revision_event_id) => Err(lifecycle_invalid( + refs.operation, + refs.order_id, + format!( + "previous event {} does not match pending revision proposal {}", + refs.previous_event_id, pending_revision_event_id + ), + )), + None => Err(lifecycle_invalid( refs.operation, refs.order_id, "requires pending revision proposal local state", - )) + )), } } @@ -3294,11 +3307,11 @@ fn require_no_pending_revision( refs: &OrderLifecycleReferences<'_>, projection: &RadrootsOrderProjection, ) -> Result<(), RadrootsSdkError> { - if has_pending_revision(projection) { + if let Some(pending_revision_event_id) = projection.pending_revision_event_id.as_ref() { Err(lifecycle_invalid( refs.operation, refs.order_id, - "cannot follow pending revision proposal local state", + format!("cannot follow pending revision proposal {pending_revision_event_id}"), )) } else { Ok(()) @@ -3306,15 +3319,6 @@ fn require_no_pending_revision( } #[cfg(feature = "runtime")] -fn has_pending_revision(projection: &RadrootsOrderProjection) -> bool { - matches!(projection.status, RadrootsOrderStatus::Accepted) - && projection.fulfillment_event_id.is_none() - && projection.agreement_event_id.is_some() - && projection.last_event_id.is_some() - && projection.agreement_event_id != projection.last_event_id -} - -#[cfg(feature = "runtime")] fn require_lifecycle_previous_is_current( refs: &OrderLifecycleReferences<'_>, projection: &RadrootsOrderProjection, diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs @@ -1482,6 +1482,14 @@ async fn order_decision_enqueue_accept_stores_event_queues_outbox_and_updates_st .map(RadrootsEventId::as_str), Some(receipt.signed_event_id.as_str()) ); + assert_eq!( + status + .agreement_event_id + .as_ref() + .map(RadrootsEventId::as_str), + Some(receipt.signed_event_id.as_str()) + ); + assert_eq!(status.pending_revision_event_id, None); assert!(status.issues.is_empty()); } @@ -1863,11 +1871,327 @@ async fn order_revision_order_fulfillment_order_receipt_lifecycle_enqueue_update status.fulfillment_status, Some(OrderFulfillmentStatusKind::ReadyForPickup) ); + assert_eq!( + status + .agreement_event_id + .as_ref() + .map(RadrootsEventId::as_str), + Some(revision_decision_receipt.signed_event_id.as_str()) + ); + assert_eq!(status.pending_revision_event_id, None); assert!(status.lifecycle_terminal); assert!(status.issues.is_empty()); } #[tokio::test] +async fn order_revision_proposal_status_exposes_pending_and_blocks_follow_on_lifecycle() { + let (_tempdir, sdk, store) = directory_sdk_and_store().await; + let request_event = signed_order_request_event("order-lifecycle-pending-revision", 55); + let request_event_id = RadrootsEventId::parse(request_event.id.as_str()).expect("request id"); + store + .ingest_event(RadrootsEventIngest::new(request_event.clone(), 5_500)) + .await + .expect("ingest request"); + let decision_receipt = sdk + .orders() + .enqueue_decision( + OrderDecisionEnqueueRequest::new( + seller_actor(), + request_event_ptr(&request_event), + order_decision("order-lifecycle-pending-revision"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("decision target relays"), + &FixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue decision"); + let proposal = order_revision_proposal( + "order-lifecycle-pending-revision", + &request_event_id, + &decision_receipt.signed_event_id, + ); + let proposal_receipt = sdk + .orders() + .enqueue_revision_proposal( + OrderRevisionProposalEnqueueRequest::new( + seller_actor(), + request_event_ptr(&request_event), + order_event_ptr(&decision_receipt.signed_event_id), + proposal, + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("proposal target relays"), + &FixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue revision proposal"); + + let status = sdk + .orders() + .status(status_request("order-lifecycle-pending-revision")) + .await + .expect("status"); + assert_eq!(status.status, OrderStatusKind::Accepted); + assert_eq!(status.event_count, 3); + assert_eq!( + status + .agreement_event_id + .as_ref() + .map(RadrootsEventId::as_str), + Some(decision_receipt.signed_event_id.as_str()) + ); + assert_eq!( + status + .pending_revision_event_id + .as_ref() + .map(RadrootsEventId::as_str), + Some(proposal_receipt.signed_event_id.as_str()) + ); + assert_eq!( + status.last_event_id.as_ref().map(RadrootsEventId::as_str), + Some(proposal_receipt.signed_event_id.as_str()) + ); + assert!(status.issues.is_empty()); + + let fulfillment_error = sdk + .orders() + .enqueue_fulfillment_update( + OrderFulfillmentUpdateEnqueueRequest::new( + seller_actor(), + request_event_ptr(&request_event), + order_event_ptr(&proposal_receipt.signed_event_id), + order_fulfillment_update( + "order-lifecycle-pending-revision", + RadrootsOrderFulfillmentState::ReadyForPickup, + ), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("fulfillment target relays"), + &FixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect_err("pending revision blocks fulfillment"); + assert!(matches!( + fulfillment_error, + RadrootsSdkError::InvalidRequest { .. } + )); + + let blocked_proposal = order_revision_proposal( + "order-lifecycle-pending-revision", + &request_event_id, + &proposal_receipt.signed_event_id, + ); + let proposal_error = sdk + .orders() + .enqueue_revision_proposal( + OrderRevisionProposalEnqueueRequest::new( + seller_actor(), + request_event_ptr(&request_event), + order_event_ptr(&proposal_receipt.signed_event_id), + blocked_proposal, + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("blocked proposal target relays"), + &FixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect_err("pending revision blocks new proposal"); + assert!(matches!( + proposal_error, + RadrootsSdkError::InvalidRequest { .. } + )); + assert_eq!( + store + .status_summary() + .await + .expect("event store status") + .total_events, + 3 + ); +} + +#[tokio::test] +async fn order_declined_revision_clears_pending_and_allows_follow_on_lifecycle() { + let (_tempdir, sdk, store) = directory_sdk_and_store().await; + let request_event = signed_order_request_event("order-lifecycle-declined-revision", 56); + let request_event_id = RadrootsEventId::parse(request_event.id.as_str()).expect("request id"); + store + .ingest_event(RadrootsEventIngest::new(request_event.clone(), 5_600)) + .await + .expect("ingest request"); + let decision_receipt = sdk + .orders() + .enqueue_decision( + OrderDecisionEnqueueRequest::new( + seller_actor(), + request_event_ptr(&request_event), + order_decision("order-lifecycle-declined-revision"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("decision target relays"), + &FixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue decision"); + let proposal = order_revision_proposal( + "order-lifecycle-declined-revision", + &request_event_id, + &decision_receipt.signed_event_id, + ); + let proposal_receipt = sdk + .orders() + .enqueue_revision_proposal( + OrderRevisionProposalEnqueueRequest::new( + seller_actor(), + request_event_ptr(&request_event), + order_event_ptr(&decision_receipt.signed_event_id), + proposal.clone(), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("proposal target relays"), + &FixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue revision proposal"); + let declined_revision = order_revision_decision( + &proposal, + &proposal_receipt.signed_event_id, + RadrootsOrderRevisionOutcome::Declined { + reason: "keep original order".to_owned(), + }, + ); + let declined_revision_receipt = sdk + .orders() + .enqueue_revision_decision( + OrderRevisionDecisionEnqueueRequest::new( + buyer_actor(), + request_event_ptr(&request_event), + order_event_ptr(&proposal_receipt.signed_event_id), + declined_revision, + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("declined revision target relays"), + &FixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue declined revision"); + + let status = sdk + .orders() + .status(status_request("order-lifecycle-declined-revision")) + .await + .expect("status"); + assert_eq!(status.status, OrderStatusKind::Accepted); + assert_eq!(status.event_count, 4); + assert_eq!( + status + .agreement_event_id + .as_ref() + .map(RadrootsEventId::as_str), + Some(decision_receipt.signed_event_id.as_str()) + ); + assert_eq!(status.pending_revision_event_id, None); + assert_eq!( + status.last_event_id.as_ref().map(RadrootsEventId::as_str), + Some(declined_revision_receipt.signed_event_id.as_str()) + ); + + let second_decision = order_revision_decision( + &proposal, + &proposal_receipt.signed_event_id, + RadrootsOrderRevisionOutcome::Accepted, + ); + let second_decision_error = sdk + .orders() + .enqueue_revision_decision( + OrderRevisionDecisionEnqueueRequest::new( + buyer_actor(), + request_event_ptr(&request_event), + order_event_ptr(&proposal_receipt.signed_event_id), + second_decision, + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("second decision target relays"), + &FixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await + .expect_err("second revision decision after decline"); + assert!(matches!( + second_decision_error, + RadrootsSdkError::InvalidRequest { .. } + )); + assert_eq!( + store + .status_summary() + .await + .expect("event store status") + .total_events, + 4 + ); + + let fulfillment_receipt = sdk + .orders() + .enqueue_fulfillment_update( + OrderFulfillmentUpdateEnqueueRequest::new( + seller_actor(), + request_event_ptr(&request_event), + order_event_ptr(&declined_revision_receipt.signed_event_id), + order_fulfillment_update( + "order-lifecycle-declined-revision", + RadrootsOrderFulfillmentState::ReadyForPickup, + ), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) + .expect("fulfillment target relays"), + &FixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue fulfillment"); + + let status = sdk + .orders() + .status(status_request("order-lifecycle-declined-revision")) + .await + .expect("status"); + assert_eq!(status.status, OrderStatusKind::Accepted); + assert_eq!(status.event_count, 5); + assert_eq!( + status + .agreement_event_id + .as_ref() + .map(RadrootsEventId::as_str), + Some(decision_receipt.signed_event_id.as_str()) + ); + assert_eq!(status.pending_revision_event_id, None); + assert_eq!( + status + .fulfillment_event_id + .as_ref() + .map(RadrootsEventId::as_str), + Some(fulfillment_receipt.signed_event_id.as_str()) + ); + assert_eq!( + status.last_event_id.as_ref().map(RadrootsEventId::as_str), + Some(fulfillment_receipt.signed_event_id.as_str()) + ); + assert_eq!( + status.fulfillment_status, + Some(OrderFulfillmentStatusKind::ReadyForPickup) + ); + assert!(status.issues.is_empty()); +} + +#[tokio::test] async fn order_cancel_lifecycle_enqueue_updates_status() { let (_tempdir, sdk, store) = directory_sdk_and_store().await; let request_event = signed_order_request_event("order-lifecycle-cancel", 60);