lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 296fbbfad980580370d2f851b3f07544e50b5a75
parent 9eee1823f01dbc1579643d457804706bd8403264
Author: triesap <tyson@radroots.org>
Date:   Tue, 30 Jun 2026 03:18:41 +0000

sdk: add trade product workflow clients

Diffstat:
Mcrates/sdk/src/lib.rs | 16+++++++++-------
Mcrates/sdk/src/orders_runtime.rs | 828++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/tests/orders_runtime.rs | 179+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/sdk/tests/source_boundary.rs | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 1066 insertions(+), 22 deletions(-)

diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -108,14 +108,16 @@ pub use crate::orders_runtime::{ TRADE_CANCELLATION_OPERATION_KIND, TRADE_DECISION_OPERATION_KIND, TRADE_REVISION_DECISION_OPERATION_KIND, TRADE_REVISION_PROPOSAL_OPERATION_KIND, TRADE_STATUS_DEFAULT_LIMIT, TRADE_STATUS_MAX_LIMIT, TRADE_SUBMIT_OPERATION_KIND, - TradeCancellationEnqueueRequest, TradeCancellationPlan, TradeCancellationPrepareRequest, - TradeCancellationReceipt, TradeDecisionEnqueueRequest, TradeDecisionPlan, - TradeDecisionPrepareRequest, TradeDecisionReceipt, TradeEvidenceIngestReceipt, - TradeEvidenceIngestRequest, TradeRequestEvidenceIngestReceipt, - TradeRequestEvidenceIngestRequest, TradeRevisionDecisionEnqueueRequest, - TradeRevisionDecisionPlan, TradeRevisionDecisionPrepareRequest, TradeRevisionDecisionReceipt, - TradeRevisionProposalEnqueueRequest, TradeRevisionProposalPlan, + TradeAcceptRequest, TradeCancelRequest, TradeCancellationEnqueueRequest, TradeCancellationPlan, + TradeCancellationPrepareRequest, TradeCancellationReceipt, TradeDecisionEnqueueRequest, + TradeDecisionPlan, TradeDecisionPrepareRequest, TradeDecisionReceipt, TradeDeclineRequest, + TradeEvidenceIngestReceipt, TradeEvidenceIngestRequest, TradeProposeRequest, + TradeRequestEvidenceIngestReceipt, TradeRequestEvidenceIngestRequest, TradeResyncReceipt, + TradeResyncRequest, TradeRevisionDecisionEnqueueRequest, TradeRevisionDecisionPlan, + TradeRevisionDecisionPrepareRequest, TradeRevisionDecisionReceipt, + TradeRevisionDecisionRequest, TradeRevisionProposalEnqueueRequest, TradeRevisionProposalPlan, TradeRevisionProposalPrepareRequest, TradeRevisionProposalReceipt, + TradeRevisionProposalRequest, TradeSellerInboxReceipt, TradeSellerInboxRequest, TradeStatusAmbiguityCandidate, TradeStatusEligibility, TradeStatusEvidenceSummary, TradeStatusKind, TradeStatusNextActionKind, TradeStatusReceipt, TradeStatusRequest, TradeSubmitEnqueueRequest, TradeSubmitPlan, TradeSubmitPrepareRequest, TradeSubmitReceipt, diff --git a/crates/sdk/src/orders_runtime.rs b/crates/sdk/src/orders_runtime.rs @@ -3,8 +3,8 @@ use crate::workflow_runtime::enqueue_configured_signed_workflow; #[cfg(feature = "runtime")] use crate::{ AckPolicy, PublishMode, RadrootsSdkError, RadrootsSdkRecoveryAction, RadrootsSdkTimestamp, - RelayResolutionPolicy, SdkIdempotencyKey, SdkMutationState, SdkRelayUrlPolicy, TradesClient, - order, + RelayResolutionPolicy, SdkIdempotencyKey, SdkMutationState, SdkRelayUrlPolicy, + TradeBuyerClient, TradeResyncClient, TradeSellerClient, TradeStatusClient, TradesClient, order, workflow_runtime::{SdkWorkflowEnqueueRequest, enqueue_signed_workflow}, }; #[cfg(feature = "runtime")] @@ -16,15 +16,21 @@ use radroots_events::{ RadrootsNostrEvent, RadrootsNostrEventPtr, contract::RadrootsActorRole, draft::RadrootsFrozenEventDraft, - ids::{RadrootsEventId, RadrootsListingAddress, RadrootsOrderId, RadrootsPublicKey}, + ids::{ + RadrootsEventId, RadrootsListingAddress, RadrootsOrderId, RadrootsOrderRevisionId, + RadrootsPublicKey, + }, kinds::{ KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST, KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL, }, order::{ - RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderEconomics, - RadrootsOrderRequest, RadrootsOrderRevisionDecision, RadrootsOrderRevisionProposal, + RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome, + RadrootsOrderEconomics, RadrootsOrderInventoryCommitment, RadrootsOrderItem, + RadrootsOrderRequest, RadrootsOrderRevisionDecision, RadrootsOrderRevisionOutcome, + RadrootsOrderRevisionProposal, }, + tags::TAG_P, }; #[cfg(feature = "runtime")] use radroots_events_codec::order::{ @@ -37,11 +43,12 @@ use radroots_events_codec::wire::{WireEventParts, to_frozen_draft}; use radroots_trade::identity::{RadrootsTradeLocator, RadrootsTradeLocatorCandidate}; #[cfg(feature = "runtime")] use radroots_trade::order::{ - RadrootsOrderCanonicalizationError, RadrootsOrderIssue, RadrootsOrderProjection, - RadrootsOrderProjectionQueryResult, RadrootsOrderStoreQueryError, + RadrootsOrderCanonicalizationError, RadrootsOrderEventRecord, RadrootsOrderIssue, + RadrootsOrderProjection, RadrootsOrderProjectionQueryResult, RadrootsOrderStoreQueryError, RadrootsTradeLocatorProjectionQueryResult, RadrootsTradeLocatorProjectionResolution, canonicalize_order_decision_for_signer, canonicalize_order_request_for_signer, - order_projection_query_for_order_id, order_projection_query_for_trade_locator, + order_event_record_from_event, order_projection_query_for_order_id, + order_projection_query_for_trade_locator, }; #[cfg(feature = "runtime")] use radroots_trade::workflow::RadrootsTradeWorkflowState; @@ -257,6 +264,8 @@ pub struct TradeSubmitPlan { pub workflow: TradeWorkflowPlan, pub order_id: RadrootsOrderId, pub listing_addr: RadrootsListingAddress, + pub buyer_pubkey: RadrootsPublicKey, + pub seller_pubkey: RadrootsPublicKey, pub listing_event_id: RadrootsEventId, pub expected_event_id: RadrootsEventId, pub frozen_draft: RadrootsFrozenEventDraft, @@ -267,8 +276,11 @@ pub struct TradeSubmitPlan { #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct TradeSubmitReceipt { pub workflow: TradeWorkflowEnqueueReceipt, + pub locator: RadrootsTradeLocator, pub order_id: RadrootsOrderId, pub listing_addr: RadrootsListingAddress, + pub buyer_pubkey: RadrootsPublicKey, + pub seller_pubkey: RadrootsPublicKey, pub listing_event_id: RadrootsEventId, pub expected_event_id: RadrootsEventId, pub signed_event_id: RadrootsEventId, @@ -467,6 +479,7 @@ pub struct TradeDecisionPlan { #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct TradeDecisionReceipt { pub workflow: TradeWorkflowEnqueueReceipt, + pub locator: RadrootsTradeLocator, pub order_id: RadrootsOrderId, pub listing_addr: RadrootsListingAddress, pub buyer_pubkey: RadrootsPublicKey, @@ -607,6 +620,7 @@ pub struct TradeRevisionProposalPlan { #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct TradeRevisionProposalReceipt { pub workflow: TradeWorkflowEnqueueReceipt, + pub locator: RadrootsTradeLocator, pub order_id: RadrootsOrderId, pub listing_addr: RadrootsListingAddress, pub buyer_pubkey: RadrootsPublicKey, @@ -748,6 +762,7 @@ pub struct TradeRevisionDecisionPlan { #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct TradeRevisionDecisionReceipt { pub workflow: TradeWorkflowEnqueueReceipt, + pub locator: RadrootsTradeLocator, pub order_id: RadrootsOrderId, pub listing_addr: RadrootsListingAddress, pub buyer_pubkey: RadrootsPublicKey, @@ -889,6 +904,7 @@ pub struct TradeCancellationPlan { #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct TradeCancellationReceipt { pub workflow: TradeWorkflowEnqueueReceipt, + pub locator: RadrootsTradeLocator, pub order_id: RadrootsOrderId, pub listing_addr: RadrootsListingAddress, pub buyer_pubkey: RadrootsPublicKey, @@ -905,6 +921,384 @@ pub struct TradeCancellationReceipt { } #[cfg(feature = "runtime")] +#[derive(Clone, Debug, serde::Serialize)] +#[non_exhaustive] +pub struct TradeProposeRequest { + #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] + pub actor: RadrootsActorContext, + pub listing_event: RadrootsNostrEventPtr, + pub order: RadrootsOrderRequest, + pub target_relays: RelayResolutionPolicy, + pub publish_mode: PublishMode, + pub ack_policy: AckPolicy, + pub idempotency_key: Option<SdkIdempotencyKey>, + pub created_at: Option<RadrootsSdkTimestamp>, +} + +#[cfg(feature = "runtime")] +impl TradeProposeRequest { + pub fn new( + actor: RadrootsActorContext, + listing_event: RadrootsNostrEventPtr, + order: RadrootsOrderRequest, + target_relays: RelayResolutionPolicy, + publish_mode: PublishMode, + ack_policy: AckPolicy, + ) -> Self { + Self { + actor, + listing_event, + order, + target_relays, + publish_mode, + ack_policy, + idempotency_key: None, + created_at: None, + } + } + + pub fn try_with_idempotency_key( + mut self, + idempotency_key: impl AsRef<str>, + ) -> Result<Self, RadrootsSdkError> { + self.idempotency_key = Some(SdkIdempotencyKey::new(idempotency_key)?); + Ok(self) + } + + pub fn with_created_at(mut self, created_at: RadrootsSdkTimestamp) -> Self { + self.created_at = Some(created_at); + self + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, serde::Serialize)] +#[non_exhaustive] +pub struct TradeAcceptRequest { + #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] + pub actor: RadrootsActorContext, + pub locator: RadrootsTradeLocator, + pub inventory_commitments: Vec<RadrootsOrderInventoryCommitment>, + pub target_relays: RelayResolutionPolicy, + pub publish_mode: PublishMode, + pub ack_policy: AckPolicy, + pub idempotency_key: Option<SdkIdempotencyKey>, + pub created_at: Option<RadrootsSdkTimestamp>, +} + +#[cfg(feature = "runtime")] +impl TradeAcceptRequest { + pub fn new( + actor: RadrootsActorContext, + locator: RadrootsTradeLocator, + inventory_commitments: Vec<RadrootsOrderInventoryCommitment>, + target_relays: RelayResolutionPolicy, + publish_mode: PublishMode, + ack_policy: AckPolicy, + ) -> Self { + Self { + actor, + locator, + inventory_commitments, + target_relays, + publish_mode, + ack_policy, + idempotency_key: None, + created_at: None, + } + } + + pub fn try_with_idempotency_key( + mut self, + idempotency_key: impl AsRef<str>, + ) -> Result<Self, RadrootsSdkError> { + self.idempotency_key = Some(SdkIdempotencyKey::new(idempotency_key)?); + Ok(self) + } + + pub fn with_created_at(mut self, created_at: RadrootsSdkTimestamp) -> Self { + self.created_at = Some(created_at); + self + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, serde::Serialize)] +#[non_exhaustive] +pub struct TradeDeclineRequest { + #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] + pub actor: RadrootsActorContext, + pub locator: RadrootsTradeLocator, + pub reason: String, + pub target_relays: RelayResolutionPolicy, + pub publish_mode: PublishMode, + pub ack_policy: AckPolicy, + pub idempotency_key: Option<SdkIdempotencyKey>, + pub created_at: Option<RadrootsSdkTimestamp>, +} + +#[cfg(feature = "runtime")] +impl TradeDeclineRequest { + pub fn new( + actor: RadrootsActorContext, + locator: RadrootsTradeLocator, + reason: impl Into<String>, + target_relays: RelayResolutionPolicy, + publish_mode: PublishMode, + ack_policy: AckPolicy, + ) -> Self { + Self { + actor, + locator, + reason: reason.into(), + target_relays, + publish_mode, + ack_policy, + idempotency_key: None, + created_at: None, + } + } + + pub fn try_with_idempotency_key( + mut self, + idempotency_key: impl AsRef<str>, + ) -> Result<Self, RadrootsSdkError> { + self.idempotency_key = Some(SdkIdempotencyKey::new(idempotency_key)?); + Ok(self) + } + + pub fn with_created_at(mut self, created_at: RadrootsSdkTimestamp) -> Self { + self.created_at = Some(created_at); + self + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, serde::Serialize)] +#[non_exhaustive] +pub struct TradeCancelRequest { + #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] + pub actor: RadrootsActorContext, + pub locator: RadrootsTradeLocator, + pub reason: String, + pub target_relays: RelayResolutionPolicy, + pub publish_mode: PublishMode, + pub ack_policy: AckPolicy, + pub idempotency_key: Option<SdkIdempotencyKey>, + pub created_at: Option<RadrootsSdkTimestamp>, +} + +#[cfg(feature = "runtime")] +impl TradeCancelRequest { + pub fn new( + actor: RadrootsActorContext, + locator: RadrootsTradeLocator, + reason: impl Into<String>, + target_relays: RelayResolutionPolicy, + publish_mode: PublishMode, + ack_policy: AckPolicy, + ) -> Self { + Self { + actor, + locator, + reason: reason.into(), + target_relays, + publish_mode, + ack_policy, + idempotency_key: None, + created_at: None, + } + } + + pub fn try_with_idempotency_key( + mut self, + idempotency_key: impl AsRef<str>, + ) -> Result<Self, RadrootsSdkError> { + self.idempotency_key = Some(SdkIdempotencyKey::new(idempotency_key)?); + Ok(self) + } + + pub fn with_created_at(mut self, created_at: RadrootsSdkTimestamp) -> Self { + self.created_at = Some(created_at); + self + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, serde::Serialize)] +#[non_exhaustive] +pub struct TradeRevisionProposalRequest { + #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] + pub actor: RadrootsActorContext, + pub locator: RadrootsTradeLocator, + pub revision_id: RadrootsOrderRevisionId, + pub items: Vec<RadrootsOrderItem>, + pub economics: RadrootsOrderEconomics, + pub reason: String, + pub target_relays: RelayResolutionPolicy, + pub publish_mode: PublishMode, + pub ack_policy: AckPolicy, + pub idempotency_key: Option<SdkIdempotencyKey>, + pub created_at: Option<RadrootsSdkTimestamp>, +} + +#[cfg(feature = "runtime")] +impl TradeRevisionProposalRequest { + pub fn new( + actor: RadrootsActorContext, + locator: RadrootsTradeLocator, + revision_id: RadrootsOrderRevisionId, + items: Vec<RadrootsOrderItem>, + economics: RadrootsOrderEconomics, + reason: impl Into<String>, + target_relays: RelayResolutionPolicy, + publish_mode: PublishMode, + ack_policy: AckPolicy, + ) -> Self { + Self { + actor, + locator, + revision_id, + items, + economics, + reason: reason.into(), + target_relays, + publish_mode, + ack_policy, + idempotency_key: None, + created_at: None, + } + } + + pub fn try_with_idempotency_key( + mut self, + idempotency_key: impl AsRef<str>, + ) -> Result<Self, RadrootsSdkError> { + self.idempotency_key = Some(SdkIdempotencyKey::new(idempotency_key)?); + Ok(self) + } + + pub fn with_created_at(mut self, created_at: RadrootsSdkTimestamp) -> Self { + self.created_at = Some(created_at); + self + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, serde::Serialize)] +#[non_exhaustive] +pub struct TradeRevisionDecisionRequest { + #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] + pub actor: RadrootsActorContext, + pub locator: RadrootsTradeLocator, + pub revision_id: RadrootsOrderRevisionId, + pub decision: RadrootsOrderRevisionOutcome, + pub target_relays: RelayResolutionPolicy, + pub publish_mode: PublishMode, + pub ack_policy: AckPolicy, + pub idempotency_key: Option<SdkIdempotencyKey>, + pub created_at: Option<RadrootsSdkTimestamp>, +} + +#[cfg(feature = "runtime")] +impl TradeRevisionDecisionRequest { + pub fn new( + actor: RadrootsActorContext, + locator: RadrootsTradeLocator, + revision_id: RadrootsOrderRevisionId, + decision: RadrootsOrderRevisionOutcome, + target_relays: RelayResolutionPolicy, + publish_mode: PublishMode, + ack_policy: AckPolicy, + ) -> Self { + Self { + actor, + locator, + revision_id, + decision, + target_relays, + publish_mode, + ack_policy, + idempotency_key: None, + created_at: None, + } + } + + pub fn try_with_idempotency_key( + mut self, + idempotency_key: impl AsRef<str>, + ) -> Result<Self, RadrootsSdkError> { + self.idempotency_key = Some(SdkIdempotencyKey::new(idempotency_key)?); + Ok(self) + } + + pub fn with_created_at(mut self, created_at: RadrootsSdkTimestamp) -> Self { + self.created_at = Some(created_at); + self + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, serde::Serialize)] +#[non_exhaustive] +pub struct TradeSellerInboxRequest { + #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] + pub actor: RadrootsActorContext, + pub limit: u32, +} + +#[cfg(feature = "runtime")] +impl TradeSellerInboxRequest { + pub fn new(actor: RadrootsActorContext) -> Self { + Self { + actor, + limit: TRADE_STATUS_DEFAULT_LIMIT, + } + } + + pub fn with_limit(mut self, limit: u32) -> Self { + self.limit = limit; + self + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct TradeSellerInboxReceipt { + pub seller_pubkey: RadrootsPublicKey, + pub statuses: Vec<TradeStatusReceipt>, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, serde::Serialize)] +#[non_exhaustive] +pub struct TradeResyncRequest { + pub locator: RadrootsTradeLocator, + pub limit: u32, +} + +#[cfg(feature = "runtime")] +impl TradeResyncRequest { + pub fn new(locator: RadrootsTradeLocator) -> Self { + Self { + locator, + limit: TRADE_STATUS_DEFAULT_LIMIT, + } + } + + pub fn with_limit(mut self, limit: u32) -> Self { + self.limit = limit; + self + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct TradeResyncReceipt { + pub status: TradeStatusReceipt, +} + +#[cfg(feature = "runtime")] #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] #[non_exhaustive] pub struct TradeStatusRequest { @@ -2025,6 +2419,388 @@ impl<'sdk> TradesClient<'sdk> { } #[cfg(feature = "runtime")] +impl<'sdk> TradeStatusClient<'sdk> { + pub async fn status( + &self, + request: TradeStatusRequest, + ) -> Result<TradeStatusReceipt, RadrootsSdkError> { + trades_client(self.sdk).status(request).await + } +} + +#[cfg(feature = "runtime")] +impl<'sdk> TradeResyncClient<'sdk> { + pub async fn resync( + &self, + request: TradeResyncRequest, + ) -> Result<TradeResyncReceipt, RadrootsSdkError> { + let status = trades_client(self.sdk) + .status(TradeStatusRequest::new(request.locator).with_limit(request.limit)) + .await?; + Ok(TradeResyncReceipt { status }) + } +} + +#[cfg(all(feature = "runtime", feature = "signer-adapters"))] +impl<'sdk> TradeBuyerClient<'sdk> { + pub async fn propose_trade( + &self, + request: TradeProposeRequest, + ) -> Result<TradeSubmitReceipt, RadrootsSdkError> { + trades_client(self.sdk) + .enqueue_submit(TradeSubmitEnqueueRequest { + actor: request.actor, + listing_event: request.listing_event, + order: request.order, + target_relays: request.target_relays, + publish_mode: request.publish_mode, + ack_policy: request.ack_policy, + idempotency_key: request.idempotency_key, + created_at: request.created_at, + }) + .await + } + + pub async fn cancel_trade( + &self, + request: TradeCancelRequest, + ) -> Result<TradeCancellationReceipt, RadrootsSdkError> { + let context = trade_mutation_context(self.sdk, request.locator, "trade.cancel").await?; + let cancellation = RadrootsOrderCancellation { + order_id: context.order_id.clone(), + listing_addr: context.listing_addr.clone(), + buyer_pubkey: context.buyer_pubkey.clone(), + seller_pubkey: context.seller_pubkey.clone(), + reason: request.reason, + }; + trades_client(self.sdk) + .enqueue_cancellation(TradeCancellationEnqueueRequest { + actor: request.actor, + root_event: event_ptr(&context.root_event_id), + previous_event: event_ptr(&context.previous_event_id), + cancellation, + target_relays: request.target_relays, + publish_mode: request.publish_mode, + ack_policy: request.ack_policy, + idempotency_key: request.idempotency_key, + created_at: request.created_at, + }) + .await + } + + pub async fn accept_revision( + &self, + mut request: TradeRevisionDecisionRequest, + ) -> Result<TradeRevisionDecisionReceipt, RadrootsSdkError> { + request.decision = RadrootsOrderRevisionOutcome::Accepted; + self.decide_revision(request).await + } + + pub async fn decline_revision( + &self, + request: TradeRevisionDecisionRequest, + ) -> Result<TradeRevisionDecisionReceipt, RadrootsSdkError> { + self.decide_revision(request).await + } + + async fn decide_revision( + &self, + request: TradeRevisionDecisionRequest, + ) -> Result<TradeRevisionDecisionReceipt, RadrootsSdkError> { + let context = + trade_mutation_context(self.sdk, request.locator, "trade.revision_decision").await?; + let previous_event_id = context.pending_revision_event_id.clone().ok_or_else(|| { + RadrootsSdkError::InvalidRequest { + message: "trade revision decision requires a pending revision".to_owned(), + } + })?; + let decision = RadrootsOrderRevisionDecision { + revision_id: request.revision_id, + order_id: context.order_id.clone(), + listing_addr: context.listing_addr.clone(), + buyer_pubkey: context.buyer_pubkey.clone(), + seller_pubkey: context.seller_pubkey.clone(), + root_event_id: context.root_event_id.clone(), + prev_event_id: previous_event_id.clone(), + decision: request.decision, + }; + trades_client(self.sdk) + .enqueue_revision_decision(TradeRevisionDecisionEnqueueRequest { + actor: request.actor, + root_event: event_ptr(&context.root_event_id), + previous_event: event_ptr(&previous_event_id), + decision, + target_relays: request.target_relays, + publish_mode: request.publish_mode, + ack_policy: request.ack_policy, + idempotency_key: request.idempotency_key, + created_at: request.created_at, + }) + .await + } +} + +#[cfg(all(feature = "runtime", feature = "signer-adapters"))] +impl<'sdk> TradeSellerClient<'sdk> { + pub async fn inbox( + &self, + request: TradeSellerInboxRequest, + ) -> Result<TradeSellerInboxReceipt, RadrootsSdkError> { + require_seller_actor(&request.actor, "trade.inbox")?; + if request.limit == 0 || request.limit > TRADE_STATUS_MAX_LIMIT { + return Err(RadrootsSdkError::trade_status_limit_invalid( + request.limit, + 1, + TRADE_STATUS_MAX_LIMIT, + )); + } + let seller_pubkey = + RadrootsPublicKey::parse(request.actor.pubkey().as_str()).map_err(|error| { + RadrootsSdkError::InvalidRequest { + message: format!("seller actor pubkey is invalid: {error}"), + } + })?; + let events = self + .sdk + ._event_store + .events_by_tag(TAG_P, request.actor.pubkey().as_str(), request.limit) + .await?; + let mut locators = Vec::new(); + for event in events { + let tags = serde_json::from_str(&event.tags_json).map_err(|error| { + RadrootsSdkError::Projection { + message: format!( + "stored trade inbox event {} contains invalid tags_json: {error}", + event.event_id + ), + } + })?; + let nostr_event = RadrootsNostrEvent { + id: event.event_id, + author: event.pubkey, + created_at: event.created_at, + kind: event.kind, + tags, + content: event.content, + sig: event.sig, + }; + if let Ok(RadrootsOrderEventRecord::Request(record)) = + order_event_record_from_event(&nostr_event) + { + if record.payload.seller_pubkey == seller_pubkey { + locators.push( + RadrootsTradeLocator::from_order_id(record.payload.order_id) + .with_root_event_id(record.event_id) + .with_listing_addr(record.payload.listing_addr) + .with_buyer_pubkey(record.payload.buyer_pubkey) + .with_seller_pubkey(record.payload.seller_pubkey), + ); + } + } + } + locators.sort_by(|left, right| left.root_event_id.cmp(&right.root_event_id)); + locators.dedup_by(|left, right| left.root_event_id == right.root_event_id); + let mut statuses = Vec::with_capacity(locators.len()); + for locator in locators { + statuses.push( + trades_client(self.sdk) + .status(TradeStatusRequest::new(locator)) + .await?, + ); + } + Ok(TradeSellerInboxReceipt { + seller_pubkey, + statuses, + }) + } + + pub async fn accept_trade( + &self, + request: TradeAcceptRequest, + ) -> Result<TradeDecisionReceipt, RadrootsSdkError> { + let context = trade_mutation_context(self.sdk, request.locator, "trade.accept").await?; + let decision = RadrootsOrderDecision { + order_id: context.order_id.clone(), + listing_addr: context.listing_addr.clone(), + buyer_pubkey: context.buyer_pubkey.clone(), + seller_pubkey: context.seller_pubkey.clone(), + decision: RadrootsOrderDecisionOutcome::Accepted { + inventory_commitments: request.inventory_commitments, + }, + }; + trades_client(self.sdk) + .enqueue_decision(TradeDecisionEnqueueRequest { + actor: request.actor, + request_event: event_ptr(&context.root_event_id), + decision, + target_relays: request.target_relays, + publish_mode: request.publish_mode, + ack_policy: request.ack_policy, + idempotency_key: request.idempotency_key, + created_at: request.created_at, + }) + .await + } + + pub async fn decline_trade( + &self, + request: TradeDeclineRequest, + ) -> Result<TradeDecisionReceipt, RadrootsSdkError> { + let context = trade_mutation_context(self.sdk, request.locator, "trade.decline").await?; + let decision = RadrootsOrderDecision { + order_id: context.order_id.clone(), + listing_addr: context.listing_addr.clone(), + buyer_pubkey: context.buyer_pubkey.clone(), + seller_pubkey: context.seller_pubkey.clone(), + decision: RadrootsOrderDecisionOutcome::Declined { + reason: request.reason, + }, + }; + trades_client(self.sdk) + .enqueue_decision(TradeDecisionEnqueueRequest { + actor: request.actor, + request_event: event_ptr(&context.root_event_id), + decision, + target_relays: request.target_relays, + publish_mode: request.publish_mode, + ack_policy: request.ack_policy, + idempotency_key: request.idempotency_key, + created_at: request.created_at, + }) + .await + } + + pub async fn propose_revision( + &self, + request: TradeRevisionProposalRequest, + ) -> Result<TradeRevisionProposalReceipt, RadrootsSdkError> { + let context = + trade_mutation_context(self.sdk, request.locator, "trade.propose_revision").await?; + let proposal = RadrootsOrderRevisionProposal { + revision_id: request.revision_id, + order_id: context.order_id.clone(), + listing_addr: context.listing_addr.clone(), + buyer_pubkey: context.buyer_pubkey.clone(), + seller_pubkey: context.seller_pubkey.clone(), + root_event_id: context.root_event_id.clone(), + prev_event_id: context.previous_event_id.clone(), + items: request.items, + economics: request.economics, + reason: request.reason, + }; + trades_client(self.sdk) + .enqueue_revision_proposal(TradeRevisionProposalEnqueueRequest { + actor: request.actor, + root_event: event_ptr(&context.root_event_id), + previous_event: event_ptr(&context.previous_event_id), + proposal, + target_relays: request.target_relays, + publish_mode: request.publish_mode, + ack_policy: request.ack_policy, + idempotency_key: request.idempotency_key, + created_at: request.created_at, + }) + .await + } +} + +#[cfg(feature = "runtime")] +fn trades_client(sdk: &crate::RadrootsClient) -> TradesClient<'_> { + TradesClient { sdk } +} + +#[cfg(feature = "runtime")] +struct TradeProductMutationContext { + order_id: RadrootsOrderId, + listing_addr: RadrootsListingAddress, + buyer_pubkey: RadrootsPublicKey, + seller_pubkey: RadrootsPublicKey, + root_event_id: RadrootsEventId, + previous_event_id: RadrootsEventId, + pending_revision_event_id: Option<RadrootsEventId>, +} + +#[cfg(feature = "runtime")] +async fn trade_mutation_context( + sdk: &crate::RadrootsClient, + locator: RadrootsTradeLocator, + operation: &'static str, +) -> Result<TradeProductMutationContext, RadrootsSdkError> { + let status = trades_client(sdk) + .status(TradeStatusRequest::new(locator)) + .await?; + if status.status == TradeStatusKind::Ambiguous { + let roots = status + .ambiguity_candidates + .iter() + .filter_map(|candidate| candidate.locator.root_event_id.as_ref()) + .map(RadrootsEventId::as_str) + .collect::<Vec<_>>() + .join(","); + return Err(RadrootsSdkError::InvalidRequest { + message: format!("{operation} requires a unique trade root; candidate_roots={roots}"), + }); + } + if !status.found { + return Err(RadrootsSdkError::InvalidRequest { + message: format!("{operation} requires a locally projected trade"), + }); + } + let root_event_id = + status + .root_event_id + .clone() + .ok_or_else(|| RadrootsSdkError::InvalidRequest { + message: format!("{operation} requires a trade root event id"), + })?; + let previous_event_id = + status + .last_event_id + .clone() + .ok_or_else(|| RadrootsSdkError::InvalidRequest { + message: format!("{operation} requires local lifecycle evidence"), + })?; + let listing_addr = + status + .listing_addr + .clone() + .ok_or_else(|| RadrootsSdkError::InvalidRequest { + message: format!("{operation} requires listing address evidence"), + })?; + let buyer_pubkey = + status + .buyer_pubkey + .clone() + .ok_or_else(|| RadrootsSdkError::InvalidRequest { + message: format!("{operation} requires buyer pubkey evidence"), + })?; + let seller_pubkey = + status + .seller_pubkey + .clone() + .ok_or_else(|| RadrootsSdkError::InvalidRequest { + message: format!("{operation} requires seller pubkey evidence"), + })?; + Ok(TradeProductMutationContext { + order_id: status.order_id, + listing_addr, + buyer_pubkey, + seller_pubkey, + root_event_id, + previous_event_id, + pending_revision_event_id: status.pending_revision_event_id, + }) +} + +#[cfg(feature = "runtime")] +fn event_ptr(event_id: &RadrootsEventId) -> RadrootsNostrEventPtr { + RadrootsNostrEventPtr { + id: event_id.as_str().to_owned(), + relays: None, + } +} + +#[cfg(feature = "runtime")] fn validate_trade_enqueue_policy( publish_mode: PublishMode, ack_policy: AckPolicy, @@ -2344,6 +3120,8 @@ fn order_submit_plan( let created_at_nostr = created_at.try_into_nostr_created_at()?; let order_id = order_request.order_id.clone(); let listing_addr = order_request.listing_addr.clone(); + let buyer_pubkey = order_request.buyer_pubkey.clone(); + let seller_pubkey = order_request.seller_pubkey.clone(); let draft = order::build_order_request_draft(&listing_event, &order_request).map_err(|error| { RadrootsSdkError::InvalidRequest { @@ -2367,6 +3145,8 @@ fn order_submit_plan( ), order_id, listing_addr, + buyer_pubkey, + seller_pubkey, listing_event_id, expected_event_id, frozen_draft, @@ -2613,14 +3393,22 @@ fn order_submit_receipt( plan: TradeSubmitPlan, enqueue: crate::workflow_runtime::SdkWorkflowEnqueueReceipt, ) -> TradeSubmitReceipt { + let locator = RadrootsTradeLocator::from_order_id(plan.order_id.clone()) + .with_root_event_id(enqueue.signed_event_id.clone()) + .with_listing_addr(plan.listing_addr.clone()) + .with_buyer_pubkey(plan.buyer_pubkey.clone()) + .with_seller_pubkey(plan.seller_pubkey.clone()); TradeSubmitReceipt { workflow: order_workflow_enqueue_receipt( TradeWorkflowKind::Submit, plan.expected_event_id.clone(), &enqueue, ), + locator, order_id: plan.order_id, listing_addr: plan.listing_addr, + buyer_pubkey: plan.buyer_pubkey, + seller_pubkey: plan.seller_pubkey, listing_event_id: plan.listing_event_id, expected_event_id: plan.expected_event_id, signed_event_id: enqueue.signed_event_id, @@ -2637,12 +3425,18 @@ fn order_decision_receipt( plan: TradeDecisionPlan, enqueue: crate::workflow_runtime::SdkWorkflowEnqueueReceipt, ) -> TradeDecisionReceipt { + let locator = RadrootsTradeLocator::from_order_id(plan.order_id.clone()) + .with_root_event_id(plan.request_event_id.clone()) + .with_listing_addr(plan.listing_addr.clone()) + .with_buyer_pubkey(plan.buyer_pubkey.clone()) + .with_seller_pubkey(plan.seller_pubkey.clone()); TradeDecisionReceipt { workflow: order_workflow_enqueue_receipt( TradeWorkflowKind::Decision, plan.expected_event_id.clone(), &enqueue, ), + locator, order_id: plan.order_id, listing_addr: plan.listing_addr, buyer_pubkey: plan.buyer_pubkey, @@ -2663,12 +3457,18 @@ fn order_revision_proposal_receipt( plan: TradeRevisionProposalPlan, enqueue: crate::workflow_runtime::SdkWorkflowEnqueueReceipt, ) -> TradeRevisionProposalReceipt { + let locator = RadrootsTradeLocator::from_order_id(plan.order_id.clone()) + .with_root_event_id(plan.root_event_id.clone()) + .with_listing_addr(plan.listing_addr.clone()) + .with_buyer_pubkey(plan.buyer_pubkey.clone()) + .with_seller_pubkey(plan.seller_pubkey.clone()); TradeRevisionProposalReceipt { workflow: order_workflow_enqueue_receipt( TradeWorkflowKind::RevisionProposal, plan.expected_event_id.clone(), &enqueue, ), + locator, order_id: plan.order_id, listing_addr: plan.listing_addr, buyer_pubkey: plan.buyer_pubkey, @@ -2690,12 +3490,18 @@ fn order_revision_decision_receipt( plan: TradeRevisionDecisionPlan, enqueue: crate::workflow_runtime::SdkWorkflowEnqueueReceipt, ) -> TradeRevisionDecisionReceipt { + let locator = RadrootsTradeLocator::from_order_id(plan.order_id.clone()) + .with_root_event_id(plan.root_event_id.clone()) + .with_listing_addr(plan.listing_addr.clone()) + .with_buyer_pubkey(plan.buyer_pubkey.clone()) + .with_seller_pubkey(plan.seller_pubkey.clone()); TradeRevisionDecisionReceipt { workflow: order_workflow_enqueue_receipt( TradeWorkflowKind::RevisionDecision, plan.expected_event_id.clone(), &enqueue, ), + locator, order_id: plan.order_id, listing_addr: plan.listing_addr, buyer_pubkey: plan.buyer_pubkey, @@ -2717,12 +3523,18 @@ fn order_cancellation_receipt( plan: TradeCancellationPlan, enqueue: crate::workflow_runtime::SdkWorkflowEnqueueReceipt, ) -> TradeCancellationReceipt { + let locator = RadrootsTradeLocator::from_order_id(plan.order_id.clone()) + .with_root_event_id(plan.root_event_id.clone()) + .with_listing_addr(plan.listing_addr.clone()) + .with_buyer_pubkey(plan.buyer_pubkey.clone()) + .with_seller_pubkey(plan.seller_pubkey.clone()); TradeCancellationReceipt { workflow: order_workflow_enqueue_receipt( TradeWorkflowKind::Cancellation, plan.expected_event_id.clone(), &enqueue, ), + locator, order_id: plan.order_id, listing_addr: plan.listing_addr, buyer_pubkey: plan.buyer_pubkey, diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs @@ -1,5 +1,8 @@ #![cfg(feature = "runtime")] +#[cfg(all(feature = "signer-adapters", feature = "local-signer"))] +use std::path::Path; + use radroots_authority::{ RadrootsActorContext, RadrootsEventSigner, RadrootsSignerError, RadrootsSignerIdentity, }; @@ -11,7 +14,7 @@ use radroots_events::{ RadrootsNostrEvent, contract::RadrootsActorRole, draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent}, - ids::{RadrootsEventId, RadrootsOrderId}, + ids::{RadrootsEventId, RadrootsOrderId, RadrootsPublicKey}, kinds::{ KIND_LISTING, KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST, KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL, @@ -40,13 +43,16 @@ use radroots_sdk::{ SdkTradeStatusIssueKind, SdkTradeStatusSource, TRADE_CANCELLATION_OPERATION_KIND, TRADE_DECISION_OPERATION_KIND, TRADE_REVISION_DECISION_OPERATION_KIND, TRADE_REVISION_PROPOSAL_OPERATION_KIND, TRADE_STATUS_DEFAULT_LIMIT, TRADE_STATUS_MAX_LIMIT, - TRADE_SUBMIT_OPERATION_KIND, TradeCancellationEnqueueRequest, TradeCancellationPrepareRequest, - TradeDecisionEnqueueRequest, TradeDecisionPrepareRequest, TradeEvidenceIngestRequest, - TradeRequestEvidenceIngestRequest, TradeRevisionDecisionEnqueueRequest, - TradeRevisionDecisionPrepareRequest, TradeRevisionProposalEnqueueRequest, - TradeRevisionProposalPrepareRequest, TradeStatusKind, TradeStatusNextActionKind, - TradeStatusRequest, TradeSubmitEnqueueRequest, TradeSubmitPrepareRequest, TradeWorkflowKind, + TRADE_SUBMIT_OPERATION_KIND, TradeAcceptRequest, TradeCancellationEnqueueRequest, + TradeCancellationPrepareRequest, TradeDecisionEnqueueRequest, TradeDecisionPrepareRequest, + TradeEvidenceIngestRequest, TradeProposeRequest, TradeRequestEvidenceIngestRequest, + TradeResyncRequest, TradeRevisionDecisionEnqueueRequest, TradeRevisionDecisionPrepareRequest, + TradeRevisionProposalEnqueueRequest, TradeRevisionProposalPrepareRequest, + TradeSellerInboxRequest, TradeStatusKind, TradeStatusNextActionKind, TradeStatusRequest, + TradeSubmitEnqueueRequest, TradeSubmitPrepareRequest, TradeWorkflowKind, }; +#[cfg(all(feature = "signer-adapters", feature = "local-signer"))] +use radroots_sdk::{RadrootsSdkLocalKeySigner, RadrootsSdkSignerProvider}; use radroots_trade::order::RadrootsOrderIssue; use serde::Serialize; use serde::ser::{self, SerializeStruct}; @@ -386,6 +392,21 @@ async fn directory_sdk_and_store() -> (tempfile::TempDir, RadrootsClient, Radroo (tempdir, sdk, store) } +#[cfg(all(feature = "signer-adapters", feature = "local-signer"))] +async fn directory_sdk_with_signer(storage_root: &Path, secret_key_hex: &str) -> RadrootsClient { + let secret_key = RadrootsNostrSecretKey::from_hex(secret_key_hex).expect("secret key"); + let signer_keys = RadrootsNostrKeys::new(secret_key); + RadrootsClient::builder() + .directory_storage(storage_root) + .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)) + .signer_provider(RadrootsSdkSignerProvider::LocalKey( + RadrootsSdkLocalKeySigner::new(signer_keys).expect("local signer"), + )) + .build() + .await + .expect("sdk") +} + fn order_id(raw: &str) -> RadrootsOrderId { RadrootsOrderId::parse(raw).expect("order id") } @@ -432,6 +453,12 @@ fn listing_event_ptr() -> RadrootsNostrEventPtr { } } +fn explicit_trade_relays() -> RelayResolutionPolicy { + RelayResolutionPolicy::explicit( + SdkRelayTargetSet::new([RELAY], SdkRelayUrlPolicy::Public).expect("target relays"), + ) +} + fn deterministic_event_id(raw: &str) -> RadrootsEventId { let mut bytes = [0u8; 32]; for (index, byte) in raw.bytes().enumerate() { @@ -788,6 +815,128 @@ async fn order_submit_enqueue_stores_event_queues_outbox_and_status_sees_request ); } +#[cfg(all(feature = "signer-adapters", feature = "local-signer"))] +#[tokio::test] +async fn trade_product_clients_propose_inbox_accept_status_and_resync() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let storage_root = tempdir.path().join("sdk"); + let buyer_sdk = directory_sdk_with_signer(storage_root.as_path(), BUYER_SECRET_KEY_HEX).await; + let propose_receipt = buyer_sdk + .trades() + .buyer() + .propose_trade( + TradeProposeRequest::new( + buyer_actor(), + listing_event_ptr(), + order_request("trade-product-facade-flow"), + explicit_trade_relays(), + PublishMode::EnqueueOnly, + AckPolicy::NoWait, + ) + .try_with_idempotency_key("trade-product-facade-propose") + .expect("propose idempotency"), + ) + .await + .expect("propose trade"); + + assert_eq!( + propose_receipt.order_id.as_str(), + "trade-product-facade-flow" + ); + assert_eq!( + propose_receipt + .locator + .root_event_id + .as_ref() + .map(RadrootsEventId::as_str), + Some(propose_receipt.signed_event_id.as_str()) + ); + assert_eq!( + propose_receipt.locator.listing_addr, + Some(listing_address()) + ); + assert_eq!( + propose_receipt + .locator + .seller_pubkey + .as_ref() + .map(RadrootsPublicKey::as_str), + Some(SELLER_PUBLIC_KEY_HEX) + ); + + let seller_sdk = directory_sdk_with_signer(storage_root.as_path(), SELLER_SECRET_KEY_HEX).await; + let inbox = seller_sdk + .trades() + .seller() + .inbox(TradeSellerInboxRequest::new(seller_actor())) + .await + .expect("seller inbox"); + assert_eq!(inbox.seller_pubkey.as_str(), SELLER_PUBLIC_KEY_HEX); + assert_eq!(inbox.statuses.len(), 1); + assert_eq!(inbox.statuses[0].status, TradeStatusKind::Requested); + assert_eq!( + inbox.statuses[0] + .root_event_id + .as_ref() + .map(RadrootsEventId::as_str), + Some(propose_receipt.signed_event_id.as_str()) + ); + + let accept_receipt = seller_sdk + .trades() + .seller() + .accept_trade( + TradeAcceptRequest::new( + seller_actor(), + propose_receipt.locator.clone(), + vec![RadrootsOrderInventoryCommitment { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 2, + }], + explicit_trade_relays(), + PublishMode::EnqueueOnly, + AckPolicy::NoWait, + ) + .try_with_idempotency_key("trade-product-facade-accept") + .expect("accept idempotency"), + ) + .await + .expect("accept trade"); + + assert_eq!(accept_receipt.order_id, propose_receipt.order_id); + assert_eq!(accept_receipt.locator, propose_receipt.locator); + assert_eq!( + accept_receipt.request_event_id, + propose_receipt.signed_event_id + ); + let status = seller_sdk + .trades() + .status_client() + .status(TradeStatusRequest::new(propose_receipt.locator.clone())) + .await + .expect("facade status"); + assert_eq!(status.status, TradeStatusKind::AgreedPendingRhi); + assert_eq!( + status + .decision_event_id + .as_ref() + .map(RadrootsEventId::as_str), + Some(accept_receipt.signed_event_id.as_str()) + ); + + let resync = seller_sdk + .trades() + .resync() + .resync(TradeResyncRequest::new(propose_receipt.locator)) + .await + .expect("facade resync"); + assert_eq!(resync.status.status, TradeStatusKind::AgreedPendingRhi); + assert_eq!( + resync.status.last_event_id, + Some(accept_receipt.signed_event_id) + ); +} + #[tokio::test] async fn order_submit_enqueue_returns_sanitized_signer_errors_before_mutation() { let (_tempdir, sdk, store) = directory_sdk_and_store().await; @@ -1183,7 +1332,16 @@ async fn order_submit_runtime_dtos_serialize_deterministically() { } }, "order_id": receipt.order_id.as_str(), + "locator": { + "trade_id": receipt.order_id.as_str(), + "root_event_id": receipt.signed_event_id.as_str(), + "listing_addr": receipt.listing_addr.as_str(), + "buyer_pubkey": BUYER_PUBLIC_KEY_HEX, + "seller_pubkey": SELLER_PUBLIC_KEY_HEX + }, "listing_addr": receipt.listing_addr.as_str(), + "buyer_pubkey": BUYER_PUBLIC_KEY_HEX, + "seller_pubkey": SELLER_PUBLIC_KEY_HEX, "listing_event_id": receipt.listing_event_id.as_str(), "expected_event_id": receipt.expected_event_id.as_str(), "signed_event_id": receipt.signed_event_id.as_str(), @@ -1850,6 +2008,13 @@ async fn order_decision_runtime_dtos_serialize_deterministically() { } }, "order_id": receipt.order_id.as_str(), + "locator": { + "trade_id": receipt.order_id.as_str(), + "root_event_id": request_event.id.as_str(), + "listing_addr": receipt.listing_addr.as_str(), + "buyer_pubkey": BUYER_PUBLIC_KEY_HEX, + "seller_pubkey": SELLER_PUBLIC_KEY_HEX + }, "listing_addr": receipt.listing_addr.as_str(), "buyer_pubkey": BUYER_PUBLIC_KEY_HEX, "seller_pubkey": SELLER_PUBLIC_KEY_HEX, diff --git a/crates/sdk/tests/source_boundary.rs b/crates/sdk/tests/source_boundary.rs @@ -47,6 +47,8 @@ const REQUIRED_TRADE_RUNTIME_EXPORTS: &[&str] = &[ "TRADE_STATUS_DEFAULT_LIMIT", "TRADE_STATUS_MAX_LIMIT", "TRADE_SUBMIT_OPERATION_KIND", + "TradeAcceptRequest", + "TradeCancelRequest", "TradeCancellationEnqueueRequest", "TradeCancellationPlan", "TradeCancellationPrepareRequest", @@ -55,18 +57,26 @@ const REQUIRED_TRADE_RUNTIME_EXPORTS: &[&str] = &[ "TradeDecisionPlan", "TradeDecisionPrepareRequest", "TradeDecisionReceipt", + "TradeDeclineRequest", "TradeEvidenceIngestReceipt", "TradeEvidenceIngestRequest", + "TradeProposeRequest", "TradeRequestEvidenceIngestReceipt", "TradeRequestEvidenceIngestRequest", + "TradeResyncReceipt", + "TradeResyncRequest", "TradeRevisionDecisionEnqueueRequest", "TradeRevisionDecisionPlan", "TradeRevisionDecisionPrepareRequest", "TradeRevisionDecisionReceipt", + "TradeRevisionDecisionRequest", "TradeRevisionProposalEnqueueRequest", "TradeRevisionProposalPlan", "TradeRevisionProposalPrepareRequest", + "TradeRevisionProposalRequest", "TradeRevisionProposalReceipt", + "TradeSellerInboxReceipt", + "TradeSellerInboxRequest", "TradeStatusAmbiguityCandidate", "TradeStatusEligibility", "TradeStatusEvidenceSummary", @@ -159,6 +169,24 @@ const REQUIRED_TRADES_CLIENT_ADVANCED_SIGNER_METHODS: &[&str] = &[ "pub async fn enqueue_prepared_cancellation_with_explicit_signer(", ]; +const REQUIRED_TRADE_BUYER_CLIENT_METHODS: &[&str] = &[ + "pub async fn propose_trade(", + "pub async fn cancel_trade(", + "pub async fn accept_revision(", + "pub async fn decline_revision(", +]; + +const REQUIRED_TRADE_SELLER_CLIENT_METHODS: &[&str] = &[ + "pub async fn inbox(", + "pub async fn accept_trade(", + "pub async fn decline_trade(", + "pub async fn propose_revision(", +]; + +const REQUIRED_TRADE_STATUS_CLIENT_METHODS: &[&str] = &["pub async fn status("]; + +const REQUIRED_TRADE_RESYNC_CLIENT_METHODS: &[&str] = &["pub async fn resync("]; + const FORBIDDEN_ORDER_RUNTIME_PUBLIC_EXPORTS: &[&str] = &[ "CheckoutClient", "EscrowClient", @@ -445,6 +473,43 @@ fn orders_client_surface_is_inventory_guarded() { } #[test] +fn trade_product_facade_methods_are_inventory_guarded() { + let source = read_source( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("src/orders_runtime.rs") + .as_path(), + ); + + for method in REQUIRED_TRADE_BUYER_CLIENT_METHODS { + assert!( + source.contains(method), + "TradeBuyerClient must expose product workflow method `{method}`" + ); + } + + for method in REQUIRED_TRADE_SELLER_CLIENT_METHODS { + assert!( + source.contains(method), + "TradeSellerClient must expose product workflow method `{method}`" + ); + } + + for method in REQUIRED_TRADE_STATUS_CLIENT_METHODS { + assert!( + source.contains(method), + "TradeStatusClient must expose product workflow method `{method}`" + ); + } + + for method in REQUIRED_TRADE_RESYNC_CLIENT_METHODS { + assert!( + source.contains(method), + "TradeResyncClient must expose product workflow method `{method}`" + ); + } +} + +#[test] fn dvm_client_surface_is_inventory_guarded() { let source = read_source( Path::new(env!("CARGO_MANIFEST_DIR"))