sdk

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

commit 71dc0de57ddc96754f291a6111e9e3dcc9e31234
parent 8084b17eb3ca6113ca8e20121031684e6fbf3248
Author: triesap <tyson@radroots.org>
Date:   Thu, 16 Jul 2026 11:37:52 +0000

runtime: add SDK journal recovery

- add release-product operation journal, recovery receipts, reservations, and projection checkpoints
- move workflow enqueue through command-hash idempotency and signer-safe journal states
- remove retired order runtime modules and old public trade runtime tests
- align trade binding DTO roots and generated TypeScript with canonical trade projections

Diffstat:
MCargo.lock | 2++
Mcrates/sdk/examples/runtime_local.rs | 8+-------
Mcrates/sdk/examples/sdk_v1_local_enqueue_and_mock_sync.rs | 7+------
Mcrates/sdk/src/error.rs | 27---------------------------
Mcrates/sdk/src/lib.rs | 67++++++-------------------------------------------------------------
Dcrates/sdk/src/market_runtime.rs | 156-------------------------------------------------------------------------------
Dcrates/sdk/src/order.rs | 112-------------------------------------------------------------------------------
Dcrates/sdk/src/orders_runtime.rs | 5695-------------------------------------------------------------------------------
Mcrates/sdk/src/private_store.rs | 3+--
Mcrates/sdk/src/product_clients.rs | 49++++---------------------------------------------
Mcrates/sdk/src/runtime.rs | 496++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mcrates/sdk/src/sync_runtime.rs | 138++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Dcrates/sdk/src/trade_storage.rs | 152-------------------------------------------------------------------------------
Mcrates/sdk/src/transport.rs | 5-----
Mcrates/sdk/src/workflow_runtime.rs | 603++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/sdk/tests/farms_runtime.rs | 6++++--
Mcrates/sdk/tests/listings_runtime.rs | 46+++++++++++++++++++++++++++++++++++++---------
Dcrates/sdk/tests/market_runtime.rs | 232-------------------------------------------------------------------------------
Dcrates/sdk/tests/orders_runtime.rs | 4054-------------------------------------------------------------------------------
Mcrates/sdk/tests/runtime_foundation.rs | 19++++++++++++-------
Mcrates/sdk/tests/source_boundary.rs | 2832++-----------------------------------------------------------------------------
Dcrates/sdk/tests/trade_product_publish_runtime.rs | 297-------------------------------------------------------------------------------
Dcrates/sdk/tests/trade_public_api.rs | 64----------------------------------------------------------------
Mcrates/sdk/tests/unit/error_tests.rs | 11+++++++++--
Mcrates/sdk/tests/unit/farms_runtime_tests.rs | 5++++-
Mcrates/sdk/tests/unit/listings_runtime_tests.rs | 5++++-
Dcrates/sdk/tests/unit/market_runtime_tests.rs | 112-------------------------------------------------------------------------------
Dcrates/sdk/tests/unit/orders_runtime_tests.rs | 199-------------------------------------------------------------------------------
Mcrates/sdk/tests/unit/runtime_tests.rs | 187++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mcrates/sdk/tests/unit/sync_runtime_tests.rs | 4++--
Mcrates/sdk/tests/unit/workflow_runtime_tests.rs | 69++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mcrates/trade_bindings/src/dto.rs | 90+------------------------------------------------------------------------------
Mcrates/trade_bindings/src/lib.rs | 97++++++++++++++++++++++++++-----------------------------------------------------
Mpackages/trade-bindings/src/generated/types.ts | 36++++++++++++++++++++----------------
Mtools/xtask/src/dto_roots.rs | 68++++++++++++++++++++++++++------------------------------------------
35 files changed, 1359 insertions(+), 14594 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1834,11 +1834,13 @@ dependencies = [ name = "radroots_event_store" version = "0.1.0-alpha.2" dependencies = [ + "hex", "radroots_event", "radroots_nostr", "radroots_transport", "serde", "serde_json", + "sha2", "sqlx", "thiserror 1.0.69", ] diff --git a/crates/sdk/examples/runtime_local.rs b/crates/sdk/examples/runtime_local.rs @@ -11,7 +11,7 @@ use radroots_nostr::prelude::RadrootsNostrKeys; use radroots_sdk::{ ListingPreparePublishRequest, NostrRelayUrlPolicy, PushOutboxRequest, RadrootsClient, RadrootsSdkError, RadrootsSdkLocalKeySigner, RadrootsSdkSignerProvider, RadrootsSdkTimestamp, - SdkIdempotencyKey, TargetPolicy, TradeStatusRequest, + SdkIdempotencyKey, TargetPolicy, }; const RELAY: &str = "wss://relay.example.com"; @@ -47,11 +47,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { .sync() .push_outbox(PushOutboxRequest::new().with_limit(1)) .await; - let order_status = sdk - .trades() - .status(TradeStatusRequest::parse("example-order-1")?) - .await?; - assert_eq!( prepared.public_listing_addr.as_str(), enqueue.public_listing_addr.as_str() @@ -66,7 +61,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { push, Err(RadrootsSdkError::ProductSyncUnsupported { .. }) )); - assert!(!order_status.found); Ok(()) } diff --git a/crates/sdk/examples/sdk_v1_local_enqueue_and_mock_sync.rs b/crates/sdk/examples/sdk_v1_local_enqueue_and_mock_sync.rs @@ -11,7 +11,7 @@ use radroots_nostr::prelude::RadrootsNostrKeys; use radroots_sdk::{ ListingPreparePublishRequest, NostrRelayUrlPolicy, PushOutboxRequest, RadrootsClient, RadrootsSdkLocalKeySigner, RadrootsSdkSignerProvider, RadrootsSdkTimestamp, SdkIdempotencyKey, - TargetPolicy, TargetSet, TradeStatusRequest, + TargetPolicy, TargetSet, }; use radroots_transport_nostr::{RadrootsMockRelayPublishAdapter, RadrootsNostrTransport}; @@ -55,14 +55,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { .sync() .push_outbox_with_transport(&transport, PushOutboxRequest::new().with_limit(1)) .await?; - let order_status = sdk - .trades() - .status(TradeStatusRequest::parse("example-order-1")?) - .await?; println!("queued listing event: {}", enqueue.signed_event_id.as_str()); println!("published events: {}", push.published_events); - println!("order found: {}", order_status.found); Ok(()) } diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs @@ -510,17 +510,6 @@ impl RadrootsSdkError { } } - pub(crate) fn trade_status_limit_invalid(limit: u32, min: u32, max: u32) -> Self { - Self::TradeStatusLimitInvalid { limit, min, max } - } - - pub(crate) fn invalid_trade_id(value: impl Into<String>, message: impl Into<String>) -> Self { - Self::InvalidTradeId { - value: value.into(), - message: message.into(), - } - } - pub(crate) fn missing_geonames_config() -> Self { Self::GeoNames { kind: RadrootsSdkGeoNamesErrorKind::Configuration, @@ -786,22 +775,6 @@ impl From<radroots_trade::listing::RadrootsListingMutationError> for RadrootsSdk } #[cfg(feature = "runtime")] -impl From<radroots_trade::projection::RadrootsTradeProjectionError> for RadrootsSdkError { - fn from(error: radroots_trade::projection::RadrootsTradeProjectionError) -> Self { - match error { - radroots_trade::projection::RadrootsTradeProjectionError::InvalidLimit { max } => { - Self::InvalidRequest { - message: format!("projection query limit must be between 1 and {max}"), - } - } - error => Self::Projection { - message: error.to_string(), - }, - } - } -} - -#[cfg(feature = "runtime")] impl From<radroots_outbox::RadrootsOutboxError> for RadrootsSdkError { fn from(error: radroots_outbox::RadrootsOutboxError) -> Self { match error { diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -33,12 +33,6 @@ pub mod knowledge; #[cfg(feature = "runtime")] mod listings_runtime; #[cfg(feature = "runtime")] -mod market_runtime; -#[cfg(feature = "runtime")] -mod order; -#[cfg(feature = "runtime")] -mod orders_runtime; -#[cfg(feature = "runtime")] mod privacy; #[cfg(feature = "runtime")] mod private_store; @@ -56,8 +50,6 @@ mod studio_store; #[cfg(feature = "runtime")] mod sync_runtime; #[cfg(feature = "runtime")] -mod trade_storage; -#[cfg(feature = "runtime")] pub mod transport; #[cfg(feature = "runtime")] mod workflow_runtime; @@ -136,63 +128,21 @@ pub use crate::listings_runtime::{ ListingPreparePublishRequest, ListingPublishPlan, SdkMutationState, }; #[cfg(feature = "runtime")] -pub use crate::market_runtime::{ - MARKET_SEARCH_DEFAULT_LIMIT, MarketListingSearchRow, MarketSearchReceipt, MarketSearchRequest, - MarketSearchSource, -}; -#[cfg(feature = "runtime")] -pub use crate::orders_runtime::{ - SdkTradeStatusIssue, SdkTradeStatusIssueKind, SdkTradeStatusSource, TRADE_STATUS_DEFAULT_LIMIT, - TRADE_STATUS_MAX_LIMIT, TRADE_STATUS_ROOT_SELECTOR_SEPARATOR, - TRADE_STATUS_WATCH_DEFAULT_CAPACITY, TRADE_STATUS_WATCH_DEFAULT_REFRESH_INTERVAL_MS, - TRADE_STATUS_WATCH_MAX_CAPACITY, TRADE_STATUS_WATCH_MAX_REFRESH_INTERVAL_MS, - TradeEvidenceBranchReceipt, TradeEvidenceIngestReceipt, TradeEvidenceIngestRequest, - TradeEvidenceNostrRelayFilter, TradeEvidenceNostrRelayTagFilter, TradeEvidenceQueryBranch, - TradeEvidenceQueryBranchKind, TradeEvidenceQueryPlan, TradeRequestEvidenceIngestReceipt, - TradeRequestEvidenceIngestRequest, TradeResyncEventImportReceipt, TradeResyncEvidenceReceipt, - TradeResyncNostrRelayOutcomeKind, TradeResyncNostrRelayOutcomeReceipt, - TradeResyncNostrRelayTransportOutcomeKind, TradeResyncReceipt, TradeResyncRequest, - TradeSellerInboxReceipt, TradeSellerInboxRequest, TradeStatusAmbiguityCandidate, - TradeStatusEligibility, TradeStatusEvidenceSummary, TradeStatusKind, TradeStatusNextActionKind, - TradeStatusReceipt, TradeStatusRequest, TradeStatusWatch, TradeStatusWatchCancelReceipt, - TradeStatusWatchCancelState, TradeStatusWatchRequest, TradeStatusWatchUpdate, - TradeValidationReceiptEvent, TradeValidationReceiptInspectReceipt, - TradeValidationReceiptInspectRequest, TradeValidationReceiptInvalidCandidate, - TradeValidationReceiptListReceipt, TradeValidationReceiptListRequest, - TradeValidationReceiptNostrEvidenceReceipt, TradeValidationReceiptNostrRelayOutcomeKind, - TradeValidationReceiptNostrRelayOutcomeReceipt, - TradeValidationReceiptNostrRelayTransportOutcomeKind, TradeValidationReceiptTags, - TradeValidationReceiptVerifyRequest, TradeValidationTrustDecision, -}; -#[cfg(all(feature = "runtime", feature = "signer-adapters"))] -pub use crate::orders_runtime::{ - TRADE_CANCELLATION_OPERATION_KIND, TRADE_DECISION_OPERATION_KIND, TRADE_SUBMIT_OPERATION_KIND, - TradeAcceptRequest, TradeCancelRequest, TradeCancellationPlan, TradeCancellationReceipt, - TradeDecisionPlan, TradeDecisionReceipt, TradeDeclineRequest, TradeEvidenceMode, - TradeMutationOutcome, TradeProposeRequest, TradeSubmitPlan, TradeSubmitReceipt, - TradeWorkflowEnqueueReceipt, TradeWorkflowIdempotencyReceipt, TradeWorkflowKind, - TradeWorkflowPlan, TradeWorkflowRetryAdvice, -}; -#[cfg(feature = "runtime")] pub use crate::privacy::{ PrivacyPreflightConfirmation, PrivacyPreflightReceipt, PrivacyPreflightStatus, ProductSensitivityField, }; -#[cfg(all(feature = "runtime", feature = "signer-adapters"))] -pub use crate::product_clients::TradeBuyerClient; #[cfg(feature = "runtime")] pub use crate::product_clients::{ - FarmsClient, GeoNamesClient, ListingsClient, MarketClient, SyncClient, TradeResyncClient, - TradeSellerClient, TradeValidationReceiptsClient, TradesClient, + FarmsClient, GeoNamesClient, ListingsClient, MarketClient, SyncClient, TradesClient, }; #[cfg(feature = "runtime")] pub use crate::runtime::{ - BackupReceipt, BackupRequest, IntegrityReceipt, IntegrityRequest, QuarantineResetReceipt, - QuarantineResetRequest, RadrootsClient, RadrootsClientBuilder, RadrootsSdkClock, - RadrootsSdkStorageConfig, RadrootsSdkStoragePaths, RadrootsSdkTimestamp, RestoreArchive, - RestoreReceipt, RestoreRequest, SdkBackupManifest, SdkBackupManifestKind, SdkBackupState, - SdkBackupVerification, SdkEventStoreStorageStatus, SdkOutboxStorageStatus, - SdkPrivateStoreStorageStatus, SdkRestoreState, SdkSqliteStoreStatus, + BackupReceipt, BackupRequest, IntegrityReceipt, IntegrityRequest, RadrootsClient, + RadrootsClientBuilder, RadrootsSdkClock, RadrootsSdkStorageConfig, RadrootsSdkStoragePaths, + RadrootsSdkTimestamp, RestoreArchive, RestoreReceipt, RestoreRequest, SdkBackupManifest, + SdkBackupManifestKind, SdkBackupState, SdkBackupVerification, SdkEventStoreStorageStatus, + SdkOutboxStorageStatus, SdkPrivateStoreStorageStatus, SdkRestoreState, SdkSqliteStoreStatus, SdkSqliteWalCheckpointReceipt, SdkSqliteWalStatus, SdkStorageKind, SdkStudioStoreStorageStatus, StorageCheckpointReceipt, StorageCheckpointRequest, StorageStatusReceipt, StorageStatusRequest, }; @@ -220,11 +170,6 @@ pub use crate::sync_runtime::{ SyncTransportStatusSummary, SyncTransportTargetSummary, }; #[cfg(feature = "runtime")] -pub use crate::trade_storage::{ - SDK_TRADE_PROJECTION_CACHE_VERSION, SdkTradeProjectionCache, SdkTradeProjectionCacheKey, - SdkTradeProjectionCacheRecord, -}; -#[cfg(feature = "runtime")] pub use crate::transport::{ MeshScopeId, MultiTargetProfile, NostrProfile, NostrRelayUrlPolicy, PublishMode, RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryTargetStatus, RadrootsTransportKind, diff --git a/crates/sdk/src/market_runtime.rs b/crates/sdk/src/market_runtime.rs @@ -1,156 +0,0 @@ -#[cfg(feature = "runtime")] -use crate::sync_runtime::refresh_product_projections_for_sdk; -#[cfg(feature = "runtime")] -use crate::{ - MarketClient, RadrootsSdkError, SyncProjectionRefreshReceipt, SyncProjectionRefreshRequest, -}; -#[cfg(feature = "runtime")] -use radroots_event::ids::{RadrootsEventId, RadrootsListingAddress, RadrootsPublicKey}; -#[cfg(feature = "runtime")] -use radroots_trade::projection::{ - RadrootsListingProjectionRow, RadrootsListingSearchRequest, search_listing_projection, -}; - -#[cfg(feature = "runtime")] -pub const MARKET_SEARCH_DEFAULT_LIMIT: u32 = 50; - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -#[non_exhaustive] -pub struct MarketSearchRequest { - pub query: String, - pub limit: u32, - pub projection_refresh: SyncProjectionRefreshRequest, -} - -#[cfg(feature = "runtime")] -impl MarketSearchRequest { - pub fn new(query: impl Into<String>) -> Self { - Self { - query: query.into(), - limit: MARKET_SEARCH_DEFAULT_LIMIT, - projection_refresh: SyncProjectionRefreshRequest::new(), - } - } - - pub fn with_limit(mut self, limit: u32) -> Self { - self.limit = limit; - self - } - - pub fn with_projection_refresh(mut self, refresh: SyncProjectionRefreshRequest) -> Self { - self.projection_refresh = refresh; - self - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct MarketSearchReceipt { - pub source: MarketSearchSource, - pub refresh: SyncProjectionRefreshReceipt, - pub listings: Vec<MarketListingSearchRow>, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum MarketSearchSource { - LocalProjectionFts, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct MarketListingSearchRow { - pub listing_addr: RadrootsListingAddress, - pub listing_event_id: RadrootsEventId, - pub seller_pubkey: RadrootsPublicKey, - pub title: String, - pub description: String, - pub product_type: String, - pub price_amount: String, - pub price_currency: String, - pub inventory_available: String, - pub delivery_method: String, - pub locality_primary: String, - pub locality_city: Option<String>, - pub locality_region: Option<String>, - pub locality_country: Option<String>, - pub geohash5: String, - pub updated_at_ms: i64, -} - -#[cfg(feature = "runtime")] -impl<'sdk> MarketClient<'sdk> { - pub async fn search( - &self, - request: MarketSearchRequest, - ) -> Result<MarketSearchReceipt, RadrootsSdkError> { - let refresh = - refresh_product_projections_for_sdk(self.sdk, request.projection_refresh).await?; - let rows = search_listing_projection( - &self.sdk._event_store, - &RadrootsListingSearchRequest::new(request.query).with_limit(request.limit), - ) - .await?; - let listings = rows - .into_iter() - .map(MarketListingSearchRow::try_from_projection_row) - .collect::<Result<Vec<_>, _>>()?; - Ok(MarketSearchReceipt { - source: MarketSearchSource::LocalProjectionFts, - refresh, - listings, - }) - } -} - -#[cfg(feature = "runtime")] -impl MarketListingSearchRow { - fn try_from_projection_row( - row: RadrootsListingProjectionRow, - ) -> Result<Self, RadrootsSdkError> { - let listing_addr = row.listing_addr; - let listing_event_id = - RadrootsEventId::parse(row.listing_event_id.as_str()).map_err(|source| { - RadrootsSdkError::Projection { - message: format!( - "stored listing projection event id `{}` is invalid: {source}", - row.listing_event_id - ), - } - })?; - let seller_pubkey = - RadrootsPublicKey::parse(row.seller_pubkey.as_str()).map_err(|source| { - RadrootsSdkError::Projection { - message: format!( - "stored listing projection seller pubkey `{}` is invalid: {source}", - row.seller_pubkey - ), - } - })?; - Ok(Self { - listing_addr, - listing_event_id, - seller_pubkey, - title: row.title, - description: row.description, - product_type: row.product_type, - price_amount: row.price_amount, - price_currency: row.price_currency, - inventory_available: row.inventory_available, - delivery_method: row.delivery_method, - locality_primary: row.locality_primary, - locality_city: row.locality_city, - locality_region: row.locality_region, - locality_country: row.locality_country, - geohash5: row.geohash5, - updated_at_ms: row.updated_at_ms, - }) - } -} - -#[cfg(test)] -#[path = "../tests/unit/market_runtime_tests.rs"] -mod tests; diff --git a/crates/sdk/src/order.rs b/crates/sdk/src/order.rs @@ -1,112 +0,0 @@ -pub use radroots_event::order::*; -#[cfg(feature = "signer-adapters")] -pub use radroots_event_codec::error::EventEncodeError; -pub use radroots_event_codec::order::RadrootsOrderEnvelopeParseError; - -use radroots_event::RadrootsEventEnvelope; -#[cfg(feature = "signer-adapters")] -use radroots_event::wire::RadrootsNip01EventWireParts; -#[cfg(feature = "signer-adapters")] -use radroots_event::{RadrootsEventPtr, ids::RadrootsEventId, tags::TAG_E}; - -#[cfg(feature = "signer-adapters")] -#[derive(Debug, Clone)] -pub struct RadrootsOrderRequestDraft { - parts: RadrootsNip01EventWireParts, -} - -#[cfg(feature = "signer-adapters")] -#[derive(Debug, Clone)] -pub struct RadrootsOrderDecisionDraft { - parts: RadrootsNip01EventWireParts, -} - -#[cfg(feature = "signer-adapters")] -#[derive(Debug, Clone)] -pub struct RadrootsOrderCancellationDraft { - parts: RadrootsNip01EventWireParts, -} - -#[cfg(feature = "signer-adapters")] -impl RadrootsOrderRequestDraft { - pub fn into_wire_parts(self) -> RadrootsNip01EventWireParts { - self.parts - } -} - -#[cfg(feature = "signer-adapters")] -impl RadrootsOrderDecisionDraft { - pub fn into_wire_parts(self) -> RadrootsNip01EventWireParts { - self.parts - } -} - -#[cfg(feature = "signer-adapters")] -impl RadrootsOrderCancellationDraft { - pub fn into_wire_parts(self) -> RadrootsNip01EventWireParts { - self.parts - } -} - -#[cfg(feature = "signer-adapters")] -fn with_contract_root_event_tag( - mut parts: RadrootsNip01EventWireParts, - root_event_id: &RadrootsEventId, -) -> RadrootsNip01EventWireParts { - parts - .tags - .push(vec![TAG_E.to_owned(), root_event_id.as_str().to_owned()]); - parts -} - -#[cfg(feature = "signer-adapters")] -pub fn build_order_request_draft( - listing_event: &RadrootsEventPtr, - payload: &RadrootsOrderRequest, -) -> Result<RadrootsOrderRequestDraft, EventEncodeError> { - Ok(RadrootsOrderRequestDraft { - parts: radroots_event_codec::order::order_request_event_build(listing_event, payload)?, - }) -} - -#[cfg(feature = "signer-adapters")] -pub fn build_order_decision_draft( - root_event_id: &RadrootsEventId, - prev_event_id: &RadrootsEventId, - payload: &RadrootsOrderDecision, -) -> Result<RadrootsOrderDecisionDraft, EventEncodeError> { - Ok(RadrootsOrderDecisionDraft { - parts: with_contract_root_event_tag( - radroots_event_codec::order::order_decision_event_build( - root_event_id, - prev_event_id, - payload, - )?, - root_event_id, - ), - }) -} - -#[cfg(feature = "signer-adapters")] -pub fn build_order_cancellation_draft( - root_event_id: &RadrootsEventId, - prev_event_id: &RadrootsEventId, - payload: &RadrootsOrderCancellation, -) -> Result<RadrootsOrderCancellationDraft, EventEncodeError> { - Ok(RadrootsOrderCancellationDraft { - parts: with_contract_root_event_tag( - radroots_event_codec::order::order_cancellation_event_build( - root_event_id, - prev_event_id, - payload, - )?, - root_event_id, - ), - }) -} - -pub fn parse_order_request( - event: &RadrootsEventEnvelope, -) -> Result<RadrootsOrderEnvelope<RadrootsOrderRequest>, RadrootsOrderEnvelopeParseError> { - radroots_event_codec::order::order_request_from_event(event) -} diff --git a/crates/sdk/src/orders_runtime.rs b/crates/sdk/src/orders_runtime.rs @@ -1,5695 +0,0 @@ -#[cfg(feature = "signer-adapters")] -use crate::TradeBuyerClient; -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -use crate::runtime::sdk_now_ms; -#[cfg(feature = "runtime")] -use crate::sync_runtime::SyncProjectionRefreshReceipt; -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -use crate::sync_runtime::{SyncProjectionRefreshRequest, refresh_product_projections_for_sdk}; -#[cfg(feature = "signer-adapters")] -use crate::workflow_runtime::enqueue_configured_signed_workflow; -#[cfg(feature = "signer-adapters")] -use crate::{ - PrivacyPreflightConfirmation, PrivacyPreflightReceipt, ProductSensitivityField, PublishMode, - PushOutboxReceipt, PushOutboxRequest, RadrootsSdkRecoveryAction, SatisfactionPolicy, - SdkIdempotencyKey, SdkMutationState, TargetPolicy, workflow_runtime::SdkWorkflowEnqueueRequest, -}; -#[cfg(feature = "runtime")] -use crate::{ - RadrootsSdkError, RadrootsSdkTimestamp, TradeResyncClient, TradeSellerClient, - TradeValidationReceiptsClient, TradesClient, order, -}; -#[cfg(feature = "runtime")] -use radroots_authority::RadrootsActorContext; -#[cfg(feature = "signer-adapters")] -use radroots_event::wire::RadrootsNip01EventWireParts; -#[cfg(feature = "runtime")] -use radroots_event::{ - RadrootsEventEnvelope, RadrootsEventEnvelopeParts, - contract::RadrootsActorRole, - draft::RadrootsSignedEvent, - ids::RadrootsEventId, - kinds::{ - KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST, - KIND_TRADE_VALIDATION_RECEIPT, - }, - tags::TAG_P, -}; -#[cfg(feature = "signer-adapters")] -use radroots_event::{ - RadrootsEventPtr, - draft::RadrootsEventDraft, - order::{ - RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome, - RadrootsOrderInventoryCommitment, RadrootsOrderItem, RadrootsOrderRequest, - }, -}; -#[cfg(any(feature = "runtime", feature = "signer-adapters", test))] -use radroots_event::{ - ids::{RadrootsListingAddress, RadrootsOrderId, RadrootsPublicKey}, - order::RadrootsOrderEconomics, -}; -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -use radroots_event::{ - kinds::KIND_LISTING, - tags::{TAG_D, TAG_E}, -}; -#[cfg(feature = "runtime")] -use radroots_event_codec::order::{ - order_cancellation_from_event, order_decision_from_event, order_request_from_event, -}; -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -use radroots_event_store::RadrootsStoredEventTag; -#[cfg(feature = "runtime")] -use radroots_event_store::{RadrootsEventIngest, RadrootsStoredEvent}; -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -use radroots_nostr::prelude::{ - RadrootsNostrEventId, RadrootsNostrFilter, RadrootsNostrKind, RadrootsNostrPublicKey, - radroots_nostr_filter_tag, -}; -#[cfg(feature = "runtime")] -use radroots_trade::identity::{RadrootsTradeLocator, RadrootsTradeLocatorCandidate}; -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -use radroots_trade::listing::parse_listing_address; -#[cfg(feature = "signer-adapters")] -use radroots_trade::order::{ - RadrootsOrderCanonicalizationError, RadrootsOrderProjectionQueryResult, - canonicalize_order_decision_for_signer, canonicalize_order_request_for_signer, - order_projection_query_for_order_id, -}; -#[cfg(feature = "runtime")] -use radroots_trade::order::{ - RadrootsOrderEventRecord, RadrootsOrderIssue, RadrootsOrderProjection, - RadrootsOrderStoreQueryError, RadrootsTradeLocatorProjectionQueryResult, - RadrootsTradeLocatorProjectionResolution, order_event_record_from_event, - order_projection_query_for_trade_locator, -}; -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -use radroots_trade::validation_receipt::RadrootsValidationReceiptError; -#[cfg(feature = "runtime")] -use radroots_trade::validation_receipt::{ - RadrootsTradeCommitmentConfidence, RadrootsTradeValidationAuthority, - RadrootsTradeValidationReceipt, RadrootsTradeValidationTrustPolicy, - RadrootsTradeValidationTrustState, RadrootsValidationReceiptExpectedBinding, - RadrootsValidationReceiptProofSystem, RadrootsValidationReceiptResult, - RadrootsValidationReceiptTags, verify_validation_receipt_event, -}; -#[cfg(feature = "runtime")] -use radroots_trade::workflow::RadrootsTradeWorkflowState; -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -use radroots_transport_nostr::{ - RadrootsNostrClientFetchAdapter, RadrootsRelayFetchAdapter, RadrootsRelayFetchEventReceipt, - RadrootsRelayFetchOutcomeKind, RadrootsRelayFetchReceipt, RadrootsRelayFetchRelayOutcome, - RadrootsRelayFetchRequest, RadrootsRelayOutcomeKind, fetch_and_ingest_relay_events, -}; -#[cfg(feature = "runtime")] -use serde::ser::SerializeStruct; -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -use std::collections::{BTreeMap, BTreeSet}; -#[cfg(feature = "runtime")] -use std::time::Duration; -#[cfg(feature = "runtime")] -use tokio::{ - sync::{mpsc, oneshot}, - task::JoinHandle, -}; -#[cfg(feature = "runtime")] -pub const TRADE_STATUS_DEFAULT_LIMIT: u32 = 500; -#[cfg(feature = "runtime")] -pub const TRADE_STATUS_MAX_LIMIT: u32 = 1_000; -#[cfg(feature = "runtime")] -pub const TRADE_STATUS_ROOT_SELECTOR_SEPARATOR: char = '@'; -#[cfg(feature = "runtime")] -pub const TRADE_STATUS_WATCH_DEFAULT_CAPACITY: usize = 8; -#[cfg(feature = "runtime")] -pub const TRADE_STATUS_WATCH_MAX_CAPACITY: usize = 128; -#[cfg(feature = "runtime")] -pub const TRADE_STATUS_WATCH_DEFAULT_REFRESH_INTERVAL_MS: u64 = 1_000; -#[cfg(feature = "runtime")] -pub const TRADE_STATUS_WATCH_MAX_REFRESH_INTERVAL_MS: u64 = 60_000; -#[cfg(feature = "signer-adapters")] -pub const TRADE_SUBMIT_OPERATION_KIND: &str = "trade.submit.v1"; -#[cfg(feature = "signer-adapters")] -pub const TRADE_DECISION_OPERATION_KIND: &str = "trade.decision.v1"; -#[cfg(feature = "signer-adapters")] -pub const TRADE_CANCELLATION_OPERATION_KIND: &str = "trade.cancellation.v1"; - -#[cfg(feature = "signer-adapters")] -const TRADE_SUBMIT_CONTRACT_ID: &str = "radroots.order.request.v1"; -#[cfg(feature = "signer-adapters")] -const TRADE_DECISION_CONTRACT_ID: &str = "radroots.order.decision.v1"; -#[cfg(feature = "signer-adapters")] -const TRADE_CANCELLATION_CONTRACT_ID: &str = "radroots.order.cancellation.v1"; - -#[cfg(feature = "signer-adapters")] -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum TradeWorkflowKind { - Submit, - Decision, - Cancellation, -} - -#[cfg(feature = "signer-adapters")] -impl TradeWorkflowKind { - pub fn operation_kind(self) -> &'static str { - match self { - Self::Submit => TRADE_SUBMIT_OPERATION_KIND, - Self::Decision => TRADE_DECISION_OPERATION_KIND, - Self::Cancellation => TRADE_CANCELLATION_OPERATION_KIND, - } - } - - pub fn contract_id(self) -> &'static str { - match self { - Self::Submit => TRADE_SUBMIT_CONTRACT_ID, - Self::Decision => TRADE_DECISION_CONTRACT_ID, - Self::Cancellation => TRADE_CANCELLATION_CONTRACT_ID, - } - } -} - -#[cfg(feature = "signer-adapters")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeWorkflowPlan { - pub kind: TradeWorkflowKind, - pub operation_kind: &'static str, - pub contract_id: &'static str, - pub expected_event_id: RadrootsEventId, - pub created_at: RadrootsSdkTimestamp, -} - -#[cfg(feature = "signer-adapters")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeWorkflowEnqueueReceipt { - pub kind: TradeWorkflowKind, - pub operation_kind: &'static str, - pub expected_event_id: RadrootsEventId, - pub signed_event_id: RadrootsEventId, - pub local_event_seq: i64, - pub outbox_operation_id: i64, - pub outbox_event_id: i64, - pub state: SdkMutationState, - pub idempotency_digest_prefix: Option<String>, - pub idempotency: TradeWorkflowIdempotencyReceipt, - pub retry: TradeWorkflowRetryAdvice, -} - -#[cfg(feature = "signer-adapters")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeWorkflowIdempotencyReceipt { - pub digest_prefix: Option<String>, - pub replayed_existing_operation: bool, - pub safe_to_retry_with_same_idempotency_key: bool, -} - -#[cfg(feature = "signer-adapters")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeWorkflowRetryAdvice { - pub retryable_after_error: bool, - pub safe_to_retry_enqueue_with_same_idempotency_key: bool, - pub recovery_actions: Vec<RadrootsSdkRecoveryAction>, -} - -#[cfg(feature = "signer-adapters")] -#[derive(Clone, Debug, serde::Serialize)] -#[non_exhaustive] -pub struct TradeSubmitPrepareRequest { - #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] - pub actor: RadrootsActorContext, - pub listing_event: RadrootsEventPtr, - pub order: RadrootsOrderRequest, - pub created_at: Option<RadrootsSdkTimestamp>, -} - -#[cfg(feature = "signer-adapters")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -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: RadrootsEventDraft, - pub created_at: RadrootsSdkTimestamp, -} - -#[cfg(feature = "signer-adapters")] -#[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, - pub local_event_seq: i64, - pub outbox_operation_id: i64, - pub outbox_event_id: i64, - pub state: SdkMutationState, - pub idempotency_digest_prefix: Option<String>, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, serde::Serialize)] -#[non_exhaustive] -pub struct TradeRequestEvidenceIngestRequest { - pub event: RadrootsSignedEvent, - pub observed_at: Option<RadrootsSdkTimestamp>, -} - -#[cfg(feature = "runtime")] -impl TradeRequestEvidenceIngestRequest { - pub fn new(event: RadrootsSignedEvent) -> Self { - Self { - event, - observed_at: None, - } - } - - pub fn with_observed_at(mut self, observed_at: RadrootsSdkTimestamp) -> Self { - self.observed_at = Some(observed_at); - self - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeRequestEvidenceIngestReceipt { - pub order_id: RadrootsOrderId, - pub listing_addr: RadrootsListingAddress, - pub buyer_pubkey: RadrootsPublicKey, - pub seller_pubkey: RadrootsPublicKey, - pub request_event_id: RadrootsEventId, - pub local_event_seq: i64, - pub inserted: bool, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, serde::Serialize)] -#[non_exhaustive] -pub struct TradeEvidenceIngestRequest { - pub event: RadrootsSignedEvent, - pub observed_at: Option<RadrootsSdkTimestamp>, -} - -#[cfg(feature = "runtime")] -impl TradeEvidenceIngestRequest { - pub fn new(event: RadrootsSignedEvent) -> Self { - Self { - event, - observed_at: None, - } - } - - pub fn with_observed_at(mut self, observed_at: RadrootsSdkTimestamp) -> Self { - self.observed_at = Some(observed_at); - self - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeEvidenceIngestReceipt { - pub order_id: RadrootsOrderId, - pub listing_addr: RadrootsListingAddress, - pub event_id: RadrootsEventId, - pub event_kind: u32, - pub local_event_seq: i64, - pub inserted: bool, -} - -#[cfg(feature = "signer-adapters")] -#[derive(Clone, Debug, serde::Serialize)] -#[non_exhaustive] -pub struct TradeDecisionPrepareRequest { - #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] - pub actor: RadrootsActorContext, - pub request_event: RadrootsEventPtr, - pub decision: RadrootsOrderDecision, - pub created_at: Option<RadrootsSdkTimestamp>, -} - -#[cfg(feature = "signer-adapters")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeDecisionPlan { - pub workflow: TradeWorkflowPlan, - pub order_id: RadrootsOrderId, - pub listing_addr: RadrootsListingAddress, - pub buyer_pubkey: RadrootsPublicKey, - pub seller_pubkey: RadrootsPublicKey, - pub request_event_id: RadrootsEventId, - pub expected_event_id: RadrootsEventId, - pub frozen_draft: RadrootsEventDraft, - pub created_at: RadrootsSdkTimestamp, -} - -#[cfg(feature = "signer-adapters")] -#[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, - pub seller_pubkey: RadrootsPublicKey, - pub request_event_id: RadrootsEventId, - pub expected_event_id: RadrootsEventId, - pub signed_event_id: RadrootsEventId, - pub local_event_seq: i64, - pub outbox_operation_id: i64, - pub outbox_event_id: i64, - pub state: SdkMutationState, - pub idempotency_digest_prefix: Option<String>, -} - -#[cfg(feature = "signer-adapters")] -#[derive(Clone, Debug, serde::Serialize)] -#[non_exhaustive] -pub struct TradeCancellationPrepareRequest { - #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] - pub actor: RadrootsActorContext, - pub root_event: RadrootsEventPtr, - pub previous_event: RadrootsEventPtr, - pub cancellation: RadrootsOrderCancellation, - pub created_at: Option<RadrootsSdkTimestamp>, -} - -#[cfg(feature = "signer-adapters")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeCancellationPlan { - pub workflow: TradeWorkflowPlan, - pub order_id: RadrootsOrderId, - pub listing_addr: RadrootsListingAddress, - pub buyer_pubkey: RadrootsPublicKey, - pub seller_pubkey: RadrootsPublicKey, - pub root_event_id: RadrootsEventId, - pub previous_event_id: RadrootsEventId, - pub expected_event_id: RadrootsEventId, - pub frozen_draft: RadrootsEventDraft, - pub created_at: RadrootsSdkTimestamp, -} - -#[cfg(feature = "signer-adapters")] -#[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, - pub seller_pubkey: RadrootsPublicKey, - pub root_event_id: RadrootsEventId, - pub previous_event_id: RadrootsEventId, - pub expected_event_id: RadrootsEventId, - pub signed_event_id: RadrootsEventId, - pub local_event_seq: i64, - pub outbox_operation_id: i64, - pub outbox_event_id: i64, - pub state: SdkMutationState, - pub idempotency_digest_prefix: Option<String>, -} - -#[cfg(feature = "signer-adapters")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case", tag = "mode")] -pub enum TradeMutationOutcome<Plan, Receipt> { - DryRun { - plan: Plan, - }, - Enqueued { - receipt: Receipt, - }, - Published { - receipt: Receipt, - publish: PushOutboxReceipt, - }, -} - -#[cfg(feature = "signer-adapters")] -#[derive(Clone, Debug, serde::Serialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum TradeEvidenceMode { - LocalOnly, - ResyncBeforeMutation, - RequireExplicitEvidence { - evidence: Vec<TradeEvidenceIngestRequest>, - }, -} - -#[cfg(feature = "signer-adapters")] -impl TradeEvidenceMode { - pub fn require_explicit_evidence( - evidence: impl IntoIterator<Item = TradeEvidenceIngestRequest>, - ) -> Self { - Self::RequireExplicitEvidence { - evidence: evidence.into_iter().collect(), - } - } -} - -#[cfg(feature = "signer-adapters")] -#[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: RadrootsEventPtr, - pub order_id: RadrootsOrderId, - pub listing_addr: RadrootsListingAddress, - pub seller_pubkey: RadrootsPublicKey, - pub items: Vec<RadrootsOrderItem>, - pub economics: RadrootsOrderEconomics, - pub public_note: Option<String>, - pub target_policy: TargetPolicy, - pub publish_mode: PublishMode, - pub satisfaction_policy: SatisfactionPolicy, - pub privacy_confirmation: PrivacyPreflightConfirmation, - pub idempotency_key: Option<SdkIdempotencyKey>, - pub created_at: Option<RadrootsSdkTimestamp>, -} - -#[cfg(feature = "signer-adapters")] -impl TradeProposeRequest { - pub fn new( - actor: RadrootsActorContext, - listing_event: RadrootsEventPtr, - order: RadrootsOrderRequest, - target_policy: TargetPolicy, - publish_mode: PublishMode, - satisfaction_policy: SatisfactionPolicy, - ) -> Self { - Self { - actor, - listing_event, - order_id: order.order_id, - listing_addr: order.listing_addr, - seller_pubkey: order.seller_pubkey, - items: order.items, - economics: order.economics, - public_note: None, - target_policy, - publish_mode, - satisfaction_policy, - privacy_confirmation: PrivacyPreflightConfirmation::new(), - idempotency_key: None, - created_at: None, - } - } - - pub fn with_public_note(mut self, public_note: impl Into<String>) -> Self { - self.public_note = Some(public_note.into()); - self - } - - pub fn with_optional_public_note(mut self, public_note: Option<String>) -> Self { - self.public_note = public_note; - self - } - - pub fn with_privacy_confirmation( - mut self, - privacy_confirmation: PrivacyPreflightConfirmation, - ) -> Self { - self.privacy_confirmation = privacy_confirmation; - self - } - - 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 = "signer-adapters")] -#[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_policy: TargetPolicy, - pub publish_mode: PublishMode, - pub satisfaction_policy: SatisfactionPolicy, - pub evidence_mode: TradeEvidenceMode, - pub privacy_confirmation: PrivacyPreflightConfirmation, - pub idempotency_key: Option<SdkIdempotencyKey>, - pub created_at: Option<RadrootsSdkTimestamp>, -} - -#[cfg(feature = "signer-adapters")] -impl TradeAcceptRequest { - pub fn new( - actor: RadrootsActorContext, - locator: RadrootsTradeLocator, - inventory_commitments: Vec<RadrootsOrderInventoryCommitment>, - target_policy: TargetPolicy, - publish_mode: PublishMode, - satisfaction_policy: SatisfactionPolicy, - evidence_mode: TradeEvidenceMode, - ) -> Self { - Self { - actor, - locator, - inventory_commitments, - target_policy, - publish_mode, - satisfaction_policy, - evidence_mode, - privacy_confirmation: PrivacyPreflightConfirmation::new(), - idempotency_key: None, - created_at: None, - } - } - - pub fn with_privacy_confirmation( - mut self, - privacy_confirmation: PrivacyPreflightConfirmation, - ) -> Self { - self.privacy_confirmation = privacy_confirmation; - self - } - - 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 = "signer-adapters")] -#[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_policy: TargetPolicy, - pub publish_mode: PublishMode, - pub satisfaction_policy: SatisfactionPolicy, - pub evidence_mode: TradeEvidenceMode, - pub privacy_confirmation: PrivacyPreflightConfirmation, - pub idempotency_key: Option<SdkIdempotencyKey>, - pub created_at: Option<RadrootsSdkTimestamp>, -} - -#[cfg(feature = "signer-adapters")] -impl TradeDeclineRequest { - pub fn new( - actor: RadrootsActorContext, - locator: RadrootsTradeLocator, - reason: impl Into<String>, - target_policy: TargetPolicy, - publish_mode: PublishMode, - satisfaction_policy: SatisfactionPolicy, - evidence_mode: TradeEvidenceMode, - ) -> Self { - Self { - actor, - locator, - reason: reason.into(), - target_policy, - publish_mode, - satisfaction_policy, - evidence_mode, - privacy_confirmation: PrivacyPreflightConfirmation::new(), - idempotency_key: None, - created_at: None, - } - } - - pub fn with_privacy_confirmation( - mut self, - privacy_confirmation: PrivacyPreflightConfirmation, - ) -> Self { - self.privacy_confirmation = privacy_confirmation; - self - } - - 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 = "signer-adapters")] -#[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_policy: TargetPolicy, - pub publish_mode: PublishMode, - pub satisfaction_policy: SatisfactionPolicy, - pub evidence_mode: TradeEvidenceMode, - pub privacy_confirmation: PrivacyPreflightConfirmation, - pub idempotency_key: Option<SdkIdempotencyKey>, - pub created_at: Option<RadrootsSdkTimestamp>, -} - -#[cfg(feature = "signer-adapters")] -impl TradeCancelRequest { - pub fn new( - actor: RadrootsActorContext, - locator: RadrootsTradeLocator, - reason: impl Into<String>, - target_policy: TargetPolicy, - publish_mode: PublishMode, - satisfaction_policy: SatisfactionPolicy, - evidence_mode: TradeEvidenceMode, - ) -> Self { - Self { - actor, - locator, - reason: reason.into(), - target_policy, - publish_mode, - satisfaction_policy, - evidence_mode, - privacy_confirmation: PrivacyPreflightConfirmation::new(), - idempotency_key: None, - created_at: None, - } - } - - pub fn with_privacy_confirmation( - mut self, - privacy_confirmation: PrivacyPreflightConfirmation, - ) -> Self { - self.privacy_confirmation = privacy_confirmation; - self - } - - 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, - pub validation_trust_policy: RadrootsTradeValidationTrustPolicy, -} - -#[cfg(feature = "runtime")] -impl TradeResyncRequest { - pub fn new(locator: RadrootsTradeLocator) -> Self { - Self { - locator, - limit: TRADE_STATUS_DEFAULT_LIMIT, - validation_trust_policy: RadrootsTradeValidationTrustPolicy::production(), - } - } - - pub fn with_limit(mut self, limit: u32) -> Self { - self.limit = limit; - self - } - - pub fn with_validation_trust_policy( - mut self, - policy: RadrootsTradeValidationTrustPolicy, - ) -> Self { - self.validation_trust_policy = policy; - self - } - - #[cfg(feature = "transport-nostr-runtime")] - fn validate(&self) -> Result<(), RadrootsSdkError> { - if self.limit == 0 || self.limit > TRADE_STATUS_MAX_LIMIT { - return Err(RadrootsSdkError::trade_status_limit_invalid( - self.limit, - 1, - TRADE_STATUS_MAX_LIMIT, - )); - } - Ok(()) - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeResyncReceipt { - pub nostr_relay_urls: Vec<String>, - pub evidence: TradeResyncEvidenceReceipt, - pub refresh: SyncProjectionRefreshReceipt, - pub status: TradeStatusReceipt, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeResyncEvidenceReceipt { - pub query_plan: TradeEvidenceQueryPlan, - pub inserted_count: usize, - pub duplicate_count: usize, - pub malformed_count: usize, - pub out_of_filter_count: usize, - pub skipped_over_limit_count: usize, - pub unsupported_count: usize, - pub eose_count: usize, - pub closed_count: usize, - pub notice_count: usize, - pub branches: Vec<TradeEvidenceBranchReceipt>, - pub events: Vec<TradeResyncEventImportReceipt>, - pub nostr_relay_outcomes: Vec<TradeResyncNostrRelayOutcomeReceipt>, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeEvidenceQueryPlan { - pub locator: RadrootsTradeLocator, - pub limit: u32, - pub branches: Vec<TradeEvidenceQueryBranch>, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeEvidenceQueryBranch { - pub kind: TradeEvidenceQueryBranchKind, - pub filter: TradeEvidenceNostrRelayFilter, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum TradeEvidenceQueryBranchKind { - RequestRoots, - LifecycleChain, - ValidationReceipts, - ListingSnapshot, - RejectedEvidence, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeEvidenceNostrRelayFilter { - pub active: bool, - pub event_kinds: Vec<u32>, - pub author_pubkey: Option<String>, - pub tag: Option<TradeEvidenceNostrRelayTagFilter>, - pub limit: u32, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeEvidenceNostrRelayTagFilter { - pub tag_name: String, - pub values: Vec<String>, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeEvidenceBranchReceipt { - pub branch: TradeEvidenceQueryBranchKind, - pub accepted_count: usize, - pub inserted_count: usize, - pub duplicate_count: usize, - pub malformed_count: usize, - pub out_of_filter_count: usize, - pub skipped_over_limit_count: usize, - pub unsupported_count: usize, - pub nostr_relay_failure_count: usize, - pub empty_result: bool, - pub events: Vec<TradeResyncEventImportReceipt>, - pub nostr_relay_outcomes: Vec<TradeResyncNostrRelayOutcomeReceipt>, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeResyncEventImportReceipt { - pub nostr_relay_url: String, - pub event_id: Option<String>, - pub inserted: bool, - pub duplicate: bool, - pub unsupported: bool, - pub malformed: bool, - pub out_of_filter: bool, - pub skipped_over_limit: bool, - pub projection_eligible: bool, - pub verification_status: Option<String>, - pub message: Option<String>, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeResyncNostrRelayOutcomeReceipt { - pub nostr_relay_url: String, - pub outcome_kind: TradeResyncNostrRelayOutcomeKind, - pub transport_outcome_kind: Option<TradeResyncNostrRelayTransportOutcomeKind>, - pub message: Option<String>, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum TradeResyncNostrRelayOutcomeKind { - Eose, - Closed, - Notice, -} - -#[cfg(feature = "runtime")] -impl TradeResyncNostrRelayOutcomeKind { - pub fn as_str(self) -> &'static str { - match self { - Self::Eose => "eose", - Self::Closed => "closed", - Self::Notice => "notice", - } - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum TradeResyncNostrRelayTransportOutcomeKind { - Accepted, - DuplicateAccepted, - Blocked, - RateLimited, - Invalid, - PowRequired, - Restricted, - AuthRequired, - Muted, - Unsupported, - PaymentRequired, - Error, - Timeout, - ConnectionFailed, - RelayUrlRejected, - SkippedAlreadyAccepted, - Unknown, -} - -#[cfg(feature = "runtime")] -impl TradeResyncNostrRelayTransportOutcomeKind { - pub fn as_str(self) -> &'static str { - match self { - Self::Accepted => "accepted", - Self::DuplicateAccepted => "duplicate_accepted", - Self::Blocked => "blocked", - Self::RateLimited => "rate_limited", - Self::Invalid => "invalid", - Self::PowRequired => "pow_required", - Self::Restricted => "restricted", - Self::AuthRequired => "auth_required", - Self::Muted => "muted", - Self::Unsupported => "unsupported", - Self::PaymentRequired => "payment_required", - Self::Error => "error", - Self::Timeout => "timeout", - Self::ConnectionFailed => "connection_failed", - Self::RelayUrlRejected => "relay_url_rejected", - Self::SkippedAlreadyAccepted => "skipped_already_accepted", - Self::Unknown => "unknown", - } - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -#[non_exhaustive] -pub struct TradeValidationReceiptListRequest { - pub order_id: RadrootsOrderId, - pub limit: u32, -} - -#[cfg(feature = "runtime")] -impl TradeValidationReceiptListRequest { - pub fn new(order_id: RadrootsOrderId) -> Self { - Self { - order_id, - limit: TRADE_STATUS_DEFAULT_LIMIT, - } - } - - pub fn parse(order_id: &str) -> Result<Self, RadrootsSdkError> { - RadrootsOrderId::parse(order_id) - .map(Self::new) - .map_err(|error| RadrootsSdkError::invalid_trade_id(order_id, error.to_string())) - } - - pub fn with_limit(mut self, limit: u32) -> Self { - self.limit = limit; - self - } - - #[cfg(feature = "transport-nostr-runtime")] - fn validate(&self) -> Result<(), RadrootsSdkError> { - validate_validation_receipt_limit(self.limit) - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -#[non_exhaustive] -pub struct TradeValidationReceiptInspectRequest { - pub receipt_event_id: RadrootsEventId, -} - -#[cfg(feature = "runtime")] -impl TradeValidationReceiptInspectRequest { - pub fn new(receipt_event_id: RadrootsEventId) -> Self { - Self { receipt_event_id } - } - - pub fn parse(receipt_event_id: &str) -> Result<Self, RadrootsSdkError> { - RadrootsEventId::parse(receipt_event_id) - .map(Self::new) - .map_err(|error| RadrootsSdkError::InvalidRequest { - message: format!( - "invalid validation receipt event id `{receipt_event_id}`: {error}" - ), - }) - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -#[non_exhaustive] -pub struct TradeValidationReceiptVerifyRequest { - pub receipt_event_id: RadrootsEventId, -} - -#[cfg(feature = "runtime")] -impl TradeValidationReceiptVerifyRequest { - pub fn new(receipt_event_id: RadrootsEventId) -> Self { - Self { receipt_event_id } - } - - pub fn parse(receipt_event_id: &str) -> Result<Self, RadrootsSdkError> { - RadrootsEventId::parse(receipt_event_id) - .map(Self::new) - .map_err(|error| RadrootsSdkError::InvalidRequest { - message: format!( - "invalid validation receipt event id `{receipt_event_id}`: {error}" - ), - }) - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeValidationReceiptListReceipt { - pub nostr_relay_urls: Vec<String>, - pub nostr_evidence: TradeValidationReceiptNostrEvidenceReceipt, - pub order_id: RadrootsOrderId, - pub receipts: Vec<TradeValidationReceiptEvent>, - pub invalid_receipts: Vec<TradeValidationReceiptInvalidCandidate>, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeValidationReceiptInspectReceipt { - pub nostr_relay_urls: Vec<String>, - pub nostr_evidence: TradeValidationReceiptNostrEvidenceReceipt, - pub receipt_event_id: RadrootsEventId, - pub receipt: Option<TradeValidationReceiptEvent>, - pub invalid_receipt: Option<TradeValidationReceiptInvalidCandidate>, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeValidationReceiptEvent { - pub event: RadrootsEventEnvelope, - pub receipt: RadrootsTradeValidationReceipt, - pub tags: TradeValidationReceiptTags, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeValidationReceiptInvalidCandidate { - pub event: RadrootsEventEnvelope, - pub reason_code: String, - pub reason: String, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeValidationReceiptTags { - pub order_id: String, - pub event_set_root: String, - pub listing_event_id: String, - pub reducer_output_root: String, - pub public_values_hash: String, - pub proof_system: String, - pub receipt_type: String, - pub root_event_id: String, - pub target_event_id: String, - pub validator_set_addr: String, - pub validator_set_event_id: String, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeValidationReceiptNostrEvidenceReceipt { - pub inserted_count: usize, - pub duplicate_count: usize, - pub malformed_count: usize, - pub out_of_filter_count: usize, - pub skipped_over_limit_count: usize, - pub unsupported_count: usize, - pub eose_count: usize, - pub closed_count: usize, - pub notice_count: usize, - pub events: Vec<TradeResyncEventImportReceipt>, - pub nostr_relay_outcomes: Vec<TradeValidationReceiptNostrRelayOutcomeReceipt>, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeValidationReceiptNostrRelayOutcomeReceipt { - pub nostr_relay_url: String, - pub outcome_kind: TradeValidationReceiptNostrRelayOutcomeKind, - pub transport_outcome_kind: Option<TradeValidationReceiptNostrRelayTransportOutcomeKind>, - pub message: Option<String>, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum TradeValidationReceiptNostrRelayOutcomeKind { - Eose, - Closed, - Notice, -} - -#[cfg(feature = "runtime")] -impl TradeValidationReceiptNostrRelayOutcomeKind { - pub fn as_str(self) -> &'static str { - match self { - Self::Eose => "eose", - Self::Closed => "closed", - Self::Notice => "notice", - } - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum TradeValidationReceiptNostrRelayTransportOutcomeKind { - Accepted, - DuplicateAccepted, - Blocked, - RateLimited, - Invalid, - PowRequired, - Restricted, - AuthRequired, - Muted, - Unsupported, - PaymentRequired, - Error, - Timeout, - ConnectionFailed, - RelayUrlRejected, - SkippedAlreadyAccepted, - Unknown, -} - -#[cfg(feature = "runtime")] -impl TradeValidationReceiptNostrRelayTransportOutcomeKind { - pub fn as_str(self) -> &'static str { - match self { - Self::Accepted => "accepted", - Self::DuplicateAccepted => "duplicate_accepted", - Self::Blocked => "blocked", - Self::RateLimited => "rate_limited", - Self::Invalid => "invalid", - Self::PowRequired => "pow_required", - Self::Restricted => "restricted", - Self::AuthRequired => "auth_required", - Self::Muted => "muted", - Self::Unsupported => "unsupported", - Self::PaymentRequired => "payment_required", - Self::Error => "error", - Self::Timeout => "timeout", - Self::ConnectionFailed => "connection_failed", - Self::RelayUrlRejected => "relay_url_rejected", - Self::SkippedAlreadyAccepted => "skipped_already_accepted", - Self::Unknown => "unknown", - } - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -#[non_exhaustive] -pub struct TradeStatusRequest { - pub locator: RadrootsTradeLocator, - pub limit: u32, - pub source: SdkTradeStatusSource, - pub validation_trust_policy: RadrootsTradeValidationTrustPolicy, -} - -#[cfg(feature = "runtime")] -impl TradeStatusRequest { - pub fn new(locator: RadrootsTradeLocator) -> Self { - Self { - locator, - limit: TRADE_STATUS_DEFAULT_LIMIT, - source: SdkTradeStatusSource::LocalOnly, - validation_trust_policy: RadrootsTradeValidationTrustPolicy::production(), - } - } - - pub fn parse(selector: &str) -> Result<Self, RadrootsSdkError> { - let (trade_id, root_event_id) = trade_status_selector_parts(selector)?; - let locator = RadrootsOrderId::parse(trade_id) - .map(RadrootsTradeLocator::from_order_id) - .map_err(|error| RadrootsSdkError::invalid_trade_id(trade_id, error.to_string()))?; - let locator = match root_event_id { - Some(root_event_id) => { - locator.with_root_event_id(root_event_id.parse().map_err(|error| { - RadrootsSdkError::InvalidRequest { - message: format!( - "invalid trade status root selector `{selector}`: {error}" - ), - } - })?) - } - None => locator, - }; - Ok(Self::new(locator)) - } - - pub fn locator_selector(locator: &RadrootsTradeLocator) -> String { - match locator.root_event_id.as_ref() { - Some(root_event_id) => format!( - "{}{TRADE_STATUS_ROOT_SELECTOR_SEPARATOR}{}", - locator.trade_id.as_str(), - root_event_id.as_str() - ), - None => locator.trade_id.as_str().to_owned(), - } - } - - pub fn with_limit(mut self, limit: u32) -> Self { - self.limit = limit; - self - } - - pub fn with_source(mut self, source: SdkTradeStatusSource) -> Self { - self.source = source; - self - } - - pub fn with_validation_trust_policy( - mut self, - policy: RadrootsTradeValidationTrustPolicy, - ) -> Self { - self.validation_trust_policy = policy; - self - } - - fn validate(&self) -> Result<(), RadrootsSdkError> { - if self.limit == 0 || self.limit > TRADE_STATUS_MAX_LIMIT { - return Err(RadrootsSdkError::trade_status_limit_invalid( - self.limit, - 1, - TRADE_STATUS_MAX_LIMIT, - )); - } - Ok(()) - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -#[non_exhaustive] -pub struct TradeStatusWatchRequest { - pub status: TradeStatusRequest, - pub capacity: usize, - pub refresh_interval_ms: u64, - pub refresh_limit: Option<u32>, -} - -#[cfg(feature = "runtime")] -impl TradeStatusWatchRequest { - pub fn new(status: TradeStatusRequest) -> Self { - Self { - status, - capacity: TRADE_STATUS_WATCH_DEFAULT_CAPACITY, - refresh_interval_ms: TRADE_STATUS_WATCH_DEFAULT_REFRESH_INTERVAL_MS, - refresh_limit: None, - } - } - - pub fn parse(selector: &str) -> Result<Self, RadrootsSdkError> { - TradeStatusRequest::parse(selector).map(Self::new) - } - - pub fn with_capacity(mut self, capacity: usize) -> Self { - self.capacity = capacity; - self - } - - pub fn with_refresh_interval_ms(mut self, refresh_interval_ms: u64) -> Self { - self.refresh_interval_ms = refresh_interval_ms; - self - } - - pub fn with_refresh_limit(mut self, refresh_limit: u32) -> Self { - self.refresh_limit = Some(refresh_limit); - self - } - - pub fn without_refresh_limit(mut self) -> Self { - self.refresh_limit = None; - self - } - - fn validate(&self) -> Result<(), RadrootsSdkError> { - self.status.validate()?; - if self.capacity == 0 || self.capacity > TRADE_STATUS_WATCH_MAX_CAPACITY { - return Err(RadrootsSdkError::InvalidRequest { - message: format!( - "trade status watch capacity must be between 1 and {TRADE_STATUS_WATCH_MAX_CAPACITY}" - ), - }); - } - if self.refresh_interval_ms == 0 - || self.refresh_interval_ms > TRADE_STATUS_WATCH_MAX_REFRESH_INTERVAL_MS - { - return Err(RadrootsSdkError::InvalidRequest { - message: format!( - "trade status watch refresh interval must be between 1 and {TRADE_STATUS_WATCH_MAX_REFRESH_INTERVAL_MS} milliseconds" - ), - }); - } - if self.refresh_limit == Some(0) { - return Err(RadrootsSdkError::InvalidRequest { - message: "trade status watch refresh limit must be greater than zero".to_owned(), - }); - } - Ok(()) - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeStatusWatchUpdate { - pub sequence: u64, - pub status: TradeStatusReceipt, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeStatusWatchCancelReceipt { - pub state: TradeStatusWatchCancelState, - pub buffered_updates_dropped: usize, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum TradeStatusWatchCancelState { - Cancelled, - AlreadyFinished, -} - -#[cfg(feature = "runtime")] -pub struct TradeStatusWatch { - receiver: mpsc::Receiver<Result<TradeStatusWatchUpdate, RadrootsSdkError>>, - cancel: Option<oneshot::Sender<()>>, - producer: Option<JoinHandle<()>>, - capacity: usize, -} - -#[cfg(feature = "runtime")] -impl TradeStatusWatch { - pub fn capacity(&self) -> usize { - self.capacity - } - - pub fn buffered_len(&self) -> usize { - self.receiver.len() - } - - pub async fn next(&mut self) -> Result<Option<TradeStatusWatchUpdate>, RadrootsSdkError> { - match self.receiver.recv().await { - Some(Ok(update)) => Ok(Some(update)), - Some(Err(error)) => Err(error), - None => Ok(None), - } - } - - pub async fn cancel(&mut self) -> TradeStatusWatchCancelReceipt { - let producer_active = self - .producer - .as_ref() - .map(|producer| !producer.is_finished()) - .unwrap_or(false); - let buffered_updates_dropped = self.drain_buffered_updates(); - let cancel_sent = self - .cancel - .take() - .map(|sender| sender.send(()).is_ok()) - .unwrap_or(false); - let producer_finished = match self.producer.take() { - Some(producer) => producer.await.is_ok(), - None => true, - }; - let state = if producer_active || cancel_sent || !producer_finished { - TradeStatusWatchCancelState::Cancelled - } else { - TradeStatusWatchCancelState::AlreadyFinished - }; - TradeStatusWatchCancelReceipt { - state, - buffered_updates_dropped, - } - } - - fn drain_buffered_updates(&mut self) -> usize { - self.receiver.close(); - let mut dropped = 0; - while self.receiver.try_recv().is_ok() { - dropped += 1; - } - dropped - } -} - -#[cfg(feature = "runtime")] -impl Drop for TradeStatusWatch { - fn drop(&mut self) { - self.drain_buffered_updates(); - if let Some(cancel) = self.cancel.take() { - let _ = cancel.send(()); - } - if let Some(producer) = self.producer.take() { - producer.abort(); - } - } -} - -#[cfg(feature = "runtime")] -fn trade_status_selector_parts(selector: &str) -> Result<(&str, Option<&str>), RadrootsSdkError> { - let selector = selector.trim(); - if selector.is_empty() { - return Err(RadrootsSdkError::invalid_trade_id( - selector, - "empty trade id", - )); - } - let Some((trade_id, root_event_id)) = selector.split_once(TRADE_STATUS_ROOT_SELECTOR_SEPARATOR) - else { - return Ok((selector, None)); - }; - if trade_id.trim().is_empty() - || root_event_id.trim().is_empty() - || root_event_id.contains(TRADE_STATUS_ROOT_SELECTOR_SEPARATOR) - { - return Err(RadrootsSdkError::InvalidRequest { - message: format!("invalid trade status selector `{selector}`"), - }); - } - Ok((trade_id, Some(root_event_id))) -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeStatusReceipt { - pub locator: RadrootsTradeLocator, - pub order_id: RadrootsOrderId, - pub root_event_id: Option<RadrootsEventId>, - pub ambiguity_candidates: Vec<TradeStatusAmbiguityCandidate>, - pub source: SdkTradeStatusSource, - pub found: bool, - pub event_count: usize, - pub limit_applied: u32, - pub status: TradeStatusKind, - pub lifecycle_terminal: bool, - pub listing_addr: Option<RadrootsListingAddress>, - pub buyer_pubkey: Option<RadrootsPublicKey>, - pub seller_pubkey: Option<RadrootsPublicKey>, - pub economics: Option<RadrootsOrderEconomics>, - pub evidence: TradeStatusEvidenceSummary, - pub validation_trust: Option<TradeValidationTrustDecision>, - pub online_evidence: Option<TradeResyncEvidenceReceipt>, - pub eligibility: TradeStatusEligibility, - pub next_action: TradeStatusNextActionKind, - pub event_ids: Vec<RadrootsEventId>, - pub request_event_id: Option<RadrootsEventId>, - pub decision_event_id: Option<RadrootsEventId>, - pub agreement_event_id: Option<RadrootsEventId>, - pub rhi_receipt_event_id: Option<RadrootsEventId>, - pub cancellation_event_id: Option<RadrootsEventId>, - pub last_event_id: Option<RadrootsEventId>, - pub issues: Vec<SdkTradeStatusIssue>, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeStatusAmbiguityCandidate { - pub locator: RadrootsTradeLocator, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeStatusEvidenceSummary { - pub event_count: usize, - pub limit_applied: u32, - pub has_request: bool, - pub has_decision: bool, - pub has_agreement: bool, - pub has_validation_receipt: bool, - pub has_cancellation: bool, - pub has_issues: bool, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeValidationTrustDecision { - pub state: RadrootsTradeValidationTrustState, - pub validator_count: usize, - pub validator_set_addr: Option<String>, - pub validator_set_event_id: Option<String>, - pub require_cryptographic_proof: bool, - pub receipt_event_id: Option<RadrootsEventId>, - pub receipt_author: Option<RadrootsPublicKey>, - pub result_event_id: Option<RadrootsEventId>, - pub result_author: Option<RadrootsPublicKey>, - pub proof_system: Option<String>, - pub validation_authority: Option<RadrootsTradeValidationAuthority>, - pub commitment_confidence: Option<RadrootsTradeCommitmentConfidence>, - pub cryptographic_proof_required: bool, - pub cryptographic_proof_verified: bool, - pub production_committed: bool, - pub reason_code: Option<String>, - pub reason: Option<String>, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeStatusEligibility { - pub can_decide: bool, - pub can_cancel: bool, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum TradeStatusNextActionKind { - NoLocalOrder, - InspectEvidenceIssues, - AwaitSellerDecision, - AwaitValidation, - Terminal, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum SdkTradeStatusSource { - LocalOnly, - ResyncThenLocal, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum TradeStatusKind { - Missing, - Ambiguous, - Requested, - AgreedPendingValidation, - Committed, - Declined, - Cancelled, - ValidationExpired, - Invalid, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SdkTradeStatusIssue { - pub kind: SdkTradeStatusIssueKind, - pub event_ids: Vec<RadrootsEventId>, -} - -#[cfg(feature = "runtime")] -impl SdkTradeStatusIssue { - fn new(kind: SdkTradeStatusIssueKind, event_ids: Vec<RadrootsEventId>) -> Self { - Self { kind, event_ids } - } - - fn single(kind: SdkTradeStatusIssueKind, event_id: RadrootsEventId) -> Self { - Self::new(kind, vec![event_id]) - } - - pub fn code(&self) -> String { - self.kind.code() - } -} - -#[cfg(feature = "runtime")] -impl serde::Serialize for SdkTradeStatusIssue { - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: serde::Serializer, - { - let mut state = serializer.serialize_struct("SdkTradeStatusIssue", 3)?; - state.serialize_field("code", &self.code())?; - state.serialize_field("kind", &self.kind)?; - state.serialize_field("event_ids", &self.event_ids)?; - state.end() - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum SdkTradeStatusIssueKind { - MissingRequest, - MultipleRequests, - RequestPayloadInvalid, - RequestOrderIdMismatch, - RequestAuthorMismatch, - RequestListingAddressInvalid, - RequestSellerListingMismatch, - DecisionPayloadInvalid, - DecisionOrderIdMismatch, - DecisionAuthorMismatch, - DecisionCounterpartyMismatch, - DecisionBuyerMismatch, - DecisionSellerMismatch, - DecisionListingAddressInvalid, - DecisionListingMismatch, - DecisionRootMismatch, - DecisionPreviousMismatch, - DecisionMissingInventoryCommitments, - DecisionInventoryCommitmentMismatch, - DecisionMissingReason, - ConflictingDecisions, - CancellationWithoutCancellableOrder, - CancellationPayloadInvalid, - CancellationOrderIdMismatch, - CancellationAuthorMismatch, - CancellationCounterpartyMismatch, - CancellationBuyerMismatch, - CancellationSellerMismatch, - CancellationListingAddressInvalid, - CancellationListingMismatch, - CancellationRootMismatch, - CancellationPreviousMismatch, - ForkedLifecycle, - ValidationReceiptWithoutPendingAgreement, - ValidationReceiptOrderIdMismatch, - ValidationReceiptTypeMismatch, - ValidationReceiptRootMismatch, - ValidationReceiptTargetMismatch, - ValidationReceiptListingMismatch, - ConflictingValidationReceipts, - DeterministicValidationFailure, - StaleListingEvent, -} - -#[cfg(feature = "runtime")] -impl SdkTradeStatusIssueKind { - pub fn code(self) -> String { - camel_to_snake(format!("{self:?}").as_str()) - } -} - -#[cfg(feature = "runtime")] -impl<'sdk> TradesClient<'sdk> { - pub async fn ingest_evidence( - &self, - request: TradeEvidenceIngestRequest, - ) -> Result<TradeEvidenceIngestReceipt, RadrootsSdkError> { - let evidence = parse_order_evidence(request.event.envelope())?; - let observed_at = self.resolved_created_at(request.observed_at)?; - let observed_at_ms = sdk_timestamp_ms(observed_at)?; - let receipt = self - .sdk - ._event_store - .ingest_event(RadrootsEventIngest::new(request.event, observed_at_ms)) - .await - .map_err(|error| RadrootsSdkError::EventStore { - message: error.to_string(), - })?; - Ok(TradeEvidenceIngestReceipt { - order_id: evidence.order_id, - listing_addr: evidence.listing_addr, - event_id: evidence.event_id, - event_kind: evidence.event_kind, - local_event_seq: receipt.seq, - inserted: receipt.inserted, - }) - } - - pub async fn ingest_request_evidence( - &self, - request: TradeRequestEvidenceIngestRequest, - ) -> Result<TradeRequestEvidenceIngestReceipt, RadrootsSdkError> { - let evidence = parse_order_request_evidence(request.event.envelope())?; - let observed_at = self.resolved_created_at(request.observed_at)?; - let observed_at_ms = sdk_timestamp_ms(observed_at)?; - let receipt = self - .sdk - ._event_store - .ingest_event(RadrootsEventIngest::new(request.event, observed_at_ms)) - .await - .map_err(|error| RadrootsSdkError::EventStore { - message: error.to_string(), - })?; - Ok(TradeRequestEvidenceIngestReceipt { - order_id: evidence.order_id, - listing_addr: evidence.listing_addr, - buyer_pubkey: evidence.buyer_pubkey, - seller_pubkey: evidence.seller_pubkey, - request_event_id: evidence.request_event_id, - local_event_seq: receipt.seq, - inserted: receipt.inserted, - }) - } - - #[cfg(feature = "signer-adapters")] - pub(crate) fn prepare_submit( - &self, - request: TradeSubmitPrepareRequest, - ) -> Result<TradeSubmitPlan, RadrootsSdkError> { - let created_at = self.resolved_created_at(request.created_at)?; - order_submit_plan( - &request.actor, - request.listing_event, - request.order, - created_at, - ) - } - - #[cfg(feature = "signer-adapters")] - pub(crate) async fn enqueue_prepared_submit( - &self, - actor: &RadrootsActorContext, - plan: TradeSubmitPlan, - target_policy: TargetPolicy, - publish_mode: PublishMode, - satisfaction_policy: SatisfactionPolicy, - idempotency_key: Option<SdkIdempotencyKey>, - ) -> Result<TradeSubmitReceipt, RadrootsSdkError> { - validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; - let enqueue = enqueue_configured_signed_workflow( - self.sdk, - SdkWorkflowEnqueueRequest { - operation_kind: TradeWorkflowKind::Submit.operation_kind(), - actor, - frozen_draft: &plan.frozen_draft, - target_policy: target_policy.workflow_target_policy(), - satisfaction_policy, - idempotency_key, - }, - ) - .await?; - Ok(order_submit_receipt(plan, enqueue)) - } - - #[cfg(feature = "signer-adapters")] - pub(crate) fn prepare_decision( - &self, - request: TradeDecisionPrepareRequest, - ) -> Result<TradeDecisionPlan, RadrootsSdkError> { - let created_at = self.resolved_created_at(request.created_at)?; - order_decision_plan( - &request.actor, - request.request_event, - request.decision, - created_at, - ) - } - - #[cfg(feature = "signer-adapters")] - pub(crate) async fn enqueue_prepared_decision( - &self, - actor: &RadrootsActorContext, - plan: TradeDecisionPlan, - target_policy: TargetPolicy, - publish_mode: PublishMode, - satisfaction_policy: SatisfactionPolicy, - idempotency_key: Option<SdkIdempotencyKey>, - ) -> Result<TradeDecisionReceipt, RadrootsSdkError> { - validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; - if !self - .prepared_order_event_exists(&plan.expected_event_id) - .await? - { - self.require_decision_preflight(&plan).await?; - } - let enqueue = enqueue_configured_signed_workflow( - self.sdk, - SdkWorkflowEnqueueRequest { - operation_kind: TradeWorkflowKind::Decision.operation_kind(), - actor, - frozen_draft: &plan.frozen_draft, - target_policy: target_policy.workflow_target_policy(), - satisfaction_policy, - idempotency_key, - }, - ) - .await?; - Ok(order_decision_receipt(plan, enqueue)) - } - - #[cfg(feature = "signer-adapters")] - pub(crate) fn prepare_cancellation( - &self, - request: TradeCancellationPrepareRequest, - ) -> Result<TradeCancellationPlan, RadrootsSdkError> { - let created_at = self.resolved_created_at(request.created_at)?; - order_cancellation_plan( - &request.actor, - request.root_event, - request.previous_event, - request.cancellation, - created_at, - ) - } - - #[cfg(feature = "signer-adapters")] - pub(crate) async fn enqueue_prepared_cancellation( - &self, - actor: &RadrootsActorContext, - plan: TradeCancellationPlan, - target_policy: TargetPolicy, - publish_mode: PublishMode, - satisfaction_policy: SatisfactionPolicy, - idempotency_key: Option<SdkIdempotencyKey>, - ) -> Result<TradeCancellationReceipt, RadrootsSdkError> { - validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; - if !self - .prepared_order_event_exists(&plan.expected_event_id) - .await? - { - self.require_cancellation_preflight(&plan).await?; - } - let enqueue = enqueue_configured_signed_workflow( - self.sdk, - SdkWorkflowEnqueueRequest { - operation_kind: TradeWorkflowKind::Cancellation.operation_kind(), - actor, - frozen_draft: &plan.frozen_draft, - target_policy: target_policy.workflow_target_policy(), - satisfaction_policy, - idempotency_key, - }, - ) - .await?; - Ok(order_cancellation_receipt(plan, enqueue)) - } - - pub async fn status( - &self, - request: TradeStatusRequest, - ) -> Result<TradeStatusReceipt, RadrootsSdkError> { - match request.source { - SdkTradeStatusSource::LocalOnly => self.local_status(request).await, - SdkTradeStatusSource::ResyncThenLocal => { - #[cfg(feature = "transport-nostr-runtime")] - { - let adapter = RadrootsNostrClientFetchAdapter; - return self.status_with_fetch_adapter(request, &adapter).await; - } - #[cfg(not(feature = "transport-nostr-runtime"))] - { - let _ = request; - Err(RadrootsSdkError::ProductSyncUnsupported { - operation: "trade.status.resync_then_local", - required_feature: "transport-nostr-runtime", - }) - } - } - } - } - - pub async fn watch( - &self, - request: TradeStatusWatchRequest, - ) -> Result<TradeStatusWatch, RadrootsSdkError> { - request.validate()?; - let handle = tokio::runtime::Handle::try_current().map_err(|_| { - RadrootsSdkError::InvalidRequest { - message: "trade status watch requires an active Tokio runtime".to_owned(), - } - })?; - let (updates, receiver) = mpsc::channel(request.capacity); - let (cancel, cancel_receiver) = oneshot::channel(); - let sdk = self.sdk.clone(); - let capacity = request.capacity; - let producer = handle.spawn(run_trade_status_watch( - sdk, - request, - updates, - cancel_receiver, - )); - Ok(TradeStatusWatch { - receiver, - cancel: Some(cancel), - producer: Some(producer), - capacity, - }) - } - - #[cfg(all(feature = "signer-adapters", feature = "transport-nostr-runtime"))] - pub async fn status_with_fetch_adapter<A>( - &self, - request: TradeStatusRequest, - adapter: &A, - ) -> Result<TradeStatusReceipt, RadrootsSdkError> - where - A: RadrootsRelayFetchAdapter, - { - match request.source { - SdkTradeStatusSource::LocalOnly => self.local_status(request).await, - SdkTradeStatusSource::ResyncThenLocal => { - request.validate()?; - let execution = execute_trade_resync_with_fetch_adapter( - self.sdk, - request.locator.clone(), - request.limit, - adapter, - "trade.status.resync_then_local", - ) - .await?; - let mut status = self - .local_status( - TradeStatusRequest::new(request.locator) - .with_limit(request.limit) - .with_validation_trust_policy(request.validation_trust_policy), - ) - .await?; - status.source = SdkTradeStatusSource::ResyncThenLocal; - status.online_evidence = Some(execution.evidence); - Ok(status) - } - } - } - - async fn local_status( - &self, - request: TradeStatusRequest, - ) -> Result<TradeStatusReceipt, RadrootsSdkError> { - request.validate()?; - let query_result = order_projection_query_for_trade_locator( - &self.sdk._event_store, - &request.locator, - request.limit, - ) - .await - .map_err(projection_error)?; - let mut receipt = - TradeStatusReceipt::from_locator_query_result(request.locator, query_result); - receipt.source = request.source; - apply_trade_status_validation_trust( - self.sdk, - &mut receipt, - &request.validation_trust_policy, - ) - .await?; - Ok(receipt) - } - - fn resolved_created_at( - &self, - created_at: Option<RadrootsSdkTimestamp>, - ) -> Result<RadrootsSdkTimestamp, RadrootsSdkError> { - match created_at { - Some(created_at) => Ok(created_at), - None => self.sdk.now(), - } - } - - #[cfg(feature = "signer-adapters")] - async fn require_decision_preflight( - &self, - plan: &TradeDecisionPlan, - ) -> Result<(), RadrootsSdkError> { - let query_result = self.query_order_projection(&plan.order_id).await?; - require_decision_request_evidence(plan, &query_result.projection) - } - - #[cfg(feature = "signer-adapters")] - async fn require_cancellation_preflight( - &self, - plan: &TradeCancellationPlan, - ) -> Result<(), RadrootsSdkError> { - let query_result = self.query_order_projection(&plan.order_id).await?; - require_cancellation_state(plan, &query_result.projection) - } - - #[cfg(feature = "signer-adapters")] - async fn query_order_projection( - &self, - order_id: &RadrootsOrderId, - ) -> Result<RadrootsOrderProjectionQueryResult, RadrootsSdkError> { - order_projection_query_for_order_id( - &self.sdk._event_store, - order_id, - TRADE_STATUS_MAX_LIMIT, - ) - .await - .map_err(projection_error) - } - - #[cfg(feature = "signer-adapters")] - async fn prepared_order_event_exists( - &self, - expected_event_id: &RadrootsEventId, - ) -> Result<bool, RadrootsSdkError> { - self.sdk - ._event_store - .get_event(expected_event_id.as_str()) - .await - .map(|event| event.is_some()) - .map_err(|error| RadrootsSdkError::EventStore { - message: error.to_string(), - }) - } -} - -#[cfg(feature = "runtime")] -async fn run_trade_status_watch( - sdk: crate::RadrootsClient, - request: TradeStatusWatchRequest, - updates: mpsc::Sender<Result<TradeStatusWatchUpdate, RadrootsSdkError>>, - mut cancel: oneshot::Receiver<()>, -) { - let refresh_interval = Duration::from_millis(request.refresh_interval_ms); - let mut sequence = 0_u64; - loop { - let status_result = sdk.trades().status(request.status.clone()).await; - let stop_after_update = status_result - .as_ref() - .map(|status| status.lifecycle_terminal) - .unwrap_or(true); - sequence += 1; - let update_result = status_result.map(|status| TradeStatusWatchUpdate { sequence, status }); - let send_result = tokio::select! { - _ = &mut cancel => break, - send_result = updates.send(update_result) => send_result, - }; - if send_result.is_err() || stop_after_update { - break; - } - if request - .refresh_limit - .map(|refresh_limit| sequence >= u64::from(refresh_limit)) - .unwrap_or(false) - { - break; - } - tokio::select! { - _ = &mut cancel => break, - _ = tokio::time::sleep(refresh_interval) => {} - } - } -} - -#[cfg(feature = "runtime")] -impl<'sdk> TradeResyncClient<'sdk> { - pub async fn resync( - &self, - request: TradeResyncRequest, - ) -> Result<TradeResyncReceipt, RadrootsSdkError> { - #[cfg(feature = "transport-nostr-runtime")] - { - let adapter = RadrootsNostrClientFetchAdapter; - return self.resync_with_fetch_adapter(request, &adapter).await; - } - #[cfg(not(feature = "transport-nostr-runtime"))] - { - let _ = self.sdk; - let _ = request; - Err(RadrootsSdkError::ProductSyncUnsupported { - operation: "trade.resync", - required_feature: "transport-nostr-runtime", - }) - } - } - - #[cfg(feature = "transport-nostr-runtime")] - pub async fn resync_with_fetch_adapter<A>( - &self, - request: TradeResyncRequest, - adapter: &A, - ) -> Result<TradeResyncReceipt, RadrootsSdkError> - where - A: RadrootsRelayFetchAdapter, - { - request.validate()?; - let execution = execute_trade_resync_with_fetch_adapter( - self.sdk, - request.locator.clone(), - request.limit, - adapter, - "trade.resync", - ) - .await?; - let status = trades_client(self.sdk) - .status( - TradeStatusRequest::new(request.locator.clone()) - .with_limit(request.limit) - .with_validation_trust_policy(request.validation_trust_policy.clone()), - ) - .await?; - if status.status == TradeStatusKind::Ambiguous { - return Err(RadrootsSdkError::TradeAmbiguous { - operation: "trade.resync".to_owned(), - locator: Box::new(request.locator), - candidates: status - .ambiguity_candidates - .iter() - .map(|candidate| candidate.locator.clone()) - .collect(), - }); - } - Ok(TradeResyncReceipt { - nostr_relay_urls: execution.nostr_relay_urls, - evidence: execution.evidence, - refresh: execution.refresh, - status, - }) - } -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -struct TradeResyncExecution { - nostr_relay_urls: Vec<String>, - evidence: TradeResyncEvidenceReceipt, - refresh: SyncProjectionRefreshReceipt, -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -async fn execute_trade_resync_with_fetch_adapter<A>( - sdk: &crate::RadrootsClient, - locator: RadrootsTradeLocator, - limit: u32, - adapter: &A, - operation: &'static str, -) -> Result<TradeResyncExecution, RadrootsSdkError> -where - A: RadrootsRelayFetchAdapter, -{ - let nostr_relay_urls = sdk.configured_nostr_relay_urls().to_vec(); - if nostr_relay_urls.is_empty() { - return Err(RadrootsSdkError::empty_transport_targets(operation)); - } - let query_plan = trade_evidence_query_plan(locator, limit)?; - let fetch_request = trade_evidence_fetch_request(sdk, &query_plan, &nostr_relay_urls)?; - let fetch_receipt = - fetch_and_ingest_relay_events(adapter, &sdk._event_store, fetch_request).await?; - if trade_resync_total_relay_failure(&fetch_receipt, nostr_relay_urls.len()) { - return Err(RadrootsSdkError::ProductSyncTransportSetupFailure { - message: trade_resync_total_failure_message(operation, &fetch_receipt), - }); - } - let evidence = TradeResyncEvidenceReceipt::from_fetch(sdk, query_plan, fetch_receipt).await?; - let refresh = refresh_product_projections_for_sdk( - sdk, - SyncProjectionRefreshRequest::new().with_limit(limit), - ) - .await?; - Ok(TradeResyncExecution { - nostr_relay_urls, - evidence, - refresh, - }) -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -fn trade_evidence_fetch_request( - sdk: &crate::RadrootsClient, - query_plan: &TradeEvidenceQueryPlan, - nostr_relay_urls: &[String], -) -> Result<RadrootsRelayFetchRequest, RadrootsSdkError> { - let mut filters = Vec::new(); - for branch in query_plan - .branches - .iter() - .filter(|branch| branch.filter.active) - { - filters.extend(trade_evidence_branch_filters(branch)?); - } - if filters.is_empty() { - return Err(RadrootsSdkError::InvalidRequest { - message: "trade evidence query plan has no active relay filters".to_owned(), - }); - } - Ok( - RadrootsRelayFetchRequest::fetch(sdk_now_ms(sdk)?, query_plan.limit as usize, filters)? - .with_relay_urls(nostr_relay_urls.iter().cloned()), - ) -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -fn trade_evidence_query_plan( - locator: RadrootsTradeLocator, - limit: u32, -) -> Result<TradeEvidenceQueryPlan, RadrootsSdkError> { - let order_id = locator.order_id().as_str().to_owned(); - let root_event_id = locator - .root_event_id - .as_ref() - .map(|value| value.as_str().to_owned()); - let lifecycle_tag = root_event_id - .map(|event_id| (TAG_E, vec![event_id])) - .unwrap_or_else(|| (TAG_D, vec![order_id.clone()])); - let listing_filter = locator - .listing_addr - .as_ref() - .map(|value| listing_snapshot_filter_parts(value.as_str())) - .transpose()?; - let branches = vec![ - trade_evidence_branch( - TradeEvidenceQueryBranchKind::RequestRoots, - vec![KIND_ORDER_REQUEST], - Some((TAG_D, vec![order_id.clone()])), - None, - limit, - true, - ), - trade_evidence_branch( - TradeEvidenceQueryBranchKind::LifecycleChain, - vec![KIND_ORDER_DECISION, KIND_ORDER_CANCELLATION], - Some(lifecycle_tag), - None, - limit, - true, - ), - trade_evidence_branch( - TradeEvidenceQueryBranchKind::ValidationReceipts, - vec![KIND_TRADE_VALIDATION_RECEIPT], - Some((TAG_D, vec![order_id])), - None, - limit, - true, - ), - trade_evidence_branch( - TradeEvidenceQueryBranchKind::ListingSnapshot, - vec![KIND_LISTING], - listing_filter - .as_ref() - .map(|parts| (TAG_D, vec![parts.listing_id.clone()])), - listing_filter - .as_ref() - .map(|parts| parts.seller_pubkey.clone()), - limit, - locator.listing_addr.is_some(), - ), - ]; - Ok(TradeEvidenceQueryPlan { - locator, - limit, - branches, - }) -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -fn trade_evidence_branch( - kind: TradeEvidenceQueryBranchKind, - event_kinds: Vec<u32>, - tag: Option<(&'static str, Vec<String>)>, - author_pubkey: Option<String>, - limit: u32, - active: bool, -) -> TradeEvidenceQueryBranch { - TradeEvidenceQueryBranch { - kind, - filter: TradeEvidenceNostrRelayFilter { - active, - event_kinds, - author_pubkey, - tag: tag.map(|(tag_name, values)| TradeEvidenceNostrRelayTagFilter { - tag_name: tag_name.to_owned(), - values, - }), - limit, - }, - } -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -fn trade_evidence_branch_filters( - branch: &TradeEvidenceQueryBranch, -) -> Result<Vec<RadrootsNostrFilter>, RadrootsSdkError> { - if branch.filter.event_kinds.is_empty() { - return Err(RadrootsSdkError::InvalidRequest { - message: "trade evidence branch has no event kinds".to_owned(), - }); - } - branch - .filter - .event_kinds - .iter() - .map(|kind| trade_evidence_branch_filter(branch, *kind)) - .collect() -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -fn trade_evidence_branch_filter( - branch: &TradeEvidenceQueryBranch, - kind: u32, -) -> Result<RadrootsNostrFilter, RadrootsSdkError> { - let mut filter = RadrootsNostrFilter::new().limit(branch.filter.limit as usize); - let nostr_kind = u16::try_from(kind).map_err(|_| RadrootsSdkError::InvalidRequest { - message: format!("trade evidence event kind {kind} exceeds Nostr filter range"), - })?; - filter = filter.kind(RadrootsNostrKind::Custom(nostr_kind)); - if let Some(author_pubkey) = branch.filter.author_pubkey.as_ref() { - let author = author_pubkey - .parse::<RadrootsNostrPublicKey>() - .map_err(|error| RadrootsSdkError::InvalidRequest { - message: format!("trade evidence filter author invalid: {error}"), - })?; - filter = filter.author(author); - } - match branch.filter.tag.as_ref() { - Some(tag) => radroots_nostr_filter_tag(filter, tag.tag_name.as_str(), tag.values.clone()) - .map_err(|error| RadrootsSdkError::InvalidRequest { - message: format!("trade evidence filter invalid: {error}"), - }), - None => Ok(filter), - } -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -struct ListingSnapshotFilterParts { - seller_pubkey: String, - listing_id: String, -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -fn listing_snapshot_filter_parts( - listing_addr: &str, -) -> Result<ListingSnapshotFilterParts, RadrootsSdkError> { - let parts = - parse_listing_address(listing_addr).map_err(|error| RadrootsSdkError::InvalidRequest { - message: format!("trade listing snapshot filter invalid: {error}"), - })?; - Ok(ListingSnapshotFilterParts { - seller_pubkey: parts.seller_pubkey.as_str().to_owned(), - listing_id: parts.listing_id.as_str().to_owned(), - }) -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -fn trade_resync_total_relay_failure( - receipt: &RadrootsRelayFetchReceipt, - relay_count: usize, -) -> bool { - relay_count > 0 && receipt.eose_count == 0 && receipt.closed_count >= relay_count -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -fn trade_resync_total_failure_message( - operation: &str, - receipt: &RadrootsRelayFetchReceipt, -) -> String { - format!( - "{operation} failed for all configured relays: closed_count={}, notice_count={}, malformed_count={}", - receipt.closed_count, receipt.notice_count, receipt.malformed_count - ) -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -impl TradeResyncEvidenceReceipt { - async fn from_fetch( - sdk: &crate::RadrootsClient, - query_plan: TradeEvidenceQueryPlan, - receipt: RadrootsRelayFetchReceipt, - ) -> Result<Self, RadrootsSdkError> { - let events = receipt - .events - .into_iter() - .map(Into::into) - .collect::<Vec<_>>(); - let nostr_relay_outcomes = receipt - .relay_outcomes - .into_iter() - .map(Into::into) - .collect::<Vec<_>>(); - let branches = trade_evidence_branch_receipts( - sdk, - &query_plan, - events.as_slice(), - nostr_relay_outcomes.as_slice(), - ) - .await?; - Ok(Self { - query_plan, - inserted_count: receipt.inserted_count, - duplicate_count: receipt.duplicate_count, - malformed_count: receipt.malformed_count, - out_of_filter_count: receipt.out_of_filter_count, - skipped_over_limit_count: receipt.skipped_over_limit_count, - unsupported_count: receipt.unsupported_count, - eose_count: receipt.eose_count, - closed_count: receipt.closed_count, - notice_count: receipt.notice_count, - branches, - events, - nostr_relay_outcomes, - }) - } -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -async fn trade_evidence_branch_receipts( - sdk: &crate::RadrootsClient, - query_plan: &TradeEvidenceQueryPlan, - events: &[TradeResyncEventImportReceipt], - nostr_relay_outcomes: &[TradeResyncNostrRelayOutcomeReceipt], -) -> Result<Vec<TradeEvidenceBranchReceipt>, RadrootsSdkError> { - let mut branch_events = query_plan - .branches - .iter() - .filter(|branch| branch.filter.active) - .map(|branch| (branch.kind, Vec::<TradeResyncEventImportReceipt>::new())) - .collect::<BTreeMap<_, _>>(); - branch_events - .entry(TradeEvidenceQueryBranchKind::RejectedEvidence) - .or_default(); - for event in events { - let branch = trade_evidence_event_branch(sdk, query_plan, event).await?; - branch_events.entry(branch).or_default().push(event.clone()); - } - let nostr_relay_failure_count = nostr_relay_outcomes - .iter() - .filter(|relay| relay.outcome_kind != TradeResyncNostrRelayOutcomeKind::Eose) - .count(); - Ok(branch_events - .into_iter() - .map(|(branch, events)| { - TradeEvidenceBranchReceipt::from_parts( - branch, - events, - nostr_relay_outcomes.to_vec(), - nostr_relay_failure_count, - ) - }) - .collect()) -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -async fn trade_evidence_event_branch( - sdk: &crate::RadrootsClient, - query_plan: &TradeEvidenceQueryPlan, - event: &TradeResyncEventImportReceipt, -) -> Result<TradeEvidenceQueryBranchKind, RadrootsSdkError> { - if event.malformed || event.out_of_filter || event.skipped_over_limit { - return Ok(TradeEvidenceQueryBranchKind::RejectedEvidence); - } - let Some(event_id) = event.event_id.as_deref() else { - return Ok(TradeEvidenceQueryBranchKind::RejectedEvidence); - }; - let Some(stored_event) = sdk - ._event_store - .get_event(event_id) - .await - .map_err(|error| RadrootsSdkError::EventStore { - message: error.to_string(), - })? - else { - return Ok(TradeEvidenceQueryBranchKind::RejectedEvidence); - }; - let stored_tags = sdk - ._event_store - .tags_for_event(event_id) - .await - .map_err(|error| RadrootsSdkError::EventStore { - message: error.to_string(), - })?; - Ok(query_plan - .branches - .iter() - .filter(|branch| branch.filter.active) - .find(|branch| trade_evidence_branch_matches_event(branch, &stored_event, &stored_tags)) - .map(|branch| branch.kind) - .unwrap_or(TradeEvidenceQueryBranchKind::RejectedEvidence)) -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -fn trade_evidence_branch_matches_event( - branch: &TradeEvidenceQueryBranch, - stored_event: &RadrootsStoredEvent, - stored_tags: &[RadrootsStoredEventTag], -) -> bool { - if !branch.filter.event_kinds.contains(&stored_event.kind) { - return false; - } - if let Some(author_pubkey) = branch.filter.author_pubkey.as_ref() - && stored_event.pubkey != *author_pubkey - { - return false; - } - match branch.filter.tag.as_ref() { - Some(tag) => stored_tags.iter().any(|stored_tag| { - stored_tag.tag_name == tag.tag_name - && stored_tag - .tag_value - .as_ref() - .is_some_and(|value| tag.values.iter().any(|expected| expected == value)) - }), - None => true, - } -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -impl TradeEvidenceBranchReceipt { - fn from_parts( - branch: TradeEvidenceQueryBranchKind, - events: Vec<TradeResyncEventImportReceipt>, - nostr_relay_outcomes: Vec<TradeResyncNostrRelayOutcomeReceipt>, - nostr_relay_failure_count: usize, - ) -> Self { - let inserted_count = events.iter().filter(|event| event.inserted).count(); - let duplicate_count = events.iter().filter(|event| event.duplicate).count(); - let malformed_count = events.iter().filter(|event| event.malformed).count(); - let out_of_filter_count = events.iter().filter(|event| event.out_of_filter).count(); - let skipped_over_limit_count = events - .iter() - .filter(|event| event.skipped_over_limit) - .count(); - let unsupported_count = events.iter().filter(|event| event.unsupported).count(); - let accepted_count = events - .iter() - .filter(|event| { - !event.malformed - && !event.out_of_filter - && !event.skipped_over_limit - && !event.unsupported - && (event.inserted || event.duplicate) - }) - .count(); - let empty_result = accepted_count == 0 - && duplicate_count == 0 - && malformed_count == 0 - && out_of_filter_count == 0 - && skipped_over_limit_count == 0 - && unsupported_count == 0; - Self { - branch, - accepted_count, - inserted_count, - duplicate_count, - malformed_count, - out_of_filter_count, - skipped_over_limit_count, - unsupported_count, - nostr_relay_failure_count, - empty_result, - events, - nostr_relay_outcomes, - } - } -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -impl From<RadrootsRelayFetchEventReceipt> for TradeResyncEventImportReceipt { - fn from(receipt: RadrootsRelayFetchEventReceipt) -> Self { - Self { - nostr_relay_url: receipt.relay_url, - event_id: receipt.event_id, - inserted: receipt.inserted, - duplicate: receipt.duplicate, - unsupported: receipt.unsupported, - malformed: receipt.malformed, - out_of_filter: receipt.out_of_filter, - skipped_over_limit: receipt.skipped_over_limit, - projection_eligible: receipt.projection_eligible, - verification_status: receipt.verification_status, - message: receipt.message, - } - } -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -impl From<RadrootsRelayFetchRelayOutcome> for TradeResyncNostrRelayOutcomeReceipt { - fn from(receipt: RadrootsRelayFetchRelayOutcome) -> Self { - Self { - nostr_relay_url: receipt.relay_url, - outcome_kind: receipt.kind.into(), - transport_outcome_kind: receipt.relay_outcome.map(|outcome| outcome.kind.into()), - message: receipt.message, - } - } -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -impl From<RadrootsRelayFetchOutcomeKind> for TradeResyncNostrRelayOutcomeKind { - fn from(kind: RadrootsRelayFetchOutcomeKind) -> Self { - match kind { - RadrootsRelayFetchOutcomeKind::Eose => Self::Eose, - RadrootsRelayFetchOutcomeKind::Closed => Self::Closed, - RadrootsRelayFetchOutcomeKind::Notice => Self::Notice, - } - } -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -impl From<RadrootsRelayOutcomeKind> for TradeResyncNostrRelayTransportOutcomeKind { - fn from(kind: RadrootsRelayOutcomeKind) -> Self { - match kind { - RadrootsRelayOutcomeKind::Accepted => Self::Accepted, - RadrootsRelayOutcomeKind::DuplicateAccepted => Self::DuplicateAccepted, - RadrootsRelayOutcomeKind::Blocked => Self::Blocked, - RadrootsRelayOutcomeKind::RateLimited => Self::RateLimited, - RadrootsRelayOutcomeKind::Invalid => Self::Invalid, - RadrootsRelayOutcomeKind::PowRequired => Self::PowRequired, - RadrootsRelayOutcomeKind::Restricted => Self::Restricted, - RadrootsRelayOutcomeKind::AuthRequired => Self::AuthRequired, - RadrootsRelayOutcomeKind::Muted => Self::Muted, - RadrootsRelayOutcomeKind::Unsupported => Self::Unsupported, - RadrootsRelayOutcomeKind::PaymentRequired => Self::PaymentRequired, - RadrootsRelayOutcomeKind::Error => Self::Error, - RadrootsRelayOutcomeKind::Timeout => Self::Timeout, - RadrootsRelayOutcomeKind::ConnectionFailed => Self::ConnectionFailed, - RadrootsRelayOutcomeKind::RelayUrlRejected => Self::RelayUrlRejected, - RadrootsRelayOutcomeKind::SkippedAlreadyAccepted => Self::SkippedAlreadyAccepted, - RadrootsRelayOutcomeKind::Unknown => Self::Unknown, - } - } -} - -#[cfg(feature = "runtime")] -impl<'sdk> TradeValidationReceiptsClient<'sdk> { - pub async fn list( - &self, - request: TradeValidationReceiptListRequest, - ) -> Result<TradeValidationReceiptListReceipt, RadrootsSdkError> { - #[cfg(feature = "transport-nostr-runtime")] - { - let adapter = RadrootsNostrClientFetchAdapter; - return self.list_with_fetch_adapter(request, &adapter).await; - } - #[cfg(not(feature = "transport-nostr-runtime"))] - { - let _ = self.sdk; - let _ = request; - Err(RadrootsSdkError::ProductSyncUnsupported { - operation: "trade.validation_receipts.list", - required_feature: "transport-nostr-runtime", - }) - } - } - - #[cfg(feature = "transport-nostr-runtime")] - pub async fn list_with_fetch_adapter<A>( - &self, - request: TradeValidationReceiptListRequest, - adapter: &A, - ) -> Result<TradeValidationReceiptListReceipt, RadrootsSdkError> - where - A: RadrootsRelayFetchAdapter, - { - request.validate()?; - let nostr_relay_urls = - validation_receipt_nostr_relay_urls(self.sdk, "trade.validation_receipts.list")?; - let fetch_request = - validation_receipt_list_fetch_request(self.sdk, &request, &nostr_relay_urls)?; - let fetch_receipt = - fetch_and_ingest_relay_events(adapter, &self.sdk._event_store, fetch_request).await?; - let nostr_evidence = TradeValidationReceiptNostrEvidenceReceipt::from_fetch(fetch_receipt); - let events = validation_receipt_events_from_fetch( - self.sdk, - &nostr_evidence.events, - KIND_TRADE_VALIDATION_RECEIPT, - ) - .await?; - let (mut receipts, mut invalid_receipts) = - classify_validation_receipts(events, Some(request.order_id.as_str()))?; - receipts.sort_by(validation_receipt_event_order); - invalid_receipts.sort_by(validation_receipt_invalid_order); - Ok(TradeValidationReceiptListReceipt { - nostr_relay_urls, - nostr_evidence, - order_id: request.order_id, - receipts, - invalid_receipts, - }) - } - - pub async fn inspect( - &self, - request: TradeValidationReceiptInspectRequest, - ) -> Result<TradeValidationReceiptInspectReceipt, RadrootsSdkError> { - #[cfg(feature = "transport-nostr-runtime")] - { - let adapter = RadrootsNostrClientFetchAdapter; - return self.inspect_with_fetch_adapter(request, &adapter).await; - } - #[cfg(not(feature = "transport-nostr-runtime"))] - { - let _ = self.sdk; - let _ = request; - Err(RadrootsSdkError::ProductSyncUnsupported { - operation: "trade.validation_receipts.inspect", - required_feature: "transport-nostr-runtime", - }) - } - } - - #[cfg(feature = "transport-nostr-runtime")] - pub async fn inspect_with_fetch_adapter<A>( - &self, - request: TradeValidationReceiptInspectRequest, - adapter: &A, - ) -> Result<TradeValidationReceiptInspectReceipt, RadrootsSdkError> - where - A: RadrootsRelayFetchAdapter, - { - validation_receipt_inspect( - self.sdk, - request.receipt_event_id, - adapter, - "trade.validation_receipts.inspect", - ) - .await - } - - pub async fn verify( - &self, - request: TradeValidationReceiptVerifyRequest, - ) -> Result<TradeValidationReceiptInspectReceipt, RadrootsSdkError> { - #[cfg(feature = "transport-nostr-runtime")] - { - let adapter = RadrootsNostrClientFetchAdapter; - return self.verify_with_fetch_adapter(request, &adapter).await; - } - #[cfg(not(feature = "transport-nostr-runtime"))] - { - let _ = self.sdk; - let _ = request; - Err(RadrootsSdkError::ProductSyncUnsupported { - operation: "trade.validation_receipts.verify", - required_feature: "transport-nostr-runtime", - }) - } - } - - #[cfg(feature = "transport-nostr-runtime")] - pub async fn verify_with_fetch_adapter<A>( - &self, - request: TradeValidationReceiptVerifyRequest, - adapter: &A, - ) -> Result<TradeValidationReceiptInspectReceipt, RadrootsSdkError> - where - A: RadrootsRelayFetchAdapter, - { - validation_receipt_inspect( - self.sdk, - request.receipt_event_id, - adapter, - "trade.validation_receipts.verify", - ) - .await - } -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -async fn validation_receipt_inspect<A>( - sdk: &crate::RadrootsClient, - receipt_event_id: RadrootsEventId, - adapter: &A, - operation: &'static str, -) -> Result<TradeValidationReceiptInspectReceipt, RadrootsSdkError> -where - A: RadrootsRelayFetchAdapter, -{ - let nostr_relay_urls = validation_receipt_nostr_relay_urls(sdk, operation)?; - let fetch_request = - validation_receipt_inspect_fetch_request(sdk, &receipt_event_id, &nostr_relay_urls)?; - let fetch_receipt = - fetch_and_ingest_relay_events(adapter, &sdk._event_store, fetch_request).await?; - let nostr_evidence = TradeValidationReceiptNostrEvidenceReceipt::from_fetch(fetch_receipt); - let events = validation_receipt_events_from_fetch( - sdk, - &nostr_evidence.events, - KIND_TRADE_VALIDATION_RECEIPT, - ) - .await?; - let (mut receipts, mut invalid_receipts) = classify_validation_receipts(events, None)?; - receipts.retain(|receipt| receipt.event.id_str() == receipt_event_id.as_str()); - invalid_receipts.retain(|receipt| receipt.event.id_str() == receipt_event_id.as_str()); - receipts.sort_by(validation_receipt_event_order); - invalid_receipts.sort_by(validation_receipt_invalid_order); - Ok(TradeValidationReceiptInspectReceipt { - nostr_relay_urls, - nostr_evidence, - receipt_event_id, - receipt: receipts.into_iter().next(), - invalid_receipt: invalid_receipts.into_iter().next(), - }) -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -fn validation_receipt_nostr_relay_urls( - sdk: &crate::RadrootsClient, - operation: impl Into<String>, -) -> Result<Vec<String>, RadrootsSdkError> { - let nostr_relay_urls = sdk.configured_nostr_relay_urls().to_vec(); - if nostr_relay_urls.is_empty() { - return Err(RadrootsSdkError::empty_transport_targets(operation)); - } - Ok(nostr_relay_urls) -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -fn validation_receipt_list_fetch_request( - sdk: &crate::RadrootsClient, - request: &TradeValidationReceiptListRequest, - nostr_relay_urls: &[String], -) -> Result<RadrootsRelayFetchRequest, RadrootsSdkError> { - let filter = RadrootsNostrFilter::new() - .kind(RadrootsNostrKind::Custom( - KIND_TRADE_VALIDATION_RECEIPT as u16, - )) - .limit(request.limit as usize); - let filter = - radroots_nostr_filter_tag(filter, TAG_D, vec![request.order_id.as_str().to_owned()]) - .map_err(|error| RadrootsSdkError::InvalidRequest { - message: format!("validation receipt list filter invalid: {error}"), - })?; - Ok( - RadrootsRelayFetchRequest::fetch(sdk_now_ms(sdk)?, request.limit as usize, [filter])? - .with_relay_urls(nostr_relay_urls.iter().cloned()), - ) -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -fn validation_receipt_inspect_fetch_request( - sdk: &crate::RadrootsClient, - receipt_event_id: &RadrootsEventId, - nostr_relay_urls: &[String], -) -> Result<RadrootsRelayFetchRequest, RadrootsSdkError> { - let nostr_event_id = - RadrootsNostrEventId::parse(receipt_event_id.as_str()).map_err(|error| { - RadrootsSdkError::InvalidRequest { - message: format!( - "validation receipt event id `{}` cannot build relay filter: {error}", - receipt_event_id.as_str() - ), - } - })?; - let filter = RadrootsNostrFilter::new().id(nostr_event_id); - Ok( - RadrootsRelayFetchRequest::fetch(sdk_now_ms(sdk)?, 1, [filter])? - .with_relay_urls(nostr_relay_urls.iter().cloned()), - ) -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -async fn validation_receipt_events_from_fetch( - sdk: &crate::RadrootsClient, - events: &[TradeResyncEventImportReceipt], - expected_kind: u32, -) -> Result<Vec<RadrootsEventEnvelope>, RadrootsSdkError> { - let mut event_ids = events - .iter() - .filter(|event| !event.malformed) - .filter(|event| !event.out_of_filter) - .filter_map(|event| event.event_id.as_deref()) - .collect::<BTreeSet<_>>() - .into_iter() - .collect::<Vec<_>>(); - event_ids.sort(); - let mut fetched = Vec::new(); - for event_id in event_ids { - let Some(stored_event) = sdk - ._event_store - .get_event(event_id) - .await - .map_err(|error| RadrootsSdkError::EventStore { - message: error.to_string(), - })? - else { - continue; - }; - if stored_event.kind == expected_kind { - fetched.push(stored_event_to_nostr_event(&stored_event)?); - } - } - Ok(fetched) -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -fn classify_validation_receipts( - events: Vec<RadrootsEventEnvelope>, - expected_order_id: Option<&str>, -) -> Result< - ( - Vec<TradeValidationReceiptEvent>, - Vec<TradeValidationReceiptInvalidCandidate>, - ), - RadrootsSdkError, -> { - let mut receipts = Vec::new(); - let mut invalid_receipts = Vec::new(); - for event in events { - let expected = RadrootsValidationReceiptExpectedBinding { - order_id: expected_order_id, - ..RadrootsValidationReceiptExpectedBinding::default() - }; - match verify_validation_receipt_event(&event, expected) { - Ok(verified) => receipts.push(TradeValidationReceiptEvent { - event, - receipt: verified.receipt, - tags: TradeValidationReceiptTags::from(verified.tags), - }), - Err(error) => invalid_receipts.push(TradeValidationReceiptInvalidCandidate { - event, - reason_code: validation_receipt_invalid_reason_code(&error).to_owned(), - reason: error.to_string(), - }), - } - } - Ok((receipts, invalid_receipts)) -} - -#[cfg(feature = "runtime")] -async fn apply_trade_status_validation_trust( - sdk: &crate::RadrootsClient, - status: &mut TradeStatusReceipt, - policy: &RadrootsTradeValidationTrustPolicy, -) -> Result<(), RadrootsSdkError> { - if !status.found - || matches!( - status.status, - TradeStatusKind::Missing | TradeStatusKind::Ambiguous - ) - { - return Ok(()); - } - let decision = trade_status_validation_trust_decision(sdk, status, policy).await?; - status.validation_trust = Some(decision); - apply_validation_trust_decision_to_status(status); - Ok(()) -} - -#[cfg(feature = "runtime")] -async fn trade_status_validation_trust_decision( - sdk: &crate::RadrootsClient, - status: &TradeStatusReceipt, - policy: &RadrootsTradeValidationTrustPolicy, -) -> Result<TradeValidationTrustDecision, RadrootsSdkError> { - let Some(receipt_event_id) = status.rhi_receipt_event_id.clone() else { - return Ok(trade_validation_trust_decision( - policy, - RadrootsTradeValidationTrustState::Pending, - TradeValidationTrustEvidence::none(), - false, - Some(TradeValidationTrustReason::new( - "validation_receipt_missing", - "validation receipt is not present in local product evidence", - )), - )); - }; - let Some(stored_event) = sdk - ._event_store - .get_event(receipt_event_id.as_str()) - .await - .map_err(|error| RadrootsSdkError::EventStore { - message: error.to_string(), - })? - else { - return Ok(trade_validation_trust_decision( - policy, - RadrootsTradeValidationTrustState::Pending, - TradeValidationTrustEvidence::receipt_event(receipt_event_id), - false, - Some(TradeValidationTrustReason::new( - "validation_receipt_event_missing", - "validation receipt event is referenced by projection but missing from storage", - )), - )); - }; - if stored_event.kind != KIND_TRADE_VALIDATION_RECEIPT { - return Ok(trade_validation_trust_decision( - policy, - RadrootsTradeValidationTrustState::Invalid, - TradeValidationTrustEvidence::receipt_event(receipt_event_id), - false, - Some(TradeValidationTrustReason::new( - "validation_receipt_event_kind_invalid", - "validation receipt reference does not point to a validation receipt event", - )), - )); - } - let event = stored_event_to_nostr_event(&stored_event)?; - let receipt_author = match RadrootsPublicKey::parse(event.author_str()) { - Ok(author) => author, - Err(_) => { - return Ok(trade_validation_trust_decision( - policy, - RadrootsTradeValidationTrustState::Invalid, - TradeValidationTrustEvidence::receipt_event(receipt_event_id), - false, - Some(TradeValidationTrustReason::new( - "validation_receipt_author_invalid", - "validation receipt author is not a valid public key", - )), - )); - } - }; - let expected = RadrootsValidationReceiptExpectedBinding { - order_id: Some(status.order_id.as_str()), - root_event_id: status - .request_event_id - .as_ref() - .map(RadrootsEventId::as_str), - target_event_id: status - .agreement_event_id - .as_ref() - .or(status.decision_event_id.as_ref()) - .map(RadrootsEventId::as_str), - validator_set_addr: policy.validator_set_addr.as_ref().map(|addr| addr.as_str()), - validator_set_event_id: policy.validator_set_event_id.as_deref(), - ..RadrootsValidationReceiptExpectedBinding::default() - }; - let verified = match verify_validation_receipt_event(&event, expected) { - Ok(verified) => verified, - Err(error) => { - let reason = error.to_string(); - return Ok(trade_validation_trust_decision( - policy, - RadrootsTradeValidationTrustState::Invalid, - TradeValidationTrustEvidence::receipt_author(receipt_event_id, receipt_author), - false, - Some(TradeValidationTrustReason::new( - "validation_receipt_invalid", - reason.as_str(), - )), - )); - } - }; - let receipt = TradeValidationReceiptEvent { - event, - receipt: verified.receipt, - tags: TradeValidationReceiptTags::from(verified.tags), - }; - if receipt.receipt.result == RadrootsValidationReceiptResult::Invalid { - return Ok(trade_validation_trust_decision( - policy, - RadrootsTradeValidationTrustState::Invalid, - TradeValidationTrustEvidence::receipt( - receipt_event_id, - receipt_author, - &receipt.receipt, - None, - ), - false, - Some(TradeValidationTrustReason::new( - "validation_receipt_result_invalid", - "validation receipt reports an invalid trade transition", - )), - )); - } - let Some(validator_set) = policy.validator_set.as_ref() else { - return Ok(trade_validation_trust_decision( - policy, - RadrootsTradeValidationTrustState::Untrusted, - TradeValidationTrustEvidence::receipt( - receipt_event_id, - receipt_author, - &receipt.receipt, - None, - ), - false, - Some(TradeValidationTrustReason::new( - "validator_set_required", - "validation trust policy has no configured validator set", - )), - )); - }; - let Some(validator_set_addr) = policy.validator_set_addr.as_ref() else { - return Ok(trade_validation_trust_decision( - policy, - RadrootsTradeValidationTrustState::Untrusted, - TradeValidationTrustEvidence::receipt( - receipt_event_id, - receipt_author, - &receipt.receipt, - None, - ), - false, - Some(TradeValidationTrustReason::new( - "validator_set_address_required", - "validation trust policy has no validator set address", - )), - )); - }; - let Some(validator_set_event_id) = policy.validator_set_event_id.as_ref() else { - return Ok(trade_validation_trust_decision( - policy, - RadrootsTradeValidationTrustState::Untrusted, - TradeValidationTrustEvidence::receipt( - receipt_event_id, - receipt_author, - &receipt.receipt, - None, - ), - false, - Some(TradeValidationTrustReason::new( - "validator_set_event_id_required", - "validation trust policy has no validator set event id", - )), - )); - }; - if receipt.receipt.statement.validator_set_addr != *validator_set_addr - || receipt.receipt.statement.validator_set_event_id != *validator_set_event_id - { - return Ok(trade_validation_trust_decision( - policy, - RadrootsTradeValidationTrustState::Untrusted, - TradeValidationTrustEvidence::receipt( - receipt_event_id, - receipt_author, - &receipt.receipt, - None, - ), - false, - Some(TradeValidationTrustReason::new( - "validator_set_binding_mismatch", - "validation receipt does not bind the configured validator set", - )), - )); - } - if !policy.trusts_validator_pubkey(&receipt_author) { - return Ok(trade_validation_trust_decision( - policy, - RadrootsTradeValidationTrustState::Untrusted, - TradeValidationTrustEvidence::receipt( - receipt_event_id, - receipt_author, - &receipt.receipt, - None, - ), - false, - Some(TradeValidationTrustReason::new( - "validator_set_author_untrusted", - "validation receipt author is not the configured validator set key", - )), - )); - } - let receipt_created_at = receipt.event.created_at_u64(); - if receipt_created_at < validator_set.valid_from - || receipt_created_at > validator_set.valid_until - { - return Ok(trade_validation_trust_decision( - policy, - RadrootsTradeValidationTrustState::Untrusted, - TradeValidationTrustEvidence::receipt( - receipt_event_id, - receipt_author, - &receipt.receipt, - None, - ), - false, - Some(TradeValidationTrustReason::new( - "validator_set_not_valid_at_receipt_time", - "validation receipt was authored outside the configured validator set validity window", - )), - )); - } - Ok(evaluate_trade_validation_trust_receipt( - policy, - receipt_event_id, - receipt_author, - &receipt.receipt, - )) -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Copy)] -struct TradeValidationTrustMetadata { - validation_authority: RadrootsTradeValidationAuthority, - commitment_confidence: RadrootsTradeCommitmentConfidence, - cryptographic_proof_verified: bool, -} - -#[cfg(feature = "runtime")] -struct TradeValidationTrustEvidence<'a> { - receipt_event_id: Option<RadrootsEventId>, - receipt_author: Option<RadrootsPublicKey>, - receipt: Option<&'a RadrootsTradeValidationReceipt>, - metadata: Option<TradeValidationTrustMetadata>, -} - -#[cfg(feature = "runtime")] -impl<'a> TradeValidationTrustEvidence<'a> { - fn none() -> Self { - Self { - receipt_event_id: None, - receipt_author: None, - receipt: None, - metadata: None, - } - } - - fn receipt_event(receipt_event_id: RadrootsEventId) -> Self { - Self { - receipt_event_id: Some(receipt_event_id), - receipt_author: None, - receipt: None, - metadata: None, - } - } - - fn receipt_author( - receipt_event_id: RadrootsEventId, - receipt_author: RadrootsPublicKey, - ) -> Self { - Self { - receipt_event_id: Some(receipt_event_id), - receipt_author: Some(receipt_author), - receipt: None, - metadata: None, - } - } - - fn receipt( - receipt_event_id: RadrootsEventId, - receipt_author: RadrootsPublicKey, - receipt: &'a RadrootsTradeValidationReceipt, - metadata: Option<TradeValidationTrustMetadata>, - ) -> Self { - Self { - receipt_event_id: Some(receipt_event_id), - receipt_author: Some(receipt_author), - receipt: Some(receipt), - metadata, - } - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Copy)] -struct TradeValidationTrustReason<'a> { - code: &'a str, - message: &'a str, -} - -#[cfg(feature = "runtime")] -impl<'a> TradeValidationTrustReason<'a> { - fn new(code: &'a str, message: &'a str) -> Self { - Self { code, message } - } -} - -#[cfg(feature = "runtime")] -fn evaluate_trade_validation_trust_receipt( - policy: &RadrootsTradeValidationTrustPolicy, - receipt_event_id: RadrootsEventId, - receipt_author: RadrootsPublicKey, - receipt: &RadrootsTradeValidationReceipt, -) -> TradeValidationTrustDecision { - let metadata = validation_receipt_trust_metadata(receipt); - let authority = metadata.validation_authority; - let confidence = metadata.commitment_confidence; - if authority == RadrootsTradeValidationAuthority::ValidatorSetDeterministic - || confidence == RadrootsTradeCommitmentConfidence::CommittedByValidatorSet - { - if policy.require_cryptographic_proof { - return trade_validation_trust_decision( - policy, - RadrootsTradeValidationTrustState::Pending, - TradeValidationTrustEvidence::receipt( - receipt_event_id, - receipt_author, - receipt, - Some(metadata), - ), - false, - Some(TradeValidationTrustReason::new( - "cryptographic_proof_required", - "active policy requires cryptographic proof for committed confidence", - )), - ); - } - return trade_validation_trust_decision( - policy, - RadrootsTradeValidationTrustState::ValidatorSetCommitted, - TradeValidationTrustEvidence::receipt( - receipt_event_id, - receipt_author, - receipt, - Some(metadata), - ), - true, - None, - ); - } - let cryptographic_metadata = matches!( - (authority, confidence), - ( - RadrootsTradeValidationAuthority::CryptographicProofVerified, - RadrootsTradeCommitmentConfidence::CommittedByCryptographicProof - ) | ( - RadrootsTradeValidationAuthority::ValidatorSetAndProofVerified, - RadrootsTradeCommitmentConfidence::CommittedByValidatorSetAndProof - ) - ); - if cryptographic_metadata - && receipt.proof.system != RadrootsValidationReceiptProofSystem::None - && metadata.cryptographic_proof_verified - { - return trade_validation_trust_decision( - policy, - RadrootsTradeValidationTrustState::CryptographicCommitted, - TradeValidationTrustEvidence::receipt( - receipt_event_id, - receipt_author, - receipt, - Some(metadata), - ), - true, - None, - ); - } - if policy.require_cryptographic_proof { - return trade_validation_trust_decision( - policy, - RadrootsTradeValidationTrustState::Pending, - TradeValidationTrustEvidence::receipt( - receipt_event_id, - receipt_author, - receipt, - Some(metadata), - ), - false, - Some(TradeValidationTrustReason::new( - "cryptographic_proof_required", - "active policy requires trusted cryptographic proof metadata", - )), - ); - } - if matches!( - (authority, confidence), - ( - RadrootsTradeValidationAuthority::ValidatorSetAndProofVerified, - RadrootsTradeCommitmentConfidence::CommittedByValidatorSetAndProof - ) | ( - RadrootsTradeValidationAuthority::CryptographicProofVerified, - RadrootsTradeCommitmentConfidence::CommittedByCryptographicProof - ) - ) { - return trade_validation_trust_decision( - policy, - RadrootsTradeValidationTrustState::ValidatorSetCommitted, - TradeValidationTrustEvidence::receipt( - receipt_event_id, - receipt_author, - receipt, - Some(metadata), - ), - false, - None, - ); - } - trade_validation_trust_decision( - policy, - RadrootsTradeValidationTrustState::Pending, - TradeValidationTrustEvidence::receipt( - receipt_event_id, - receipt_author, - receipt, - Some(metadata), - ), - false, - Some(TradeValidationTrustReason::new( - "validation_trust_metadata_insufficient", - "trusted validation result metadata does not satisfy the active policy", - )), - ) -} - -#[cfg(feature = "runtime")] -fn validation_receipt_trust_metadata( - receipt: &RadrootsTradeValidationReceipt, -) -> TradeValidationTrustMetadata { - if receipt.proof.system == RadrootsValidationReceiptProofSystem::None { - TradeValidationTrustMetadata { - validation_authority: RadrootsTradeValidationAuthority::ValidatorSetDeterministic, - commitment_confidence: RadrootsTradeCommitmentConfidence::CommittedByValidatorSet, - cryptographic_proof_verified: false, - } - } else { - TradeValidationTrustMetadata { - validation_authority: RadrootsTradeValidationAuthority::ValidatorSetAndProofVerified, - commitment_confidence: - RadrootsTradeCommitmentConfidence::CommittedByValidatorSetAndProof, - cryptographic_proof_verified: true, - } - } -} - -#[cfg(feature = "runtime")] -fn trade_validation_trust_decision( - policy: &RadrootsTradeValidationTrustPolicy, - state: RadrootsTradeValidationTrustState, - evidence: TradeValidationTrustEvidence<'_>, - production_committed: bool, - reason: Option<TradeValidationTrustReason<'_>>, -) -> TradeValidationTrustDecision { - let (reason_code, reason) = match reason { - Some(reason) => ( - Some(reason.code.to_owned()), - Some(reason.message.to_owned()), - ), - None => (None, None), - }; - TradeValidationTrustDecision { - state, - validator_count: policy.validator_count(), - validator_set_addr: policy - .validator_set_addr - .as_ref() - .map(|addr| addr.as_str().to_owned()), - validator_set_event_id: policy.validator_set_event_id.clone(), - require_cryptographic_proof: policy.require_cryptographic_proof, - receipt_event_id: evidence.receipt_event_id, - receipt_author: evidence.receipt_author, - result_event_id: None, - result_author: None, - proof_system: evidence - .receipt - .map(|receipt| receipt.proof.system.as_str().to_owned()), - validation_authority: evidence - .metadata - .map(|metadata| metadata.validation_authority), - commitment_confidence: evidence - .metadata - .map(|metadata| metadata.commitment_confidence), - cryptographic_proof_required: policy.require_cryptographic_proof, - cryptographic_proof_verified: evidence - .metadata - .is_some_and(|metadata| metadata.cryptographic_proof_verified), - production_committed, - reason_code, - reason, - } -} - -#[cfg(feature = "runtime")] -fn apply_validation_trust_decision_to_status(status: &mut TradeStatusReceipt) { - let Some(decision) = status.validation_trust.as_ref() else { - return; - }; - match decision.state { - RadrootsTradeValidationTrustState::Pending - | RadrootsTradeValidationTrustState::Untrusted => { - if status.status == TradeStatusKind::Committed { - status.status = TradeStatusKind::AgreedPendingValidation; - status.lifecycle_terminal = false; - status.next_action = TradeStatusNextActionKind::AwaitValidation; - status.last_event_id = status - .agreement_event_id - .clone() - .or_else(|| status.decision_event_id.clone()) - .or_else(|| status.request_event_id.clone()); - } - } - RadrootsTradeValidationTrustState::Invalid => { - status.status = TradeStatusKind::Invalid; - status.lifecycle_terminal = true; - status.next_action = TradeStatusNextActionKind::InspectEvidenceIssues; - } - RadrootsTradeValidationTrustState::ValidatorSetCommitted - | RadrootsTradeValidationTrustState::CryptographicCommitted => {} - } -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -impl TradeValidationReceiptNostrEvidenceReceipt { - fn from_fetch(receipt: RadrootsRelayFetchReceipt) -> Self { - Self { - inserted_count: receipt.inserted_count, - duplicate_count: receipt.duplicate_count, - malformed_count: receipt.malformed_count, - out_of_filter_count: receipt.out_of_filter_count, - skipped_over_limit_count: receipt.skipped_over_limit_count, - unsupported_count: receipt.unsupported_count, - eose_count: receipt.eose_count, - closed_count: receipt.closed_count, - notice_count: receipt.notice_count, - events: receipt.events.into_iter().map(Into::into).collect(), - nostr_relay_outcomes: receipt.relay_outcomes.into_iter().map(Into::into).collect(), - } - } -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -impl From<RadrootsRelayFetchRelayOutcome> for TradeValidationReceiptNostrRelayOutcomeReceipt { - fn from(receipt: RadrootsRelayFetchRelayOutcome) -> Self { - Self { - nostr_relay_url: receipt.relay_url, - outcome_kind: receipt.kind.into(), - transport_outcome_kind: receipt.relay_outcome.map(|outcome| outcome.kind.into()), - message: receipt.message, - } - } -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -impl From<RadrootsRelayFetchOutcomeKind> for TradeValidationReceiptNostrRelayOutcomeKind { - fn from(kind: RadrootsRelayFetchOutcomeKind) -> Self { - match kind { - RadrootsRelayFetchOutcomeKind::Eose => Self::Eose, - RadrootsRelayFetchOutcomeKind::Closed => Self::Closed, - RadrootsRelayFetchOutcomeKind::Notice => Self::Notice, - } - } -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -impl From<RadrootsRelayOutcomeKind> for TradeValidationReceiptNostrRelayTransportOutcomeKind { - fn from(kind: RadrootsRelayOutcomeKind) -> Self { - match kind { - RadrootsRelayOutcomeKind::Accepted => Self::Accepted, - RadrootsRelayOutcomeKind::DuplicateAccepted => Self::DuplicateAccepted, - RadrootsRelayOutcomeKind::Blocked => Self::Blocked, - RadrootsRelayOutcomeKind::RateLimited => Self::RateLimited, - RadrootsRelayOutcomeKind::Invalid => Self::Invalid, - RadrootsRelayOutcomeKind::PowRequired => Self::PowRequired, - RadrootsRelayOutcomeKind::Restricted => Self::Restricted, - RadrootsRelayOutcomeKind::AuthRequired => Self::AuthRequired, - RadrootsRelayOutcomeKind::Muted => Self::Muted, - RadrootsRelayOutcomeKind::Unsupported => Self::Unsupported, - RadrootsRelayOutcomeKind::PaymentRequired => Self::PaymentRequired, - RadrootsRelayOutcomeKind::Error => Self::Error, - RadrootsRelayOutcomeKind::Timeout => Self::Timeout, - RadrootsRelayOutcomeKind::ConnectionFailed => Self::ConnectionFailed, - RadrootsRelayOutcomeKind::RelayUrlRejected => Self::RelayUrlRejected, - RadrootsRelayOutcomeKind::SkippedAlreadyAccepted => Self::SkippedAlreadyAccepted, - RadrootsRelayOutcomeKind::Unknown => Self::Unknown, - } - } -} - -#[cfg(feature = "runtime")] -impl From<RadrootsValidationReceiptTags> for TradeValidationReceiptTags { - fn from(tags: RadrootsValidationReceiptTags) -> Self { - Self { - order_id: tags.order_id, - event_set_root: tags.event_set_root, - listing_event_id: tags.listing_event_id, - reducer_output_root: tags.reducer_output_root, - public_values_hash: tags.public_values_hash, - proof_system: tags.proof_system.as_str().to_owned(), - receipt_type: tags.receipt_type.as_str().to_owned(), - root_event_id: tags.root_event_id, - target_event_id: tags.target_event_id, - validator_set_addr: tags.validator_set_addr.as_str().to_owned(), - validator_set_event_id: tags.validator_set_event_id, - } - } -} - -#[cfg(feature = "runtime")] -fn stored_event_to_nostr_event( - stored_event: &RadrootsStoredEvent, -) -> Result<RadrootsEventEnvelope, RadrootsSdkError> { - let tags = serde_json::from_str(&stored_event.tags_json).map_err(|error| { - RadrootsSdkError::EventStore { - message: format!( - "stored event {} contains invalid tags_json: {error}", - stored_event.event_id - ), - } - })?; - RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { - id: stored_event.event_id.clone(), - author: stored_event.pubkey.clone(), - created_at: stored_event.created_at, - kind: stored_event.kind, - tags, - content: stored_event.content.clone(), - sig: stored_event.sig.clone(), - }) - .map_err(|error| RadrootsSdkError::EventStore { - message: format!( - "stored event {} contains invalid envelope: {error}", - stored_event.event_id - ), - }) -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -fn validation_receipt_event_order( - left: &TradeValidationReceiptEvent, - right: &TradeValidationReceiptEvent, -) -> core::cmp::Ordering { - left.event - .created_at() - .cmp(&right.event.created_at()) - .then_with(|| left.event.id().cmp(right.event.id())) -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -fn validation_receipt_invalid_order( - left: &TradeValidationReceiptInvalidCandidate, - right: &TradeValidationReceiptInvalidCandidate, -) -> core::cmp::Ordering { - left.event - .created_at() - .cmp(&right.event.created_at()) - .then_with(|| left.event.id().cmp(right.event.id())) -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -fn validate_validation_receipt_limit(limit: u32) -> Result<(), RadrootsSdkError> { - if limit == 0 || limit > TRADE_STATUS_MAX_LIMIT { - return Err(RadrootsSdkError::trade_status_limit_invalid( - limit, - 1, - TRADE_STATUS_MAX_LIMIT, - )); - } - Ok(()) -} - -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -fn validation_receipt_invalid_reason_code(error: &RadrootsValidationReceiptError) -> &'static str { - match error { - RadrootsValidationReceiptError::InvalidProofMetadata("proof.material") - | RadrootsValidationReceiptError::InvalidProofMetadata("proof.material_missing") => { - "sp1_proof_material_missing" - } - RadrootsValidationReceiptError::InvalidProofMetadata("proof.material_conflict") => { - "sp1_proof_material_conflict" - } - RadrootsValidationReceiptError::InvalidProofMetadata("proof.inline_proof_base64") => { - "sp1_inline_proof_invalid" - } - RadrootsValidationReceiptError::InvalidProofMetadata("proof.proof_reference") => { - "sp1_proof_reference_invalid" - } - RadrootsValidationReceiptError::TagMismatch("public_values_hash") - | RadrootsValidationReceiptError::ExpectedBindingMismatch("public_values_hash") => { - "public_values_hash_mismatch" - } - RadrootsValidationReceiptError::ExpectedBindingMismatch("program_hash") => { - "sp1_program_hash_mismatch" - } - RadrootsValidationReceiptError::ExpectedBindingMismatch("verifying_key_hash") => { - "sp1_verifying_key_hash_mismatch" - } - _ => "validation_receipt_invalid", - } -} - -#[cfg(all(feature = "runtime", feature = "signer-adapters"))] -impl<'sdk> TradeBuyerClient<'sdk> { - pub async fn propose_trade( - &self, - request: TradeProposeRequest, - ) -> Result<TradeMutationOutcome<TradeSubmitPlan, TradeSubmitReceipt>, RadrootsSdkError> { - validate_trade_product_publish_policy(request.publish_mode, &request.satisfaction_policy)?; - require_trade_product_privacy_preflight( - "trade.propose", - trade_propose_privacy_fields(&request), - &request.privacy_confirmation, - )?; - let TradeProposeRequest { - actor, - listing_event, - order_id, - listing_addr, - seller_pubkey, - items, - economics, - public_note: _, - target_policy, - publish_mode, - satisfaction_policy, - privacy_confirmation: _, - idempotency_key, - created_at, - } = request; - let order = RadrootsOrderRequest { - order_id, - listing_addr, - buyer_pubkey: actor.pubkey().clone(), - seller_pubkey, - items, - economics, - }; - let client = trades_client(self.sdk); - let plan = client.prepare_submit(TradeSubmitPrepareRequest { - actor: actor.clone(), - listing_event, - order, - created_at, - })?; - if publish_mode == PublishMode::DryRun { - return Ok(TradeMutationOutcome::DryRun { plan }); - } - let receipt = client - .enqueue_prepared_submit( - &actor, - plan, - target_policy, - publish_mode, - satisfaction_policy.clone(), - idempotency_key, - ) - .await?; - trade_product_post_enqueue_outcome( - self.sdk, - publish_mode, - satisfaction_policy, - receipt.outbox_event_id, - receipt, - ) - .await - } - - pub async fn cancel_trade( - &self, - request: TradeCancelRequest, - ) -> Result< - TradeMutationOutcome<TradeCancellationPlan, TradeCancellationReceipt>, - RadrootsSdkError, - > { - validate_trade_product_publish_policy(request.publish_mode, &request.satisfaction_policy)?; - let TradeCancelRequest { - actor, - locator, - reason, - target_policy, - publish_mode, - satisfaction_policy, - evidence_mode, - privacy_confirmation, - idempotency_key, - created_at, - } = request; - let context = - trade_mutation_context(self.sdk, locator, evidence_mode, "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, - }; - require_trade_product_privacy_preflight( - "trade.cancel", - trade_reason_privacy_fields(&cancellation.reason), - &privacy_confirmation, - )?; - let client = trades_client(self.sdk); - let plan = client.prepare_cancellation(TradeCancellationPrepareRequest { - actor: actor.clone(), - root_event: event_ptr(&context.root_event_id), - previous_event: event_ptr(&context.previous_event_id), - cancellation, - created_at, - })?; - if publish_mode == PublishMode::DryRun { - return Ok(TradeMutationOutcome::DryRun { plan }); - } - let receipt = client - .enqueue_prepared_cancellation( - &actor, - plan, - target_policy, - publish_mode, - satisfaction_policy.clone(), - idempotency_key, - ) - .await?; - trade_product_post_enqueue_outcome( - self.sdk, - publish_mode, - satisfaction_policy, - receipt.outbox_event_id, - receipt, - ) - .await - } -} - -#[cfg(feature = "runtime")] -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 = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { - id: event.event_id, - author: event.pubkey, - created_at: event.created_at, - kind: event.kind, - tags, - content: event.content, - sig: event.sig, - }) - .map_err(|error| RadrootsSdkError::Projection { - message: format!("stored trade inbox event envelope is invalid: {error}"), - })?; - if let Ok(RadrootsOrderEventRecord::Request(record)) = - order_event_record_from_event(&nostr_event) - && 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, - }) - } - - #[cfg(feature = "signer-adapters")] - pub async fn accept_trade( - &self, - request: TradeAcceptRequest, - ) -> Result<TradeMutationOutcome<TradeDecisionPlan, TradeDecisionReceipt>, RadrootsSdkError> - { - validate_trade_product_publish_policy(request.publish_mode, &request.satisfaction_policy)?; - let TradeAcceptRequest { - actor, - locator, - inventory_commitments, - target_policy, - publish_mode, - satisfaction_policy, - evidence_mode, - privacy_confirmation, - idempotency_key, - created_at, - } = request; - let context = - trade_mutation_context(self.sdk, locator, evidence_mode, "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, - }, - }; - require_trade_product_privacy_preflight( - "trade.accept", - trade_decision_privacy_fields(&decision), - &privacy_confirmation, - )?; - let client = trades_client(self.sdk); - let plan = client.prepare_decision(TradeDecisionPrepareRequest { - actor: actor.clone(), - request_event: event_ptr(&context.root_event_id), - decision, - created_at, - })?; - if publish_mode == PublishMode::DryRun { - return Ok(TradeMutationOutcome::DryRun { plan }); - } - let receipt = client - .enqueue_prepared_decision( - &actor, - plan, - target_policy, - publish_mode, - satisfaction_policy.clone(), - idempotency_key, - ) - .await?; - trade_product_post_enqueue_outcome( - self.sdk, - publish_mode, - satisfaction_policy, - receipt.outbox_event_id, - receipt, - ) - .await - } - - #[cfg(feature = "transport-nostr-runtime")] - pub async fn accept_trade_with_fetch_adapter<A>( - &self, - request: TradeAcceptRequest, - adapter: &A, - ) -> Result<TradeMutationOutcome<TradeDecisionPlan, TradeDecisionReceipt>, RadrootsSdkError> - where - A: RadrootsRelayFetchAdapter, - { - validate_trade_product_publish_policy(request.publish_mode, &request.satisfaction_policy)?; - let TradeAcceptRequest { - actor, - locator, - inventory_commitments, - target_policy, - publish_mode, - satisfaction_policy, - evidence_mode, - privacy_confirmation, - idempotency_key, - created_at, - } = request; - let context = trade_mutation_context_with_fetch_adapter( - self.sdk, - locator, - evidence_mode, - "trade.accept", - adapter, - ) - .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, - }, - }; - require_trade_product_privacy_preflight( - "trade.accept", - trade_decision_privacy_fields(&decision), - &privacy_confirmation, - )?; - let client = trades_client(self.sdk); - let plan = client.prepare_decision(TradeDecisionPrepareRequest { - actor: actor.clone(), - request_event: event_ptr(&context.root_event_id), - decision, - created_at, - })?; - if publish_mode == PublishMode::DryRun { - return Ok(TradeMutationOutcome::DryRun { plan }); - } - let receipt = client - .enqueue_prepared_decision( - &actor, - plan, - target_policy, - publish_mode, - satisfaction_policy.clone(), - idempotency_key, - ) - .await?; - trade_product_post_enqueue_outcome( - self.sdk, - publish_mode, - satisfaction_policy, - receipt.outbox_event_id, - receipt, - ) - .await - } - - #[cfg(feature = "signer-adapters")] - pub async fn decline_trade( - &self, - request: TradeDeclineRequest, - ) -> Result<TradeMutationOutcome<TradeDecisionPlan, TradeDecisionReceipt>, RadrootsSdkError> - { - validate_trade_product_publish_policy(request.publish_mode, &request.satisfaction_policy)?; - let TradeDeclineRequest { - actor, - locator, - reason, - target_policy, - publish_mode, - satisfaction_policy, - evidence_mode, - privacy_confirmation, - idempotency_key, - created_at, - } = request; - let context = - trade_mutation_context(self.sdk, locator, evidence_mode, "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 }, - }; - require_trade_product_privacy_preflight( - "trade.decline", - trade_decision_privacy_fields(&decision), - &privacy_confirmation, - )?; - let client = trades_client(self.sdk); - let plan = client.prepare_decision(TradeDecisionPrepareRequest { - actor: actor.clone(), - request_event: event_ptr(&context.root_event_id), - decision, - created_at, - })?; - if publish_mode == PublishMode::DryRun { - return Ok(TradeMutationOutcome::DryRun { plan }); - } - let receipt = client - .enqueue_prepared_decision( - &actor, - plan, - target_policy, - publish_mode, - satisfaction_policy.clone(), - idempotency_key, - ) - .await?; - trade_product_post_enqueue_outcome( - self.sdk, - publish_mode, - satisfaction_policy, - receipt.outbox_event_id, - receipt, - ) - .await - } -} - -#[cfg(feature = "runtime")] -fn trades_client(sdk: &crate::RadrootsClient) -> TradesClient<'_> { - TradesClient { sdk } -} - -#[cfg(feature = "signer-adapters")] -struct TradeProductMutationContext { - order_id: RadrootsOrderId, - listing_addr: RadrootsListingAddress, - buyer_pubkey: RadrootsPublicKey, - seller_pubkey: RadrootsPublicKey, - root_event_id: RadrootsEventId, - previous_event_id: RadrootsEventId, -} - -#[cfg(feature = "signer-adapters")] -async fn trade_mutation_context( - sdk: &crate::RadrootsClient, - locator: RadrootsTradeLocator, - evidence_mode: TradeEvidenceMode, - operation: &'static str, -) -> Result<TradeProductMutationContext, RadrootsSdkError> { - let status = trade_mutation_status(sdk, locator.clone(), &evidence_mode, operation).await?; - trade_mutation_context_from_status(locator, &evidence_mode, operation, status) -} - -#[cfg(feature = "signer-adapters")] -async fn trade_mutation_status( - sdk: &crate::RadrootsClient, - locator: RadrootsTradeLocator, - evidence_mode: &TradeEvidenceMode, - operation: &'static str, -) -> Result<TradeStatusReceipt, RadrootsSdkError> { - match evidence_mode { - TradeEvidenceMode::LocalOnly => { - trades_client(sdk) - .status( - TradeStatusRequest::new(locator).with_source(SdkTradeStatusSource::LocalOnly), - ) - .await - } - TradeEvidenceMode::RequireExplicitEvidence { evidence } => { - ingest_explicit_trade_mutation_evidence(sdk, operation, evidence).await?; - trades_client(sdk) - .status( - TradeStatusRequest::new(locator).with_source(SdkTradeStatusSource::LocalOnly), - ) - .await - } - TradeEvidenceMode::ResyncBeforeMutation => { - #[cfg(feature = "transport-nostr-runtime")] - { - let adapter = RadrootsNostrClientFetchAdapter; - let status = trades_client(sdk) - .status_with_fetch_adapter( - TradeStatusRequest::new(locator) - .with_source(SdkTradeStatusSource::ResyncThenLocal), - &adapter, - ) - .await?; - require_trade_mutation_online_evidence_clean(operation, &status)?; - Ok(status) - } - #[cfg(not(feature = "transport-nostr-runtime"))] - { - let _ = locator; - Err(RadrootsSdkError::ProductSyncUnsupported { - operation, - required_feature: "transport-nostr-runtime", - }) - } - } - } -} - -#[cfg(all(feature = "signer-adapters", feature = "transport-nostr-runtime"))] -async fn trade_mutation_context_with_fetch_adapter<A>( - sdk: &crate::RadrootsClient, - locator: RadrootsTradeLocator, - evidence_mode: TradeEvidenceMode, - operation: &'static str, - adapter: &A, -) -> Result<TradeProductMutationContext, RadrootsSdkError> -where - A: RadrootsRelayFetchAdapter, -{ - let status = match evidence_mode { - TradeEvidenceMode::LocalOnly => { - trades_client(sdk) - .status( - TradeStatusRequest::new(locator.clone()) - .with_source(SdkTradeStatusSource::LocalOnly), - ) - .await? - } - TradeEvidenceMode::RequireExplicitEvidence { ref evidence } => { - ingest_explicit_trade_mutation_evidence(sdk, operation, evidence).await?; - trades_client(sdk) - .status( - TradeStatusRequest::new(locator.clone()) - .with_source(SdkTradeStatusSource::LocalOnly), - ) - .await? - } - TradeEvidenceMode::ResyncBeforeMutation => { - let status = trades_client(sdk) - .status_with_fetch_adapter( - TradeStatusRequest::new(locator.clone()) - .with_source(SdkTradeStatusSource::ResyncThenLocal), - adapter, - ) - .await?; - require_trade_mutation_online_evidence_clean(operation, &status)?; - status - } - }; - trade_mutation_context_from_status(locator, &evidence_mode, operation, status) -} - -#[cfg(feature = "signer-adapters")] -fn trade_mutation_context_from_status( - locator: RadrootsTradeLocator, - evidence_mode: &TradeEvidenceMode, - operation: &'static str, - status: TradeStatusReceipt, -) -> Result<TradeProductMutationContext, RadrootsSdkError> { - if status.status == TradeStatusKind::Ambiguous { - return Err(RadrootsSdkError::TradeAmbiguous { - operation: operation.to_owned(), - locator: Box::new(locator), - candidates: status - .ambiguity_candidates - .into_iter() - .map(|candidate| candidate.locator) - .collect(), - }); - } - if !status.found { - let evidence_requirement = match evidence_mode { - TradeEvidenceMode::LocalOnly => "locally projected", - TradeEvidenceMode::ResyncBeforeMutation => "resynced", - TradeEvidenceMode::RequireExplicitEvidence { .. } => "explicitly ingested", - }; - return Err(RadrootsSdkError::InvalidRequest { - message: format!("{operation} requires a {evidence_requirement} 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, - }) -} - -#[cfg(feature = "signer-adapters")] -async fn ingest_explicit_trade_mutation_evidence( - sdk: &crate::RadrootsClient, - operation: &'static str, - evidence: &[TradeEvidenceIngestRequest], -) -> Result<(), RadrootsSdkError> { - if evidence.is_empty() { - return Err(RadrootsSdkError::InvalidRequest { - message: format!("{operation} requires explicit trade evidence"), - }); - } - let client = trades_client(sdk); - for event in evidence { - client.ingest_evidence(event.clone()).await?; - } - Ok(()) -} - -#[cfg(all(feature = "signer-adapters", feature = "transport-nostr-runtime"))] -fn require_trade_mutation_online_evidence_clean( - operation: &'static str, - status: &TradeStatusReceipt, -) -> Result<(), RadrootsSdkError> { - let evidence = - status - .online_evidence - .as_ref() - .ok_or_else(|| RadrootsSdkError::InvalidRequest { - message: format!("{operation} requires online mutation evidence"), - })?; - if evidence.malformed_count > 0 - || evidence.out_of_filter_count > 0 - || evidence.skipped_over_limit_count > 0 - || evidence.unsupported_count > 0 - { - return Err(RadrootsSdkError::InvalidRequest { - message: format!( - "{operation} refused online mutation evidence: malformed_count={}, out_of_filter_count={}, skipped_over_limit_count={}, unsupported_count={}", - evidence.malformed_count, - evidence.out_of_filter_count, - evidence.skipped_over_limit_count, - evidence.unsupported_count - ), - }); - } - Ok(()) -} - -#[cfg(feature = "signer-adapters")] -fn event_ptr(event_id: &RadrootsEventId) -> RadrootsEventPtr { - RadrootsEventPtr { - id: event_id.as_str().to_owned(), - relays: None, - } -} - -#[cfg(feature = "signer-adapters")] -async fn trade_product_post_enqueue_outcome<Plan, Receipt>( - sdk: &crate::RadrootsClient, - publish_mode: PublishMode, - satisfaction_policy: SatisfactionPolicy, - outbox_event_id: i64, - receipt: Receipt, -) -> Result<TradeMutationOutcome<Plan, Receipt>, RadrootsSdkError> { - match publish_mode { - PublishMode::DryRun => Err(RadrootsSdkError::InvalidRequest { - message: "trade product dry-run must return before enqueue".to_owned(), - }), - PublishMode::EnqueueOnly => Ok(TradeMutationOutcome::Enqueued { receipt }), - PublishMode::EnqueueAndPublish => { - let publish = sdk - .sync() - .push_outbox(push_request_for_satisfaction_policy( - satisfaction_policy, - outbox_event_id, - )?) - .await?; - Ok(TradeMutationOutcome::Published { receipt, publish }) - } - } -} - -#[cfg(feature = "signer-adapters")] -fn push_request_for_satisfaction_policy( - satisfaction_policy: SatisfactionPolicy, - outbox_event_id: i64, -) -> Result<PushOutboxRequest, RadrootsSdkError> { - let request = PushOutboxRequest::new().with_outbox_event_id(outbox_event_id); - if satisfaction_policy.is_no_wait() { - Err(RadrootsSdkError::InvalidRequest { - message: "trade enqueue-and-publish requires a transport satisfaction policy" - .to_owned(), - }) - } else { - Ok(request) - } -} - -#[cfg(feature = "signer-adapters")] -fn validate_trade_product_publish_policy( - publish_mode: PublishMode, - satisfaction_policy: &SatisfactionPolicy, -) -> Result<(), RadrootsSdkError> { - match publish_mode { - PublishMode::DryRun | PublishMode::EnqueueOnly if !satisfaction_policy.is_no_wait() => { - Err(RadrootsSdkError::InvalidRequest { - message: "trade dry-run and enqueue-only modes require no-wait satisfaction" - .to_owned(), - }) - } - PublishMode::EnqueueAndPublish if satisfaction_policy.is_no_wait() => { - Err(RadrootsSdkError::InvalidRequest { - message: "trade enqueue-and-publish requires a transport satisfaction policy" - .to_owned(), - }) - } - _ => Ok(()), - } -} - -#[cfg(feature = "signer-adapters")] -fn require_trade_product_privacy_preflight( - operation: &'static str, - fields: Vec<ProductSensitivityField>, - confirmation: &PrivacyPreflightConfirmation, -) -> Result<(), RadrootsSdkError> { - PrivacyPreflightReceipt::evaluate(fields) - .require_public_publish_allowed(operation, confirmation) -} - -#[cfg(feature = "signer-adapters")] -fn trade_propose_privacy_fields(request: &TradeProposeRequest) -> Vec<ProductSensitivityField> { - let mut fields = Vec::new(); - if !request.items.is_empty() || !request.economics.items.is_empty() { - fields.push(ProductSensitivityField::ProtocolMinimizedInventoryFields); - } - if let Some(public_note) = request.public_note.as_deref() - && !public_note.trim().is_empty() - { - fields.push(ProductSensitivityField::PublicButSensitiveNotes); - if trade_reason_contains_private_coordination(public_note) { - fields.push(ProductSensitivityField::SensitiveFulfillmentDetails); - } - } - fields -} - -#[cfg(feature = "signer-adapters")] -fn trade_decision_privacy_fields(decision: &RadrootsOrderDecision) -> Vec<ProductSensitivityField> { - match &decision.decision { - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments, - } if !inventory_commitments.is_empty() => { - vec![ProductSensitivityField::ProtocolMinimizedInventoryFields] - } - RadrootsOrderDecisionOutcome::Accepted { .. } => Vec::new(), - RadrootsOrderDecisionOutcome::Declined { reason } => trade_reason_privacy_fields(reason), - } -} - -#[cfg(feature = "signer-adapters")] -fn trade_reason_privacy_fields(reason: &str) -> Vec<ProductSensitivityField> { - if reason.trim().is_empty() { - return Vec::new(); - } - let mut fields = vec![ProductSensitivityField::PublicButSensitiveNotes]; - if trade_reason_contains_private_coordination(reason) { - fields.push(ProductSensitivityField::SensitiveFulfillmentDetails); - } - fields -} - -#[cfg(feature = "signer-adapters")] -fn trade_reason_contains_private_coordination(reason: &str) -> bool { - let reason = reason.to_ascii_lowercase(); - [ - "pickup address", - "delivery address", - "street address", - "exact location", - "private location", - "gate code", - "door code", - "latitude", - "longitude", - " gps", - "farm gate", - ] - .iter() - .any(|marker| reason.contains(marker)) -} - -#[cfg(feature = "signer-adapters")] -fn validate_trade_enqueue_policy( - publish_mode: PublishMode, - satisfaction_policy: &SatisfactionPolicy, -) -> Result<(), RadrootsSdkError> { - if publish_mode == PublishMode::DryRun { - return Err(RadrootsSdkError::InvalidRequest { - message: "trade dry-run publish mode must use a prepare request".to_owned(), - }); - } - if publish_mode == PublishMode::EnqueueOnly && !satisfaction_policy.is_no_wait() { - return Err(RadrootsSdkError::InvalidRequest { - message: "trade enqueue-only publish mode only supports no-wait satisfaction" - .to_owned(), - }); - } - if publish_mode == PublishMode::EnqueueAndPublish && satisfaction_policy.is_no_wait() { - return Err(RadrootsSdkError::InvalidRequest { - message: "trade enqueue-and-publish requires a transport satisfaction policy" - .to_owned(), - }); - } - Ok(()) -} - -#[cfg(feature = "runtime")] -struct ParsedOrderEvidence { - order_id: RadrootsOrderId, - listing_addr: RadrootsListingAddress, - event_id: RadrootsEventId, - event_kind: u32, -} - -#[cfg(feature = "runtime")] -#[inline(never)] -fn parse_order_evidence( - event: &RadrootsEventEnvelope, -) -> Result<ParsedOrderEvidence, RadrootsSdkError> { - let event_id = RadrootsEventId::parse(event.id_str()).map_err(|error| { - RadrootsSdkError::InvalidRequest { - message: format!("order evidence event id is invalid: {error}"), - } - })?; - let (order_id, listing_addr) = match event.kind_u32() { - KIND_ORDER_REQUEST => { - let payload = order_request_from_event(event) - .map_err(order_evidence_parse_error)? - .payload; - (payload.order_id, payload.listing_addr) - } - KIND_ORDER_DECISION => { - let payload = order_decision_from_event(event) - .map_err(order_evidence_parse_error)? - .payload; - (payload.order_id, payload.listing_addr) - } - KIND_ORDER_CANCELLATION => { - let payload = order_cancellation_from_event(event) - .map_err(order_evidence_parse_error)? - .payload; - (payload.order_id, payload.listing_addr) - } - other => { - return Err(RadrootsSdkError::InvalidRequest { - message: format!("order evidence event kind {other} is not supported"), - }); - } - }; - - Ok(ParsedOrderEvidence { - order_id, - listing_addr, - event_id, - event_kind: event.kind_u32(), - }) -} - -#[cfg(feature = "runtime")] -fn order_evidence_parse_error( - error: radroots_event_codec::order::RadrootsOrderEnvelopeParseError, -) -> RadrootsSdkError { - RadrootsSdkError::InvalidRequest { - message: format!("order evidence event is invalid: {error}"), - } -} - -#[cfg(feature = "runtime")] -impl TradeStatusReceipt { - fn from_locator_query_result( - locator: RadrootsTradeLocator, - query_result: RadrootsTradeLocatorProjectionQueryResult, - ) -> Self { - match query_result.resolution { - RadrootsTradeLocatorProjectionResolution::Projected { - locator, - projection, - } => Self::from_projection( - locator.clone(), - locator.root_event_id, - Vec::new(), - projection, - query_result.event_count, - query_result.limit_applied, - query_result.event_ids, - ), - RadrootsTradeLocatorProjectionResolution::Ambiguous { candidates, .. } => { - Self::ambiguous( - locator, - candidates, - query_result.event_count, - query_result.limit_applied, - query_result.event_ids, - ) - } - RadrootsTradeLocatorProjectionResolution::Missing { .. } => Self::missing( - locator, - query_result.event_count, - query_result.limit_applied, - query_result.event_ids, - ), - } - } - - fn from_projection( - locator: RadrootsTradeLocator, - root_event_id: Option<RadrootsEventId>, - ambiguity_candidates: Vec<TradeStatusAmbiguityCandidate>, - projection: RadrootsOrderProjection, - event_count: usize, - limit_applied: u32, - event_ids: Vec<RadrootsEventId>, - ) -> Self { - let found = projection.status != RadrootsTradeWorkflowState::Missing; - let evidence = - TradeStatusEvidenceSummary::from_projection(&projection, event_count, limit_applied); - let eligibility = TradeStatusEligibility::from_projection(&projection); - let next_action = TradeStatusNextActionKind::from_projection(&projection, &eligibility); - Self { - locator, - order_id: projection.order_id, - root_event_id, - ambiguity_candidates, - source: SdkTradeStatusSource::LocalOnly, - found, - event_count, - limit_applied, - status: projection.status.into(), - lifecycle_terminal: projection.lifecycle_terminal, - listing_addr: projection.listing_addr, - buyer_pubkey: projection.buyer_pubkey, - seller_pubkey: projection.seller_pubkey, - economics: projection.economics, - evidence, - validation_trust: None, - online_evidence: None, - eligibility, - next_action, - event_ids, - request_event_id: projection.request_event_id, - decision_event_id: projection.decision_event_id, - agreement_event_id: projection.agreement_event_id, - rhi_receipt_event_id: projection.validation_receipt_event_id, - cancellation_event_id: projection.cancellation_event_id, - last_event_id: projection.last_event_id, - issues: projection.issues.into_iter().map(Into::into).collect(), - } - } - - fn missing( - locator: RadrootsTradeLocator, - event_count: usize, - limit_applied: u32, - event_ids: Vec<RadrootsEventId>, - ) -> Self { - Self::from_projection( - locator.clone(), - locator.root_event_id.clone(), - Vec::new(), - RadrootsOrderProjection { - order_id: locator.order_id().clone(), - status: RadrootsTradeWorkflowState::Missing, - request_event_id: None, - decision_event_id: None, - cancellation_event_id: None, - validation_receipt_event_id: None, - lifecycle_terminal: false, - economics: None, - agreement_event_id: None, - pending_inventory_reservations: Vec::new(), - committed_inventory_reservations: Vec::new(), - listing_addr: locator.listing_addr.clone(), - buyer_pubkey: locator.buyer_pubkey.clone(), - seller_pubkey: locator.seller_pubkey.clone(), - last_event_id: None, - issues: Vec::new(), - }, - event_count, - limit_applied, - event_ids, - ) - } - - fn ambiguous( - locator: RadrootsTradeLocator, - candidates: Vec<RadrootsTradeLocatorCandidate>, - event_count: usize, - limit_applied: u32, - event_ids: Vec<RadrootsEventId>, - ) -> Self { - let ambiguity_candidates = candidates - .into_iter() - .map(|candidate| TradeStatusAmbiguityCandidate { - locator: candidate.locator(), - }) - .collect::<Vec<_>>(); - let mut receipt = Self::missing(locator, event_count, limit_applied, event_ids); - receipt.status = TradeStatusKind::Ambiguous; - receipt.next_action = TradeStatusNextActionKind::InspectEvidenceIssues; - receipt.ambiguity_candidates = ambiguity_candidates; - receipt - } -} - -#[cfg(feature = "runtime")] -impl TradeStatusEvidenceSummary { - fn from_projection( - projection: &RadrootsOrderProjection, - event_count: usize, - limit_applied: u32, - ) -> Self { - Self { - event_count, - limit_applied, - has_request: projection.request_event_id.is_some(), - has_decision: projection.decision_event_id.is_some(), - has_agreement: projection.agreement_event_id.is_some(), - has_validation_receipt: projection.validation_receipt_event_id.is_some(), - has_cancellation: projection.cancellation_event_id.is_some(), - has_issues: !projection.issues.is_empty(), - } - } -} - -#[cfg(feature = "runtime")] -impl TradeStatusEligibility { - fn from_projection(projection: &RadrootsOrderProjection) -> Self { - let clean = projection.issues.is_empty(); - let open = clean && !projection.lifecycle_terminal; - let requested = projection.status == RadrootsTradeWorkflowState::Requested; - - Self { - can_decide: open && requested && projection.decision_event_id.is_none(), - can_cancel: open && requested, - } - } -} - -#[cfg(feature = "runtime")] -impl TradeStatusNextActionKind { - fn from_projection( - projection: &RadrootsOrderProjection, - eligibility: &TradeStatusEligibility, - ) -> Self { - if projection.status == RadrootsTradeWorkflowState::Missing { - return Self::NoLocalOrder; - } - if !projection.issues.is_empty() || projection.status == RadrootsTradeWorkflowState::Invalid - { - return Self::InspectEvidenceIssues; - } - if projection.status == RadrootsTradeWorkflowState::AgreedPendingValidation { - return Self::AwaitValidation; - } - if projection.lifecycle_terminal { - return Self::Terminal; - } - if eligibility.can_decide { - return Self::AwaitSellerDecision; - } - Self::Terminal - } -} - -#[cfg(feature = "signer-adapters")] -fn order_submit_plan( - actor: &RadrootsActorContext, - listing_event: RadrootsEventPtr, - order_request: RadrootsOrderRequest, - created_at: RadrootsSdkTimestamp, -) -> Result<TradeSubmitPlan, RadrootsSdkError> { - require_buyer_actor(actor, "trade.prepare_submit")?; - let listing_event_id = listing_event_id(&listing_event)?; - let order_request = - canonicalize_order_request_for_signer(order_request, actor.pubkey().as_str()) - .map_err(order_canonicalization_error)?; - 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 { - message: format!("order submit draft encode failed: {error}"), - } - })?; - let (frozen_draft, expected_event_id) = freeze_order_workflow_draft( - draft.into_wire_parts(), - TRADE_SUBMIT_CONTRACT_ID, - order_request.buyer_pubkey.as_str(), - created_at_nostr, - "trade.prepare_submit", - ); - Ok(TradeSubmitPlan { - workflow: order_workflow_plan( - TradeWorkflowKind::Submit, - expected_event_id.clone(), - created_at, - ), - order_id, - listing_addr, - buyer_pubkey, - seller_pubkey, - listing_event_id, - expected_event_id, - frozen_draft, - created_at, - }) -} - -#[cfg(feature = "signer-adapters")] -fn order_decision_plan( - actor: &RadrootsActorContext, - request_event: RadrootsEventPtr, - decision: RadrootsOrderDecision, - created_at: RadrootsSdkTimestamp, -) -> Result<TradeDecisionPlan, RadrootsSdkError> { - require_seller_actor(actor, "trade.prepare_decision")?; - let request_event_id = request_event_id(&request_event)?; - if decision.seller_pubkey.as_str() != actor.pubkey().as_str() { - return Err(RadrootsSdkError::UnauthorizedActor { - operation: "trade.prepare_decision".to_owned(), - reason: "actor pubkey must match order seller_pubkey".to_owned(), - }); - } - let decision = canonicalize_order_decision_for_signer(decision, actor.pubkey().as_str()) - .map_err(order_decision_canonicalization_error)?; - let created_at_nostr = created_at.try_into_nostr_created_at()?; - let order_id = decision.order_id.clone(); - let listing_addr = decision.listing_addr.clone(); - let buyer_pubkey = decision.buyer_pubkey.clone(); - let seller_pubkey = decision.seller_pubkey.clone(); - validate_order_payload(&decision, "order decision") - .expect("canonical order decision payload validates"); - let draft = order::build_order_decision_draft(&request_event_id, &request_event_id, &decision) - .expect("validated order decision draft encodes"); - let (frozen_draft, expected_event_id) = freeze_order_workflow_draft( - draft.into_wire_parts(), - TRADE_DECISION_CONTRACT_ID, - decision.seller_pubkey.as_str(), - created_at_nostr, - "trade.prepare_decision", - ); - Ok(TradeDecisionPlan { - workflow: order_workflow_plan( - TradeWorkflowKind::Decision, - expected_event_id.clone(), - created_at, - ), - order_id, - listing_addr, - buyer_pubkey, - seller_pubkey, - request_event_id, - expected_event_id, - frozen_draft, - created_at, - }) -} - -#[cfg(feature = "signer-adapters")] -fn order_cancellation_plan( - actor: &RadrootsActorContext, - root_event: RadrootsEventPtr, - previous_event: RadrootsEventPtr, - cancellation: RadrootsOrderCancellation, - created_at: RadrootsSdkTimestamp, -) -> Result<TradeCancellationPlan, RadrootsSdkError> { - require_buyer_actor(actor, "trade.prepare_cancellation")?; - let root_event_id = order_reference_event_id(&root_event, "root")?; - let previous_event_id = order_reference_event_id(&previous_event, "previous")?; - if cancellation.buyer_pubkey.as_str() != actor.pubkey().as_str() { - return Err(RadrootsSdkError::UnauthorizedActor { - operation: "trade.prepare_cancellation".to_owned(), - reason: "actor pubkey must match order buyer_pubkey".to_owned(), - }); - } - let created_at_nostr = created_at.try_into_nostr_created_at()?; - let order_id = cancellation.order_id.clone(); - let listing_addr = cancellation.listing_addr.clone(); - let buyer_pubkey = cancellation.buyer_pubkey.clone(); - let seller_pubkey = cancellation.seller_pubkey.clone(); - validate_order_payload(&cancellation, "order cancellation")?; - let draft = - order::build_order_cancellation_draft(&root_event_id, &previous_event_id, &cancellation) - .expect("validated order cancellation draft encodes"); - let (frozen_draft, expected_event_id) = freeze_order_workflow_draft( - draft.into_wire_parts(), - TRADE_CANCELLATION_CONTRACT_ID, - buyer_pubkey.as_str(), - created_at_nostr, - "order cancellation", - ); - Ok(TradeCancellationPlan { - workflow: order_workflow_plan( - TradeWorkflowKind::Cancellation, - expected_event_id.clone(), - created_at, - ), - order_id, - listing_addr, - buyer_pubkey, - seller_pubkey, - root_event_id, - previous_event_id, - expected_event_id, - frozen_draft, - created_at, - }) -} - -#[cfg(feature = "signer-adapters")] -fn order_workflow_plan( - kind: TradeWorkflowKind, - expected_event_id: RadrootsEventId, - created_at: RadrootsSdkTimestamp, -) -> TradeWorkflowPlan { - TradeWorkflowPlan { - kind, - operation_kind: kind.operation_kind(), - contract_id: kind.contract_id(), - expected_event_id, - created_at, - } -} - -#[cfg(feature = "signer-adapters")] -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, - local_event_seq: enqueue.local_event_seq, - outbox_operation_id: enqueue.outbox_operation_id, - outbox_event_id: enqueue.outbox_event_id, - state: enqueue.state.into(), - idempotency_digest_prefix: Some(enqueue.idempotency_digest_prefix), - } -} - -#[cfg(feature = "signer-adapters")] -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, - seller_pubkey: plan.seller_pubkey, - request_event_id: plan.request_event_id, - expected_event_id: plan.expected_event_id, - signed_event_id: enqueue.signed_event_id, - local_event_seq: enqueue.local_event_seq, - outbox_operation_id: enqueue.outbox_operation_id, - outbox_event_id: enqueue.outbox_event_id, - state: enqueue.state.into(), - idempotency_digest_prefix: Some(enqueue.idempotency_digest_prefix), - } -} - -#[cfg(feature = "signer-adapters")] -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, - seller_pubkey: plan.seller_pubkey, - root_event_id: plan.root_event_id, - previous_event_id: plan.previous_event_id, - expected_event_id: plan.expected_event_id, - signed_event_id: enqueue.signed_event_id, - local_event_seq: enqueue.local_event_seq, - outbox_operation_id: enqueue.outbox_operation_id, - outbox_event_id: enqueue.outbox_event_id, - state: enqueue.state.into(), - idempotency_digest_prefix: Some(enqueue.idempotency_digest_prefix), - } -} - -#[cfg(feature = "signer-adapters")] -fn order_workflow_enqueue_receipt( - kind: TradeWorkflowKind, - expected_event_id: RadrootsEventId, - enqueue: &crate::workflow_runtime::SdkWorkflowEnqueueReceipt, -) -> TradeWorkflowEnqueueReceipt { - let state = SdkMutationState::from(enqueue.state); - let digest_prefix = Some(enqueue.idempotency_digest_prefix.clone()); - let safe_retry_same_key = true; - let replayed_existing_operation = state == SdkMutationState::AlreadyQueued; - TradeWorkflowEnqueueReceipt { - kind, - operation_kind: kind.operation_kind(), - expected_event_id, - signed_event_id: enqueue.signed_event_id.clone(), - local_event_seq: enqueue.local_event_seq, - outbox_operation_id: enqueue.outbox_operation_id, - outbox_event_id: enqueue.outbox_event_id, - state, - idempotency_digest_prefix: digest_prefix.clone(), - idempotency: TradeWorkflowIdempotencyReceipt { - digest_prefix, - replayed_existing_operation, - safe_to_retry_with_same_idempotency_key: safe_retry_same_key, - }, - retry: TradeWorkflowRetryAdvice { - retryable_after_error: false, - safe_to_retry_enqueue_with_same_idempotency_key: safe_retry_same_key, - recovery_actions: Vec::new(), - }, - } -} - -#[cfg(feature = "signer-adapters")] -fn freeze_order_workflow_draft( - parts: RadrootsNip01EventWireParts, - contract_id: &str, - expected_pubkey: &str, - created_at: u32, - _operation: &'static str, -) -> (RadrootsEventDraft, RadrootsEventId) { - let frozen_draft = RadrootsEventDraft::new( - contract_id, - parts.kind, - u64::from(created_at), - parts.tags, - parts.content, - expected_pubkey, - ) - .expect("validated order workflow draft freezes"); - let expected_event_id = RadrootsEventId::parse(frozen_draft.expected_event_id_str()) - .expect("frozen order workflow draft produces a valid event id"); - (frozen_draft, expected_event_id) -} - -#[cfg(feature = "signer-adapters")] -fn validate_order_payload<T>(payload: &T, operation: &'static str) -> Result<(), RadrootsSdkError> -where - T: OrderPayloadValidate, -{ - payload - .validate_order_payload() - .map_err(|error| RadrootsSdkError::InvalidRequest { - message: format!("{operation} payload is invalid: {error}"), - }) -} - -#[cfg(feature = "signer-adapters")] -trait OrderPayloadValidate { - fn validate_order_payload( - &self, - ) -> Result<(), radroots_event::order::RadrootsOrderPayloadError>; -} - -#[cfg(feature = "signer-adapters")] -impl OrderPayloadValidate for RadrootsOrderDecision { - fn validate_order_payload( - &self, - ) -> Result<(), radroots_event::order::RadrootsOrderPayloadError> { - self.validate() - } -} - -#[cfg(feature = "signer-adapters")] -impl OrderPayloadValidate for RadrootsOrderCancellation { - fn validate_order_payload( - &self, - ) -> Result<(), radroots_event::order::RadrootsOrderPayloadError> { - self.validate() - } -} - -#[cfg(feature = "runtime")] -struct TradeRequestEvidence { - order_id: RadrootsOrderId, - listing_addr: RadrootsListingAddress, - buyer_pubkey: RadrootsPublicKey, - seller_pubkey: RadrootsPublicKey, - request_event_id: RadrootsEventId, -} - -#[cfg(feature = "runtime")] -fn parse_order_request_evidence( - event: &RadrootsEventEnvelope, -) -> Result<TradeRequestEvidence, RadrootsSdkError> { - let request_event_id = RadrootsEventId::parse(event.id_str()).map_err(|error| { - RadrootsSdkError::InvalidRequest { - message: format!("order request evidence event id is invalid: {error}"), - } - })?; - let envelope = - order::parse_order_request(event).map_err(|error| RadrootsSdkError::InvalidRequest { - message: format!("order request evidence decode failed: {error}"), - })?; - let payload = envelope.payload; - Ok(TradeRequestEvidence { - order_id: payload.order_id, - listing_addr: payload.listing_addr, - buyer_pubkey: payload.buyer_pubkey, - seller_pubkey: payload.seller_pubkey, - request_event_id, - }) -} - -#[cfg(feature = "runtime")] -fn sdk_timestamp_ms(timestamp: RadrootsSdkTimestamp) -> Result<i64, RadrootsSdkError> { - let seconds = timestamp.unix_seconds(); - let millis = seconds - .checked_mul(1_000) - .ok_or(RadrootsSdkError::TimestampOutOfRange { value: seconds })?; - i64::try_from(millis).map_err(|_| RadrootsSdkError::TimestampOutOfRange { value: seconds }) -} - -#[cfg(feature = "signer-adapters")] -fn require_decision_request_evidence( - plan: &TradeDecisionPlan, - projection: &RadrootsOrderProjection, -) -> Result<(), RadrootsSdkError> { - let Some(request_event_id) = &projection.request_event_id else { - return Err(RadrootsSdkError::InvalidRequest { - message: format!( - "order decision requires local request evidence for order {}", - plan.order_id - ), - }); - }; - if request_event_id != &plan.request_event_id { - return Err(RadrootsSdkError::InvalidRequest { - message: format!( - "order decision request evidence {} does not match local request {} for order {}", - plan.request_event_id, request_event_id, plan.order_id - ), - }); - } - if !matches!(&projection.status, RadrootsTradeWorkflowState::Requested) { - return Err(RadrootsSdkError::InvalidRequest { - message: format!( - "order decision requires requested local state for order {}; current state is {:?}", - plan.order_id, projection.status - ), - }); - } - if !projection.issues.is_empty() { - return Err(RadrootsSdkError::InvalidRequest { - message: format!( - "order decision request evidence for order {} has {} reducer issue(s)", - plan.order_id, - projection.issues.len() - ), - }); - } - require_projection_match( - "order decision", - "listing_addr", - projection.listing_addr.as_ref(), - &plan.listing_addr, - &plan.order_id, - )?; - require_projection_match( - "order decision", - "buyer_pubkey", - projection.buyer_pubkey.as_ref(), - &plan.buyer_pubkey, - &plan.order_id, - )?; - require_projection_match( - "order decision", - "seller_pubkey", - projection.seller_pubkey.as_ref(), - &plan.seller_pubkey, - &plan.order_id, - )?; - Ok(()) -} - -#[cfg(feature = "signer-adapters")] -#[derive(Clone, Copy)] -struct OrderLifecycleReferences<'a> { - operation: &'static str, - order_id: &'a RadrootsOrderId, - listing_addr: &'a RadrootsListingAddress, - buyer_pubkey: &'a RadrootsPublicKey, - seller_pubkey: &'a RadrootsPublicKey, - root_event_id: &'a RadrootsEventId, - previous_event_id: &'a RadrootsEventId, -} - -#[cfg(feature = "signer-adapters")] -fn require_cancellation_state( - plan: &TradeCancellationPlan, - projection: &RadrootsOrderProjection, -) -> Result<(), RadrootsSdkError> { - let refs = OrderLifecycleReferences { - operation: "order cancellation", - order_id: &plan.order_id, - listing_addr: &plan.listing_addr, - buyer_pubkey: &plan.buyer_pubkey, - seller_pubkey: &plan.seller_pubkey, - root_event_id: &plan.root_event_id, - previous_event_id: &plan.previous_event_id, - }; - require_clean_lifecycle_projection(refs, projection)?; - if projection.status != RadrootsTradeWorkflowState::Requested { - return Err(lifecycle_invalid( - refs.operation, - refs.order_id, - format!( - "cancellation requires requested local state; current state is {:?}", - projection.status - ), - )); - } - require_no_lifecycle_terminal(&refs, projection)?; - require_lifecycle_previous_is_current(&refs, projection) -} - -#[cfg(feature = "signer-adapters")] -fn require_clean_lifecycle_projection( - refs: OrderLifecycleReferences<'_>, - projection: &RadrootsOrderProjection, -) -> Result<(), RadrootsSdkError> { - let Some(request_event_id) = &projection.request_event_id else { - return Err(lifecycle_invalid( - refs.operation, - refs.order_id, - "requires local order request evidence", - )); - }; - if request_event_id != refs.root_event_id { - return Err(lifecycle_invalid( - refs.operation, - refs.order_id, - format!( - "root event {} does not match local request {}", - refs.root_event_id, request_event_id - ), - )); - } - if !projection.issues.is_empty() { - return Err(lifecycle_invalid( - refs.operation, - refs.order_id, - format!( - "local order evidence has {} reducer issue(s)", - projection.issues.len() - ), - )); - } - require_projection_match( - refs.operation, - "listing_addr", - projection.listing_addr.as_ref(), - refs.listing_addr, - refs.order_id, - )?; - require_projection_match( - refs.operation, - "buyer_pubkey", - projection.buyer_pubkey.as_ref(), - refs.buyer_pubkey, - refs.order_id, - )?; - require_projection_match( - refs.operation, - "seller_pubkey", - projection.seller_pubkey.as_ref(), - refs.seller_pubkey, - refs.order_id, - ) -} - -#[cfg(feature = "signer-adapters")] -fn require_no_lifecycle_terminal( - refs: &OrderLifecycleReferences<'_>, - projection: &RadrootsOrderProjection, -) -> Result<(), RadrootsSdkError> { - if projection.lifecycle_terminal { - Err(lifecycle_invalid( - refs.operation, - refs.order_id, - "requires non-terminal local order state", - )) - } else { - Ok(()) - } -} - -#[cfg(feature = "signer-adapters")] -fn require_lifecycle_previous_is_current( - refs: &OrderLifecycleReferences<'_>, - projection: &RadrootsOrderProjection, -) -> Result<(), RadrootsSdkError> { - match projection.last_event_id.as_ref() { - Some(last_event_id) if last_event_id == refs.previous_event_id => Ok(()), - Some(last_event_id) => Err(lifecycle_invalid( - refs.operation, - refs.order_id, - format!( - "previous event {} does not match current lifecycle event {}", - refs.previous_event_id, last_event_id - ), - )), - None => Err(lifecycle_invalid( - refs.operation, - refs.order_id, - "requires current lifecycle event evidence", - )), - } -} - -#[cfg(feature = "signer-adapters")] -fn lifecycle_invalid( - operation: &'static str, - order_id: &RadrootsOrderId, - reason: impl Into<String>, -) -> RadrootsSdkError { - RadrootsSdkError::InvalidRequest { - message: format!("{operation} for order {order_id} {}", reason.into()), - } -} - -#[cfg(feature = "signer-adapters")] -fn require_projection_match<T>( - operation: &'static str, - field: &'static str, - actual: Option<&T>, - expected: &T, - order_id: &RadrootsOrderId, -) -> Result<(), RadrootsSdkError> -where - T: core::fmt::Display + PartialEq, -{ - match actual { - Some(actual) if actual == expected => Ok(()), - Some(actual) => Err(RadrootsSdkError::InvalidRequest { - message: format!( - "{operation} {field} {expected} does not match local request {actual} for order {order_id}" - ), - }), - None => Err(RadrootsSdkError::InvalidRequest { - message: format!( - "{operation} request evidence is missing {field} for order {order_id}" - ), - }), - } -} - -#[cfg(feature = "signer-adapters")] -fn require_buyer_actor( - actor: &RadrootsActorContext, - operation: &'static str, -) -> Result<(), RadrootsSdkError> { - if actor.satisfies(RadrootsActorRole::Buyer) { - Ok(()) - } else { - Err(RadrootsSdkError::UnauthorizedActor { - operation: operation.to_owned(), - reason: "missing role Buyer".to_owned(), - }) - } -} - -#[cfg(feature = "runtime")] -fn require_seller_actor( - actor: &RadrootsActorContext, - operation: &'static str, -) -> Result<(), RadrootsSdkError> { - if actor.satisfies(RadrootsActorRole::Seller) { - Ok(()) - } else { - Err(RadrootsSdkError::UnauthorizedActor { - operation: operation.to_owned(), - reason: "missing role Seller".to_owned(), - }) - } -} - -#[cfg(feature = "signer-adapters")] -fn listing_event_id(listing_event: &RadrootsEventPtr) -> Result<RadrootsEventId, RadrootsSdkError> { - RadrootsEventId::parse(listing_event.id.as_str()).map_err(|error| { - RadrootsSdkError::InvalidRequest { - message: format!("listing evidence event id is invalid: {error}"), - } - }) -} - -#[cfg(feature = "signer-adapters")] -fn request_event_id(request_event: &RadrootsEventPtr) -> Result<RadrootsEventId, RadrootsSdkError> { - RadrootsEventId::parse(request_event.id.as_str()).map_err(|error| { - RadrootsSdkError::InvalidRequest { - message: format!("order request evidence event id is invalid: {error}"), - } - }) -} - -#[cfg(feature = "signer-adapters")] -fn order_reference_event_id( - event: &RadrootsEventPtr, - label: &'static str, -) -> Result<RadrootsEventId, RadrootsSdkError> { - RadrootsEventId::parse(event.id.as_str()).map_err(|error| RadrootsSdkError::InvalidRequest { - message: format!("order {label} evidence event id is invalid: {error}"), - }) -} - -#[cfg(feature = "signer-adapters")] -fn order_canonicalization_error(error: RadrootsOrderCanonicalizationError) -> RadrootsSdkError { - match error { - RadrootsOrderCanonicalizationError::InvalidBuyerSigner => { - RadrootsSdkError::UnauthorizedActor { - operation: "trade.prepare_submit".to_owned(), - reason: "actor pubkey must match order buyer_pubkey".to_owned(), - } - } - error => RadrootsSdkError::InvalidRequest { - message: format!("order submit request is invalid: {error}"), - }, - } -} - -#[cfg(feature = "signer-adapters")] -fn order_decision_canonicalization_error( - error: RadrootsOrderCanonicalizationError, -) -> RadrootsSdkError { - RadrootsSdkError::InvalidRequest { - message: format!("order decision request is invalid: {error}"), - } -} - -#[cfg(feature = "runtime")] -impl From<RadrootsTradeWorkflowState> for TradeStatusKind { - fn from(status: RadrootsTradeWorkflowState) -> Self { - match status { - RadrootsTradeWorkflowState::Missing => Self::Missing, - RadrootsTradeWorkflowState::Requested => Self::Requested, - RadrootsTradeWorkflowState::AgreedPendingValidation => Self::AgreedPendingValidation, - RadrootsTradeWorkflowState::Committed => Self::Committed, - RadrootsTradeWorkflowState::Declined => Self::Declined, - RadrootsTradeWorkflowState::Cancelled => Self::Cancelled, - RadrootsTradeWorkflowState::ValidationExpired => Self::ValidationExpired, - RadrootsTradeWorkflowState::Invalid => Self::Invalid, - } - } -} - -#[cfg(feature = "runtime")] -impl From<RadrootsOrderIssue> for SdkTradeStatusIssue { - fn from(issue: RadrootsOrderIssue) -> Self { - match issue { - RadrootsOrderIssue::MissingRequest => { - Self::new(SdkTradeStatusIssueKind::MissingRequest, Vec::new()) - } - RadrootsOrderIssue::MultipleRequests { event_ids } => { - Self::new(SdkTradeStatusIssueKind::MultipleRequests, event_ids) - } - RadrootsOrderIssue::RequestPayloadInvalid { event_id } => { - Self::single(SdkTradeStatusIssueKind::RequestPayloadInvalid, event_id) - } - RadrootsOrderIssue::RequestOrderIdMismatch { event_id } => { - Self::single(SdkTradeStatusIssueKind::RequestOrderIdMismatch, event_id) - } - RadrootsOrderIssue::RequestAuthorMismatch { event_id } => { - Self::single(SdkTradeStatusIssueKind::RequestAuthorMismatch, event_id) - } - RadrootsOrderIssue::RequestListingAddressInvalid { event_id } => Self::single( - SdkTradeStatusIssueKind::RequestListingAddressInvalid, - event_id, - ), - RadrootsOrderIssue::RequestSellerListingMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::RequestSellerListingMismatch, - event_id, - ), - RadrootsOrderIssue::DecisionPayloadInvalid { event_id } => { - Self::single(SdkTradeStatusIssueKind::DecisionPayloadInvalid, event_id) - } - RadrootsOrderIssue::DecisionOrderIdMismatch { event_id } => { - Self::single(SdkTradeStatusIssueKind::DecisionOrderIdMismatch, event_id) - } - RadrootsOrderIssue::DecisionAuthorMismatch { event_id } => { - Self::single(SdkTradeStatusIssueKind::DecisionAuthorMismatch, event_id) - } - RadrootsOrderIssue::DecisionCounterpartyMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::DecisionCounterpartyMismatch, - event_id, - ), - RadrootsOrderIssue::DecisionBuyerMismatch { event_id } => { - Self::single(SdkTradeStatusIssueKind::DecisionBuyerMismatch, event_id) - } - RadrootsOrderIssue::DecisionSellerMismatch { event_id } => { - Self::single(SdkTradeStatusIssueKind::DecisionSellerMismatch, event_id) - } - RadrootsOrderIssue::DecisionListingAddressInvalid { event_id } => Self::single( - SdkTradeStatusIssueKind::DecisionListingAddressInvalid, - event_id, - ), - RadrootsOrderIssue::DecisionListingMismatch { event_id } => { - Self::single(SdkTradeStatusIssueKind::DecisionListingMismatch, event_id) - } - RadrootsOrderIssue::DecisionRootMismatch { event_id } => { - Self::single(SdkTradeStatusIssueKind::DecisionRootMismatch, event_id) - } - RadrootsOrderIssue::DecisionPreviousMismatch { event_id } => { - Self::single(SdkTradeStatusIssueKind::DecisionPreviousMismatch, event_id) - } - RadrootsOrderIssue::DecisionMissingInventoryCommitments { event_id } => Self::single( - SdkTradeStatusIssueKind::DecisionMissingInventoryCommitments, - event_id, - ), - RadrootsOrderIssue::DecisionInventoryCommitmentMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::DecisionInventoryCommitmentMismatch, - event_id, - ), - RadrootsOrderIssue::DecisionMissingReason { event_id } => { - Self::single(SdkTradeStatusIssueKind::DecisionMissingReason, event_id) - } - RadrootsOrderIssue::ConflictingDecisions { event_ids } => { - Self::new(SdkTradeStatusIssueKind::ConflictingDecisions, event_ids) - } - RadrootsOrderIssue::CancellationWithoutCancellableOrder { event_id } => Self::single( - SdkTradeStatusIssueKind::CancellationWithoutCancellableOrder, - event_id, - ), - RadrootsOrderIssue::CancellationPayloadInvalid { event_id } => Self::single( - SdkTradeStatusIssueKind::CancellationPayloadInvalid, - event_id, - ), - RadrootsOrderIssue::CancellationOrderIdMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::CancellationOrderIdMismatch, - event_id, - ), - RadrootsOrderIssue::CancellationAuthorMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::CancellationAuthorMismatch, - event_id, - ), - RadrootsOrderIssue::CancellationCounterpartyMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::CancellationCounterpartyMismatch, - event_id, - ), - RadrootsOrderIssue::CancellationBuyerMismatch { event_id } => { - Self::single(SdkTradeStatusIssueKind::CancellationBuyerMismatch, event_id) - } - RadrootsOrderIssue::CancellationSellerMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::CancellationSellerMismatch, - event_id, - ), - RadrootsOrderIssue::CancellationListingAddressInvalid { event_id } => Self::single( - SdkTradeStatusIssueKind::CancellationListingAddressInvalid, - event_id, - ), - RadrootsOrderIssue::CancellationListingMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::CancellationListingMismatch, - event_id, - ), - RadrootsOrderIssue::CancellationRootMismatch { event_id } => { - Self::single(SdkTradeStatusIssueKind::CancellationRootMismatch, event_id) - } - RadrootsOrderIssue::CancellationPreviousMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::CancellationPreviousMismatch, - event_id, - ), - RadrootsOrderIssue::ForkedLifecycle { event_ids } => { - Self::new(SdkTradeStatusIssueKind::ForkedLifecycle, event_ids) - } - RadrootsOrderIssue::ValidationReceiptWithoutPendingAgreement { event_id } => { - Self::single( - SdkTradeStatusIssueKind::ValidationReceiptWithoutPendingAgreement, - event_id, - ) - } - RadrootsOrderIssue::ValidationReceiptOrderIdMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::ValidationReceiptOrderIdMismatch, - event_id, - ), - RadrootsOrderIssue::ValidationReceiptTypeMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::ValidationReceiptTypeMismatch, - event_id, - ), - RadrootsOrderIssue::ValidationReceiptRootMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::ValidationReceiptRootMismatch, - event_id, - ), - RadrootsOrderIssue::ValidationReceiptTargetMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::ValidationReceiptTargetMismatch, - event_id, - ), - RadrootsOrderIssue::ValidationReceiptListingMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::ValidationReceiptListingMismatch, - event_id, - ), - RadrootsOrderIssue::ConflictingValidationReceipts { event_ids } => Self::new( - SdkTradeStatusIssueKind::ConflictingValidationReceipts, - event_ids, - ), - RadrootsOrderIssue::DeterministicValidationFailure { event_id, .. } => Self::single( - SdkTradeStatusIssueKind::DeterministicValidationFailure, - event_id, - ), - RadrootsOrderIssue::StaleListingEvent { - expected_event_id, - current_event_id, - } => Self::new( - SdkTradeStatusIssueKind::StaleListingEvent, - vec![expected_event_id, current_event_id], - ), - } - } -} - -#[cfg(feature = "runtime")] -fn projection_error(error: RadrootsOrderStoreQueryError) -> RadrootsSdkError { - let message = match error { - RadrootsOrderStoreQueryError::Store(_) => "order status store query failed", - RadrootsOrderStoreQueryError::InvalidStoredTagsJson { .. } => { - "stored order event tags could not be decoded" - } - RadrootsOrderStoreQueryError::Decode { .. } => { - "stored order event could not decode as order record" - } - RadrootsOrderStoreQueryError::InvalidStoredEnvelope { .. } => { - "stored order event envelope is invalid" - } - RadrootsOrderStoreQueryError::Projection(error) => return error.into(), - }; - RadrootsSdkError::Projection { - message: message.to_owned(), - } -} - -#[cfg(feature = "runtime")] -fn camel_to_snake(value: &str) -> String { - let mut output = String::new(); - for (index, character) in value.chars().enumerate() { - if character.is_ascii_uppercase() { - if index > 0 { - output.push('_'); - } - output.push(character.to_ascii_lowercase()); - } else { - output.push(character); - } - } - output -} - -#[cfg(all(test, feature = "runtime", feature = "signer-adapters"))] -#[path = "../tests/unit/orders_runtime_tests.rs"] -mod tests; diff --git a/crates/sdk/src/private_store.rs b/crates/sdk/src/private_store.rs @@ -441,8 +441,7 @@ impl SdkPrivateStore { if exists != 0 { return Err(RadrootsSdkError::UnsupportedProfileSchema { path: Path::new("private.sqlite").to_path_buf(), - message: "pre-V1 SDK private store is unsupported; use explicit quarantine reset" - .to_owned(), + message: "pre-V1 SDK private store is unsupported by release-product v1".to_owned(), }); } Ok(()) diff --git a/crates/sdk/src/product_clients.rs b/crates/sdk/src/product_clients.rs @@ -30,13 +30,13 @@ impl<'client> ListingsClient<'client> { #[cfg(feature = "runtime")] #[derive(Clone, Copy)] pub struct MarketClient<'client> { - pub(crate) sdk: &'client RadrootsClient, + pub(crate) _sdk: &'client RadrootsClient, } #[cfg(feature = "runtime")] impl<'client> MarketClient<'client> { pub(crate) fn new(sdk: &'client RadrootsClient) -> Self { - Self { sdk } + Self { _sdk: sdk } } } @@ -56,55 +56,14 @@ impl<'client> GeoNamesClient<'client> { #[cfg(feature = "runtime")] #[derive(Clone, Copy)] pub struct TradesClient<'client> { - pub(crate) sdk: &'client RadrootsClient, + pub(crate) _sdk: &'client RadrootsClient, } #[cfg(feature = "runtime")] impl<'client> TradesClient<'client> { pub(crate) fn new(sdk: &'client RadrootsClient) -> Self { - Self { sdk } - } - - #[cfg(feature = "signer-adapters")] - pub fn buyer(&self) -> TradeBuyerClient<'client> { - TradeBuyerClient { sdk: self.sdk } - } - - pub fn seller(&self) -> TradeSellerClient<'client> { - TradeSellerClient { sdk: self.sdk } + Self { _sdk: sdk } } - - pub fn resync(&self) -> TradeResyncClient<'client> { - TradeResyncClient { sdk: self.sdk } - } - - pub fn validation_receipts(&self) -> TradeValidationReceiptsClient<'client> { - TradeValidationReceiptsClient { sdk: self.sdk } - } -} - -#[cfg(all(feature = "runtime", feature = "signer-adapters"))] -#[derive(Clone, Copy)] -pub struct TradeBuyerClient<'client> { - pub(crate) sdk: &'client RadrootsClient, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Copy)] -pub struct TradeSellerClient<'client> { - pub(crate) sdk: &'client RadrootsClient, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Copy)] -pub struct TradeResyncClient<'client> { - pub(crate) sdk: &'client RadrootsClient, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Copy)] -pub struct TradeValidationReceiptsClient<'client> { - pub(crate) sdk: &'client RadrootsClient, } #[cfg(feature = "runtime")] diff --git a/crates/sdk/src/runtime.rs b/crates/sdk/src/runtime.rs @@ -52,39 +52,101 @@ const BACKUP_MANIFEST_FILE: &str = "manifest.json"; #[cfg(feature = "runtime")] const PRE_V1_RUNTIME_FILES: [&str; 2] = ["event_store.sqlite", "outbox.sqlite"]; #[cfg(feature = "runtime")] -const PRE_V1_RUNTIME_ARTIFACTS: [&str; 6] = [ - "event_store.sqlite", - "event_store.sqlite-wal", - "event_store.sqlite-shm", - "outbox.sqlite", - "outbox.sqlite-wal", - "outbox.sqlite-shm", -]; -#[cfg(feature = "runtime")] const SDK_RUNTIME_MIGRATION_UP: &str = r#" CREATE TABLE IF NOT EXISTS sdk_runtime_operation_journal ( journal_id INTEGER PRIMARY KEY AUTOINCREMENT, contract_version TEXT NOT NULL, - operation_id TEXT NOT NULL, + operation_kind TEXT NOT NULL, actor_pubkey TEXT NOT NULL, idempotency_key TEXT NOT NULL, - request_digest_sha256_hex TEXT NOT NULL, + command_payload_hash TEXT NOT NULL CHECK (length(command_payload_hash) = 64), + frozen_draft_json TEXT NOT NULL, + expected_transport_id TEXT NOT NULL, + mutation_id TEXT, + state TEXT NOT NULL CHECK (state IN ('prepared','signature_pending','committed','rejected','failed_recoverable')), + result_json TEXT, + last_error_code TEXT, + last_error_detail TEXT, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + UNIQUE(contract_version, operation_kind, actor_pubkey, idempotency_key), + UNIQUE(mutation_id) +) STRICT; + +CREATE INDEX IF NOT EXISTS sdk_runtime_operation_journal_state_idx +ON sdk_runtime_operation_journal(state, updated_at_ms, journal_id); + +CREATE TABLE IF NOT EXISTS sdk_runtime_recovery_receipt ( + recovery_receipt_id INTEGER PRIMARY KEY AUTOINCREMENT, + recovery_code TEXT NOT NULL CHECK (recovery_code IN ('signer_timeout','projection_stale','relay_failure','reservation_expiry','idempotency_conflict')), + operation_kind TEXT, + actor_pubkey TEXT, + idempotency_key TEXT, + recovery_action TEXT NOT NULL, + detail_json TEXT NOT NULL, + created_at_ms INTEGER NOT NULL +) STRICT; + +CREATE INDEX IF NOT EXISTS sdk_runtime_recovery_receipt_code_idx +ON sdk_runtime_recovery_receipt(recovery_code, created_at_ms, recovery_receipt_id); + +CREATE TABLE IF NOT EXISTS sdk_seller_inventory_reservation ( + reservation_id TEXT PRIMARY KEY NOT NULL, + farm_id TEXT NOT NULL, + candidate_id TEXT NOT NULL, + authority_id TEXT NOT NULL, + inventory_epoch INTEGER NOT NULL CHECK (inventory_epoch >= 0), + assertion_commitment TEXT NOT NULL UNIQUE, + state TEXT NOT NULL CHECK (state IN ('prepared','bound','released','expired','conflict')), + lease_until_ms INTEGER NOT NULL, + bound_mutation_id TEXT UNIQUE, created_at_ms INTEGER NOT NULL, - completed_at_ms INTEGER, - UNIQUE(contract_version, operation_id, actor_pubkey, idempotency_key) -); + updated_at_ms INTEGER NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS sdk_seller_inventory_reservation_line ( + reservation_id TEXT NOT NULL REFERENCES sdk_seller_inventory_reservation(reservation_id) ON DELETE CASCADE, + farm_id TEXT NOT NULL, + candidate_id TEXT NOT NULL, + line_id TEXT NOT NULL, + bin_id TEXT NOT NULL, + quantity_mantissa TEXT NOT NULL, + quantity_scale INTEGER NOT NULL CHECK (quantity_scale BETWEEN 0 AND 9), + unit_code TEXT NOT NULL, + PRIMARY KEY (reservation_id, line_id), + UNIQUE(farm_id, candidate_id, line_id, bin_id) +) STRICT; + +CREATE INDEX IF NOT EXISTS sdk_seller_inventory_reservation_expiring_idx +ON sdk_seller_inventory_reservation(lease_until_ms, reservation_id) +WHERE state = 'prepared'; + +CREATE INDEX IF NOT EXISTS sdk_seller_inventory_reservation_candidate_idx +ON sdk_seller_inventory_reservation(candidate_id, state, reservation_id); + +CREATE TABLE IF NOT EXISTS sdk_runtime_trade_projection_checkpoint ( + projection_name TEXT PRIMARY KEY NOT NULL, + reducer_contract_id TEXT NOT NULL, + reducer_version INTEGER NOT NULL, + last_ingest_seq INTEGER NOT NULL, + source_digest TEXT NOT NULL, + projection_digest TEXT NOT NULL, + completeness_state TEXT NOT NULL CHECK (completeness_state IN ('current','partial','stale','rebuilding','failed')), + rebuilt_at_ms INTEGER, + updated_at_ms INTEGER NOT NULL +) STRICT; CREATE TABLE IF NOT EXISTS sdk_runtime_health_state ( key TEXT PRIMARY KEY NOT NULL, value_json TEXT NOT NULL, updated_at_ms INTEGER NOT NULL -); +) STRICT; CREATE TABLE IF NOT EXISTS sdk_runtime_projection_generation ( projection_name TEXT PRIMARY KEY NOT NULL, generation INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL -); +) STRICT; "#; #[cfg(feature = "runtime")] @@ -466,40 +528,6 @@ pub struct RestoreReceipt { } #[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -#[non_exhaustive] -pub struct QuarantineResetRequest { - pub profile: PathBuf, - pub quarantine: PathBuf, - pub overwrite: bool, -} - -#[cfg(feature = "runtime")] -impl QuarantineResetRequest { - pub fn new(profile: impl Into<PathBuf>, quarantine: impl Into<PathBuf>) -> Self { - Self { - profile: profile.into(), - quarantine: quarantine.into(), - overwrite: false, - } - } - - pub fn with_overwrite(mut self, overwrite: bool) -> Self { - self.overwrite = overwrite; - self - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct QuarantineResetReceipt { - pub profile: PathBuf, - pub quarantine: PathBuf, - pub quarantined_paths: Vec<PathBuf>, - pub reset_paths: RadrootsSdkStoragePaths, -} - -#[cfg(feature = "runtime")] #[derive(Clone)] pub struct RadrootsClientBuilder { storage: RadrootsSdkStorageConfig, @@ -575,7 +603,8 @@ impl RadrootsClientBuilder { } pub async fn build(self) -> Result<RadrootsClient, RadrootsSdkError> { - let storage = open_storage(&self.storage).await?; + let recovery_now_ms = clock_recovery_now_ms(&self.clock); + let storage = open_storage(&self.storage, recovery_now_ms).await?; Ok(RadrootsClient { _event_store: storage.event_store, _outbox: storage.outbox, @@ -916,12 +945,6 @@ impl RadrootsClient { restored_paths, }) } - - pub async fn quarantine_reset_storage( - request: QuarantineResetRequest, - ) -> Result<QuarantineResetReceipt, RadrootsSdkError> { - quarantine_reset_storage(request).await - } } #[cfg(feature = "runtime")] @@ -1802,19 +1825,25 @@ struct OpenedRuntimeStorage { #[cfg(feature = "runtime")] async fn open_storage( storage: &RadrootsSdkStorageConfig, + recovery_now_ms: i64, ) -> Result<OpenedRuntimeStorage, RadrootsSdkError> { match storage { - RadrootsSdkStorageConfig::Memory => open_memory_storage().await, - RadrootsSdkStorageConfig::Directory(path) => open_directory_storage(path).await, + RadrootsSdkStorageConfig::Memory => open_memory_storage(recovery_now_ms).await, + RadrootsSdkStorageConfig::Directory(path) => { + open_directory_storage(path, recovery_now_ms).await + } } } #[cfg(feature = "runtime")] -async fn open_memory_storage() -> Result<OpenedRuntimeStorage, RadrootsSdkError> { +async fn open_memory_storage( + recovery_now_ms: i64, +) -> Result<OpenedRuntimeStorage, RadrootsSdkError> { let runtime_pool = open_runtime_memory_pool().await?; + reject_newer_sdk_runtime_schema(&runtime_pool, Path::new(":memory:")).await?; let event_store = RadrootsEventStore::open_pool(runtime_pool.clone(), false).await?; let outbox = RadrootsOutbox::open_pool(runtime_pool.clone(), false).await?; - apply_sdk_runtime_schema(&runtime_pool).await?; + apply_sdk_runtime_schema(&runtime_pool, Path::new(":memory:"), recovery_now_ms).await?; Ok(OpenedRuntimeStorage { event_store, outbox, @@ -1825,7 +1854,10 @@ async fn open_memory_storage() -> Result<OpenedRuntimeStorage, RadrootsSdkError> } #[cfg(feature = "runtime")] -async fn open_directory_storage(path: &Path) -> Result<OpenedRuntimeStorage, RadrootsSdkError> { +async fn open_directory_storage( + path: &Path, + recovery_now_ms: i64, +) -> Result<OpenedRuntimeStorage, RadrootsSdkError> { reject_pre_v1_profile(path)?; fs::create_dir_all(path).map_err(|error| RadrootsSdkError::Io { path: path.to_path_buf(), @@ -1833,9 +1865,10 @@ async fn open_directory_storage(path: &Path) -> Result<OpenedRuntimeStorage, Rad })?; let paths = storage_paths_for_directory(path); let runtime_pool = open_runtime_file_pool(&paths.runtime_path).await?; + reject_newer_sdk_runtime_schema(&runtime_pool, &paths.runtime_path).await?; let event_store = RadrootsEventStore::open_pool(runtime_pool.clone(), true).await?; let outbox = RadrootsOutbox::open_pool(runtime_pool.clone(), true).await?; - apply_sdk_runtime_schema(&runtime_pool).await?; + apply_sdk_runtime_schema(&runtime_pool, &paths.runtime_path, recovery_now_ms).await?; Ok(OpenedRuntimeStorage { event_store, outbox, @@ -1846,6 +1879,17 @@ async fn open_directory_storage(path: &Path) -> Result<OpenedRuntimeStorage, Rad } #[cfg(feature = "runtime")] +fn clock_recovery_now_ms(clock: &RadrootsSdkClock) -> i64 { + let Ok(timestamp) = clock.now() else { + return 0; + }; + let Some(millis) = timestamp.unix_seconds().checked_mul(1_000) else { + return i64::MAX; + }; + i64::try_from(millis).unwrap_or(i64::MAX) +} + +#[cfg(feature = "runtime")] fn storage_paths_for_directory(path: &Path) -> RadrootsSdkStoragePaths { RadrootsSdkStoragePaths { runtime_path: path.join(RUNTIME_SQLITE_FILE), @@ -1878,11 +1922,17 @@ async fn open_runtime_file_pool(path: &Path) -> Result<SqlitePool, RadrootsSdkEr } #[cfg(feature = "runtime")] -async fn apply_sdk_runtime_schema(pool: &SqlitePool) -> Result<(), RadrootsSdkError> { +async fn apply_sdk_runtime_schema( + pool: &SqlitePool, + path: &Path, + recovery_now_ms: i64, +) -> Result<(), RadrootsSdkError> { sqlx::raw_sql(SDK_RUNTIME_MIGRATION_UP) .execute(pool) .await .map_err(|error| runtime_store_error(error.to_string()))?; + validate_sdk_runtime_schema(pool, path).await?; + recover_sdk_runtime_state(pool, recovery_now_ms).await?; sqlx::query("PRAGMA user_version = 1") .execute(pool) .await @@ -1891,130 +1941,234 @@ async fn apply_sdk_runtime_schema(pool: &SqlitePool) -> Result<(), RadrootsSdkEr } #[cfg(feature = "runtime")] -fn reject_pre_v1_profile(path: &Path) -> Result<(), RadrootsSdkError> { - for file_name in PRE_V1_RUNTIME_FILES { - let candidate = path.join(file_name); - if fs::symlink_metadata(&candidate).is_ok() { - return Err(RadrootsSdkError::UnsupportedProfileSchema { - path: candidate, - message: "pre-V1 SDK runtime file is unsupported; use explicit quarantine reset" - .to_owned(), - }); - } +async fn reject_newer_sdk_runtime_schema( + pool: &SqlitePool, + path: &Path, +) -> Result<(), RadrootsSdkError> { + let version = + sqlite_query_i64(pool, "PRAGMA user_version", SqliteStoreRole::RuntimeStore).await?; + if version > SDK_RUNTIME_SCHEMA_VERSION { + return Err(RadrootsSdkError::UnsupportedProfileSchema { + path: path.to_path_buf(), + message: format!( + "runtime schema version {version} is newer than supported version {SDK_RUNTIME_SCHEMA_VERSION}" + ), + }); } Ok(()) } #[cfg(feature = "runtime")] -async fn quarantine_reset_storage( - request: QuarantineResetRequest, -) -> Result<QuarantineResetReceipt, RadrootsSdkError> { - let moves = preflight_quarantine_reset(&request)?; - fs::create_dir_all(&request.quarantine).map_err(|error| RadrootsSdkError::Io { - path: request.quarantine.clone(), - message: error.to_string(), - })?; - let mut quarantined_paths = Vec::with_capacity(moves.len()); - for (source, destination) in moves { - if request.overwrite { - remove_existing_restore_path(&destination)?; +async fn validate_sdk_runtime_schema( + pool: &SqlitePool, + path: &Path, +) -> Result<(), RadrootsSdkError> { + for (table, columns) in [ + ( + "sdk_runtime_operation_journal", + &[ + "operation_kind", + "command_payload_hash", + "frozen_draft_json", + "expected_transport_id", + "state", + "result_json", + "updated_at_ms", + ][..], + ), + ( + "sdk_seller_inventory_reservation", + &[ + "reservation_id", + "state", + "lease_until_ms", + "bound_mutation_id", + ][..], + ), + ( + "sdk_runtime_trade_projection_checkpoint", + &["projection_name", "completeness_state", "updated_at_ms"][..], + ), + ] { + let actual = sqlite_table_columns(pool, table).await?; + for required in columns { + if !actual.iter().any(|column| column == required) { + return Err(RadrootsSdkError::UnsupportedProfileSchema { + path: path.to_path_buf(), + message: format!( + "runtime table `{table}` is missing required column `{required}`" + ), + }); + } } - rename_restore_path(&source, &destination, "quarantine reset")?; - quarantined_paths.push(destination); - } - let storage = open_directory_storage(&request.profile).await?; - let reset_paths = storage - .paths - .expect("directory storage reset always returns paths"); - Ok(QuarantineResetReceipt { - profile: request.profile, - quarantine: request.quarantine, - quarantined_paths, - reset_paths, - }) -} - -#[cfg(feature = "runtime")] -fn preflight_quarantine_reset( - request: &QuarantineResetRequest, -) -> Result<Vec<(PathBuf, PathBuf)>, RadrootsSdkError> { - if request.profile.as_os_str().is_empty() { - return Err(RadrootsSdkError::InvalidRequest { - message: "quarantine reset profile path must not be empty".to_owned(), - }); } - if request.quarantine.as_os_str().is_empty() { - return Err(RadrootsSdkError::InvalidRequest { - message: "quarantine reset destination must not be empty".to_owned(), + let integrity = + sqlite_query_string(pool, "PRAGMA quick_check", SqliteStoreRole::RuntimeStore).await?; + if integrity != "ok" { + return Err(RadrootsSdkError::UnsupportedProfileSchema { + path: path.to_path_buf(), + message: format!("runtime quick_check failed: {integrity}"), }); } - match fs::symlink_metadata(&request.profile) { - Ok(metadata) if metadata.file_type().is_symlink() => { - return Err(RadrootsSdkError::InvalidRequest { - message: "quarantine reset profile must not be a symbolic link".to_owned(), - }); - } - Ok(metadata) if metadata.is_dir() => {} - Ok(_) => { - return Err(RadrootsSdkError::InvalidRequest { - message: "quarantine reset profile must be a directory".to_owned(), - }); + Ok(()) +} + +#[cfg(feature = "runtime")] +async fn sqlite_table_columns( + pool: &SqlitePool, + table: &str, +) -> Result<Vec<String>, RadrootsSdkError> { + let sql = match table { + "sdk_runtime_operation_journal" => "PRAGMA table_info(sdk_runtime_operation_journal)", + "sdk_seller_inventory_reservation" => "PRAGMA table_info(sdk_seller_inventory_reservation)", + "sdk_runtime_trade_projection_checkpoint" => { + "PRAGMA table_info(sdk_runtime_trade_projection_checkpoint)" } - Err(error) => { - return Err(RadrootsSdkError::Io { - path: request.profile.clone(), - message: error.to_string(), + _ => { + return Err(RadrootsSdkError::UnsupportedProfileSchema { + path: PathBuf::from(":memory:"), + message: format!("runtime schema validation requested unknown table `{table}`"), }); } + }; + let rows = sqlx::query(sql) + .fetch_all(pool) + .await + .map_err(|error| runtime_store_error(error.to_string()))?; + rows.into_iter() + .map(|row| { + row.try_get::<String, _>("name") + .map_err(|error| runtime_store_error(error.to_string())) + }) + .collect() +} + +#[cfg(feature = "runtime")] +async fn recover_sdk_runtime_state(pool: &SqlitePool, now_ms: i64) -> Result<(), RadrootsSdkError> { + let operation_recovery = sqlx::query( + "UPDATE sdk_runtime_operation_journal SET state = 'failed_recoverable', last_error_code = 'signer_timeout', last_error_detail = 'operation was incomplete at SDK startup', updated_at_ms = ? WHERE state IN ('prepared','signature_pending')", + ) + .bind(now_ms) + .execute(pool) + .await + .map_err(|error| runtime_store_error(error.to_string()))?; + if operation_recovery.rows_affected() > 0 { + record_runtime_recovery_receipt( + pool, + "signer_timeout", + None, + None, + None, + "retry_operation_with_same_idempotency_key", + serde_json::json!({ "recovered_operations": operation_recovery.rows_affected() }), + now_ms, + ) + .await?; } - match fs::symlink_metadata(&request.quarantine) { - Ok(metadata) if metadata.file_type().is_symlink() => { - return Err(RadrootsSdkError::InvalidRequest { - message: "quarantine reset destination must not be a symbolic link".to_owned(), - }); - } - Ok(metadata) if metadata.is_dir() => {} - Ok(_) => { - return Err(RadrootsSdkError::InvalidRequest { - message: "quarantine reset destination must be a directory".to_owned(), - }); - } - Err(error) if error.kind() == ErrorKind::NotFound => {} - Err(error) => { - return Err(RadrootsSdkError::Io { - path: request.quarantine.clone(), - message: error.to_string(), - }); - } + let reservation_expiry = sqlx::query( + "UPDATE sdk_seller_inventory_reservation SET state = 'expired', updated_at_ms = ? WHERE state = 'prepared' AND lease_until_ms <= ?", + ) + .bind(now_ms) + .bind(now_ms) + .execute(pool) + .await + .map_err(|error| runtime_store_error(error.to_string()))?; + if reservation_expiry.rows_affected() > 0 { + record_runtime_recovery_receipt( + pool, + "reservation_expiry", + None, + None, + None, + "retry_operation_with_same_idempotency_key", + serde_json::json!({ "expired_reservations": reservation_expiry.rows_affected() }), + now_ms, + ) + .await?; } - let mut moves = Vec::new(); - for file_name in PRE_V1_RUNTIME_ARTIFACTS { - let source = request.profile.join(file_name); - let Ok(metadata) = fs::symlink_metadata(&source) else { - continue; - }; - if metadata.is_dir() { - return Err(RadrootsSdkError::InvalidRequest { - message: format!("quarantine reset artifact `{file_name}` must not be a directory"), - }); - } - let destination = request.quarantine.join(file_name); - if fs::symlink_metadata(&destination).is_ok() && !request.overwrite { - return Err(RadrootsSdkError::InvalidRequest { - message: format!( - "quarantine reset destination `{}` already exists", - destination.display() - ), + let projection_stale = sqlx::query( + "UPDATE sdk_runtime_trade_projection_checkpoint SET completeness_state = 'stale', updated_at_ms = ? WHERE completeness_state = 'rebuilding'", + ) + .bind(now_ms) + .execute(pool) + .await + .map_err(|error| runtime_store_error(error.to_string()))?; + if projection_stale.rows_affected() > 0 { + record_runtime_recovery_receipt( + pool, + "projection_stale", + None, + None, + None, + "inspect_local_stores", + serde_json::json!({ "stale_projection_checkpoints": projection_stale.rows_affected() }), + now_ms, + ) + .await?; + } + let outbox_claim_recovery = sqlx::query( + "UPDATE outbox_event SET state = CASE WHEN state = 'signing' AND signed_event_json IS NULL THEN 'sign_retryable' WHEN state = 'signing' AND signed_event_json IS NOT NULL THEN 'signed' WHEN state = 'publishing' THEN 'publish_retryable' ELSE state END, claim_token = NULL, claim_owner = NULL, claim_expires_at_ms = NULL, active_delivery_plan_id = NULL, updated_at_ms = ? WHERE claim_token IS NOT NULL AND claim_expires_at_ms <= ? AND state IN ('signing', 'signed', 'publishing')", + ) + .bind(now_ms) + .bind(now_ms) + .execute(pool) + .await + .map_err(|error| runtime_store_error(error.to_string()))?; + if outbox_claim_recovery.rows_affected() > 0 { + record_runtime_recovery_receipt( + pool, + "relay_failure", + None, + None, + None, + "retry_after_transport_failure", + serde_json::json!({ "recovered_outbox_claims": outbox_claim_recovery.rows_affected() }), + now_ms, + ) + .await?; + } + Ok(()) +} + +#[cfg(feature = "runtime")] +pub(crate) async fn record_runtime_recovery_receipt( + pool: &SqlitePool, + recovery_code: &'static str, + operation_kind: Option<&str>, + actor_pubkey: Option<&str>, + idempotency_key: Option<&str>, + recovery_action: &'static str, + detail_json: serde_json::Value, + created_at_ms: i64, +) -> Result<(), RadrootsSdkError> { + sqlx::query( + "INSERT INTO sdk_runtime_recovery_receipt(recovery_code, operation_kind, actor_pubkey, idempotency_key, recovery_action, detail_json, created_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(recovery_code) + .bind(operation_kind) + .bind(actor_pubkey) + .bind(idempotency_key) + .bind(recovery_action) + .bind(detail_json.to_string()) + .bind(created_at_ms) + .execute(pool) + .await + .map(|_| ()) + .map_err(|error| runtime_store_error(error.to_string())) +} + +#[cfg(feature = "runtime")] +fn reject_pre_v1_profile(path: &Path) -> Result<(), RadrootsSdkError> { + for file_name in PRE_V1_RUNTIME_FILES { + let candidate = path.join(file_name); + if fs::symlink_metadata(&candidate).is_ok() { + return Err(RadrootsSdkError::UnsupportedProfileSchema { + path: candidate, + message: "pre-V1 SDK runtime file is unsupported by release-product v1".to_owned(), }); } - moves.push((source, destination)); } - if moves.is_empty() { - return Err(RadrootsSdkError::InvalidRequest { - message: "quarantine reset found no pre-V1 SDK runtime artifacts".to_owned(), - }); - } - Ok(moves) + Ok(()) } #[cfg(feature = "runtime")] diff --git a/crates/sdk/src/sync_runtime.rs b/crates/sdk/src/sync_runtime.rs @@ -25,10 +25,8 @@ use radroots_outbox::{ RadrootsOutboxEventState, RadrootsOutboxReticulumEventRecord, RadrootsOutboxStatusSummary, }; #[cfg(feature = "runtime")] -use radroots_trade::projection::{ - RADROOTS_PRODUCT_PROJECTION_ID, RADROOTS_PRODUCT_PROJECTION_VERSION, - RadrootsProjectionRefreshReceipt, RadrootsProjectionRefreshRequest, - refresh_product_projections, +use radroots_trade::workflow::{ + RADROOTS_TRADE_REDUCER_CONTRACT_ID, RADROOTS_TRADE_REDUCER_VERSION, }; #[cfg(all(feature = "runtime", feature = "radrootsd-execution"))] use radroots_transport::RadrootsTransportTargetFingerprint; @@ -54,6 +52,8 @@ use radroots_transport_publish_protocol::{ TransportPublishOutcomeKind, TransportPublishTarget, TransportPublishTargetOutcome, TransportPublishTargetPolicy, }; +#[cfg(feature = "runtime")] +use sha2::{Digest, Sha256}; #[cfg(feature = "runtime")] pub const PUSH_OUTBOX_DEFAULT_LIMIT: usize = 20; @@ -70,6 +70,10 @@ pub const SYNC_PROJECTION_REFRESH_MAX_LIMIT: u32 = RADROOTS_EVENT_STORE_QUERY_LI #[cfg(feature = "runtime")] const CLAIM_OWNER: &str = "radroots_sdk.sync.push_outbox"; +#[cfg(feature = "runtime")] +const SDK_RELEASE_PRODUCT_PROJECTION_ID: &str = "radroots.release_product.trade_projection.v1"; +#[cfg(feature = "runtime")] +const SDK_RELEASE_PRODUCT_PROJECTION_VERSION: u32 = 1; #[cfg(feature = "runtime")] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)] @@ -336,17 +340,22 @@ pub struct SyncProjectionRefreshReceipt { #[cfg(feature = "runtime")] impl SyncProjectionRefreshReceipt { - fn from_trade(receipt: RadrootsProjectionRefreshReceipt, refreshed_at_ms: i64) -> Self { + fn from_event_store_snapshot( + summary: RadrootsEventStoreStatusSummary, + scanned_events: usize, + trade_upserts: usize, + refreshed_at_ms: i64, + ) -> Self { Self { - projection_id: RADROOTS_PRODUCT_PROJECTION_ID, - projection_version: RADROOTS_PRODUCT_PROJECTION_VERSION, + projection_id: SDK_RELEASE_PRODUCT_PROJECTION_ID, + projection_version: SDK_RELEASE_PRODUCT_PROJECTION_VERSION, refreshed_at_ms, - scanned_events: receipt.scanned_events, - listing_upserts: receipt.listing_upserts, - trade_upserts: receipt.trade_upserts, - validation_receipts: receipt.validation_receipts, - transport_observations: receipt.transport_observations, - last_event_seq: receipt.last_event_seq, + scanned_events, + listing_upserts: 0, + trade_upserts, + validation_receipts: 0, + transport_observations: summary.transport_observations, + last_event_seq: summary.last_event_seq, } } } @@ -546,7 +555,6 @@ impl From<RadrootsOutboxEventState> for PushOutboxEventState { RadrootsOutboxEventState::Published => Self::Published, RadrootsOutboxEventState::SignRetryable => Self::SignRetryable, RadrootsOutboxEventState::PublishRetryable => Self::PublishRetryable, - RadrootsOutboxEventState::DeferredUntilImplemented => Self::DeferredUntilImplemented, RadrootsOutboxEventState::FailedTerminal => Self::FailedTerminal, RadrootsOutboxEventState::Cancelled => Self::Cancelled, } @@ -997,19 +1005,17 @@ fn reticulum_event_receipt( #[cfg(feature = "runtime")] fn reticulum_event_final_state( - event_state: RadrootsOutboxEventState, + _event_state: RadrootsOutboxEventState, targets: &[RadrootsOutboxDeliveryTargetRecord], ) -> PushOutboxEventState { - if event_state == RadrootsOutboxEventState::DeferredUntilImplemented - || targets.iter().any(|target| { - matches!( - target.status, - RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented - | RadrootsOutboxDeliveryTargetStatus::Pending - | RadrootsOutboxDeliveryTargetStatus::FailedRetryable - ) - }) - { + if targets.iter().any(|target| { + matches!( + target.status, + RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented + | RadrootsOutboxDeliveryTargetStatus::Pending + | RadrootsOutboxDeliveryTargetStatus::FailedRetryable + ) + }) { PushOutboxEventState::DeferredUntilImplemented } else { PushOutboxEventState::DeferredUntilImplemented @@ -1078,19 +1084,87 @@ pub(crate) async fn refresh_product_projections_for_sdk( sdk: &RadrootsClient, request: SyncProjectionRefreshRequest, ) -> Result<SyncProjectionRefreshReceipt, RadrootsSdkError> { + if request.limit == 0 || request.limit > SYNC_PROJECTION_REFRESH_MAX_LIMIT { + return Err(RadrootsSdkError::InvalidRequest { + message: format!( + "projection refresh limit must be between 1 and {SYNC_PROJECTION_REFRESH_MAX_LIMIT}" + ), + }); + } let refreshed_at_ms = sdk_now_ms(sdk)?; - let receipt = refresh_product_projections( - &sdk._event_store, - RadrootsProjectionRefreshRequest::new().with_limit(request.limit), - refreshed_at_ms, + let summary = sdk._event_store.status_summary().await?; + let scanned_events = usize::try_from(summary.projection_eligible_events.max(0)) + .unwrap_or(usize::MAX) + .min(request.limit as usize); + let trade_upserts = trade_mutation_count(sdk).await?; + let source_digest = projection_source_digest(&summary, trade_upserts); + sqlx::query( + "INSERT INTO sdk_runtime_trade_projection_checkpoint(projection_name, reducer_contract_id, reducer_version, last_ingest_seq, source_digest, projection_digest, completeness_state, rebuilt_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, 'current', ?, ?) ON CONFLICT(projection_name) DO UPDATE SET reducer_contract_id = excluded.reducer_contract_id, reducer_version = excluded.reducer_version, last_ingest_seq = excluded.last_ingest_seq, source_digest = excluded.source_digest, projection_digest = excluded.projection_digest, completeness_state = excluded.completeness_state, rebuilt_at_ms = excluded.rebuilt_at_ms, updated_at_ms = excluded.updated_at_ms", ) - .await?; - Ok(SyncProjectionRefreshReceipt::from_trade( - receipt, + .bind(SDK_RELEASE_PRODUCT_PROJECTION_ID) + .bind(RADROOTS_TRADE_REDUCER_CONTRACT_ID) + .bind(i64::from(RADROOTS_TRADE_REDUCER_VERSION)) + .bind(summary.last_event_seq.unwrap_or_default()) + .bind(source_digest.as_str()) + .bind(source_digest.as_str()) + .bind(refreshed_at_ms) + .bind(refreshed_at_ms) + .execute(sdk._event_store.pool()) + .await + .map_err(|error| RadrootsSdkError::Projection { + message: error.to_string(), + })?; + Ok(SyncProjectionRefreshReceipt::from_event_store_snapshot( + summary, + scanned_events, + trade_upserts, refreshed_at_ms, )) } +#[cfg(feature = "runtime")] +async fn trade_mutation_count(sdk: &RadrootsClient) -> Result<usize, RadrootsSdkError> { + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM trade_mutation") + .fetch_one(sdk._event_store.pool()) + .await + .map_err(|error| RadrootsSdkError::Projection { + message: error.to_string(), + })?; + Ok(usize::try_from(count.max(0)).unwrap_or(usize::MAX)) +} + +#[cfg(feature = "runtime")] +fn projection_source_digest( + summary: &RadrootsEventStoreStatusSummary, + trade_upserts: usize, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(SDK_RELEASE_PRODUCT_PROJECTION_ID.as_bytes()); + hasher.update(b"\0"); + hasher.update( + SDK_RELEASE_PRODUCT_PROJECTION_VERSION + .to_string() + .as_bytes(), + ); + hasher.update(b"\0"); + hasher.update(summary.total_events.to_string().as_bytes()); + hasher.update(b"\0"); + hasher.update(summary.projection_eligible_events.to_string().as_bytes()); + hasher.update(b"\0"); + hasher.update( + summary + .last_event_seq + .unwrap_or_default() + .to_string() + .as_bytes(), + ); + hasher.update(b"\0"); + hasher.update(summary.transport_observations.to_string().as_bytes()); + hasher.update(b"\0"); + hasher.update(trade_upserts.to_string().as_bytes()); + hex::encode(hasher.finalize()) +} + #[cfg(all(feature = "runtime", feature = "radrootsd-execution"))] async fn push_radrootsd_claimed_outbox_event( sync: &SyncClient<'_>, diff --git a/crates/sdk/src/trade_storage.rs b/crates/sdk/src/trade_storage.rs @@ -1,152 +0,0 @@ -#[cfg(feature = "runtime")] -use std::collections::BTreeMap; - -#[cfg(feature = "runtime")] -use radroots_event::ids::{RadrootsEventId, RadrootsOrderId}; -#[cfg(feature = "runtime")] -use radroots_trade::{ - identity::{RadrootsTradeId, RadrootsTradeLocator, RadrootsTradeLocatorCandidate}, - workflow::RadrootsTradeWorkflowState, -}; - -#[cfg(feature = "runtime")] -pub const SDK_TRADE_PROJECTION_CACHE_VERSION: u32 = 1; - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)] -pub struct SdkTradeProjectionCacheKey { - pub order_id: RadrootsOrderId, - pub root_event_id: RadrootsEventId, - pub projection_version: u32, -} - -#[cfg(feature = "runtime")] -impl SdkTradeProjectionCacheKey { - pub fn new(order_id: RadrootsOrderId, root_event_id: RadrootsEventId) -> Self { - Self { - order_id, - root_event_id, - projection_version: SDK_TRADE_PROJECTION_CACHE_VERSION, - } - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct SdkTradeProjectionCacheRecord { - pub key: SdkTradeProjectionCacheKey, - pub locator: RadrootsTradeLocator, - pub status: RadrootsTradeWorkflowState, - pub updated_at_ms: i64, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct SdkTradeProjectionCache { - records: BTreeMap<SdkTradeProjectionCacheKey, SdkTradeProjectionCacheRecord>, -} - -#[cfg(feature = "runtime")] -impl SdkTradeProjectionCache { - pub fn upsert(&mut self, record: SdkTradeProjectionCacheRecord) { - self.records.insert(record.key.clone(), record); - } - - pub fn get(&self, key: &SdkTradeProjectionCacheKey) -> Option<&SdkTradeProjectionCacheRecord> { - self.records.get(key) - } - - pub fn ambiguity_candidates( - &self, - order_id: &RadrootsOrderId, - ) -> Vec<RadrootsTradeLocatorCandidate> { - self.records - .values() - .filter(|record| record.key.order_id == *order_id) - .filter_map(|record| { - Some(RadrootsTradeLocatorCandidate { - trade_id: RadrootsTradeId::from(record.key.order_id.clone()), - root_event_id: record.key.root_event_id.clone(), - listing_addr: record.locator.listing_addr.clone()?, - buyer_pubkey: record.locator.buyer_pubkey.clone()?, - seller_pubkey: record.locator.seller_pubkey.clone()?, - }) - }) - .collect() - } -} - -#[cfg(test)] -#[cfg(feature = "runtime")] -mod tests { - use super::{ - SDK_TRADE_PROJECTION_CACHE_VERSION, SdkTradeProjectionCache, SdkTradeProjectionCacheKey, - SdkTradeProjectionCacheRecord, - }; - use radroots_event::ids::{ - RadrootsEventId, RadrootsListingAddress, RadrootsOrderId, RadrootsPublicKey, - }; - use radroots_event::kinds::KIND_LISTING; - use radroots_trade::{identity::RadrootsTradeLocator, workflow::RadrootsTradeWorkflowState}; - - const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - const BUYER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - - fn order_id() -> RadrootsOrderId { - RadrootsOrderId::parse("order-1").expect("order id") - } - - fn event_id(raw: u8) -> RadrootsEventId { - RadrootsEventId::parse(format!("{raw:064x}")).expect("event id") - } - - fn listing_addr() -> RadrootsListingAddress { - RadrootsListingAddress::parse(format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")) - .expect("listing address") - } - - fn locator(root: RadrootsEventId) -> RadrootsTradeLocator { - RadrootsTradeLocator::from_order_id(order_id()) - .with_root_event_id(root) - .with_listing_addr(listing_addr()) - .with_buyer_pubkey(RadrootsPublicKey::parse(BUYER).expect("buyer")) - .with_seller_pubkey(RadrootsPublicKey::parse(SELLER).expect("seller")) - } - - #[test] - fn projection_cache_key_includes_root_and_version() { - let first = SdkTradeProjectionCacheKey::new(order_id(), event_id(1)); - let second = SdkTradeProjectionCacheKey::new(order_id(), event_id(2)); - - assert_ne!(first, second); - assert_eq!(first.projection_version, SDK_TRADE_PROJECTION_CACHE_VERSION); - } - - #[test] - fn projection_cache_returns_root_ambiguity_candidates() { - let mut cache = SdkTradeProjectionCache::default(); - for root in [event_id(1), event_id(2)] { - let record = SdkTradeProjectionCacheRecord { - key: SdkTradeProjectionCacheKey::new(order_id(), root.clone()), - locator: locator(root), - status: RadrootsTradeWorkflowState::Requested, - updated_at_ms: 1, - }; - cache.upsert(record); - } - - let candidates = cache.ambiguity_candidates(&order_id()); - - assert_eq!(candidates.len(), 2); - assert!( - candidates - .iter() - .any(|candidate| candidate.root_event_id == event_id(1)) - ); - assert!( - candidates - .iter() - .any(|candidate| candidate.root_event_id == event_id(2)) - ); - } -} diff --git a/crates/sdk/src/transport.rs b/crates/sdk/src/transport.rs @@ -196,11 +196,6 @@ impl TargetPolicy { pub fn mesh_scope(scope: MeshScopeId) -> Self { Self::MeshScope(scope) } - - #[cfg(feature = "signer-adapters")] - pub(crate) fn workflow_target_policy(self) -> Self { - self - } } impl serde::Serialize for TargetPolicy { diff --git a/crates/sdk/src/workflow_runtime.rs b/crates/sdk/src/workflow_runtime.rs @@ -2,7 +2,8 @@ use crate::RadrootsSdkSignRequest; use crate::{ RadrootsClient, RadrootsSdkError, ReticulumBehavior, SatisfactionPolicy, SdkIdempotencyKey, - TargetPolicy, TargetSet, TransportProfile, runtime::sdk_now_ms, + TargetPolicy, TargetSet, TransportProfile, + runtime::{record_runtime_recovery_receipt, sdk_now_ms}, }; use radroots_authority::{RadrootsActorContext, RadrootsEventSigner, sign_authorized_draft}; use radroots_event::{ @@ -23,6 +24,7 @@ use sha2::{Digest, Sha256}; use sqlx::Row; const SDK_LOCAL_EVENT_ENDPOINT_URI: &str = "local:sdk"; +const SDK_RUNTIME_CONTRACT_VERSION: &str = "1"; pub(crate) struct SdkWorkflowEnqueueRequest<'a> { pub(crate) operation_kind: &'static str, @@ -33,6 +35,7 @@ pub(crate) struct SdkWorkflowEnqueueRequest<'a> { pub(crate) idempotency_key: Option<SdkIdempotencyKey>, } +#[derive(Debug)] pub(crate) struct SdkWorkflowEnqueueReceipt { pub(crate) signed_event_id: RadrootsEventId, pub(crate) local_event_seq: i64, @@ -49,8 +52,35 @@ pub(crate) async fn enqueue_signed_workflow( ) -> Result<SdkWorkflowEnqueueReceipt, RadrootsSdkError> { let delivery_plan = resolved_delivery_plan(sdk, &request.target_policy, &request.satisfaction_policy)?; - let signed_event = sign_authorized_draft(request.actor, signer, request.frozen_draft)?; - enqueue_signed_workflow_event(sdk, request, signed_event, delivery_plan).await + let prepared = prepare_runtime_operation_journal(sdk, &request, &delivery_plan).await?; + if let Some(receipt) = prepared.committed_receipt { + return Ok(receipt); + } + mark_runtime_operation_state( + sdk, + &request, + &prepared.idempotency_key, + SdkRuntimeOperationState::SignaturePending, + None, + ) + .await?; + let signed_event = match sign_authorized_draft(request.actor, signer, request.frozen_draft) { + Ok(signed_event) => signed_event, + Err(error) => { + let sdk_error: RadrootsSdkError = error.into(); + record_runtime_operation_failure(sdk, &request, &prepared.idempotency_key, &sdk_error) + .await?; + return Err(sdk_error); + } + }; + match enqueue_signed_workflow_event(sdk, &request, signed_event, delivery_plan).await { + Ok(receipt) => Ok(receipt), + Err(error) => { + record_runtime_operation_failure(sdk, &request, &prepared.idempotency_key, &error) + .await?; + Err(error) + } + } } #[cfg(feature = "signer-adapters")] @@ -60,26 +90,53 @@ pub(crate) async fn enqueue_configured_signed_workflow( ) -> Result<SdkWorkflowEnqueueReceipt, RadrootsSdkError> { let delivery_plan = resolved_delivery_plan(sdk, &request.target_policy, &request.satisfaction_policy)?; - let signed_event = sdk + let prepared = prepare_runtime_operation_journal(sdk, &request, &delivery_plan).await?; + if let Some(receipt) = prepared.committed_receipt { + return Ok(receipt); + } + mark_runtime_operation_state( + sdk, + &request, + &prepared.idempotency_key, + SdkRuntimeOperationState::SignaturePending, + None, + ) + .await?; + let signed_event = match sdk .sign_with_configured_signer(RadrootsSdkSignRequest::new( request.operation_kind, request.actor, request.frozen_draft, )) - .await? - .signed_event; - enqueue_signed_workflow_event(sdk, request, signed_event, delivery_plan).await + .await + { + Ok(receipt) => receipt.signed_event, + Err(error) => { + record_runtime_operation_failure(sdk, &request, &prepared.idempotency_key, &error) + .await?; + return Err(error); + } + }; + match enqueue_signed_workflow_event(sdk, &request, signed_event, delivery_plan).await { + Ok(receipt) => Ok(receipt), + Err(error) => { + record_runtime_operation_failure(sdk, &request, &prepared.idempotency_key, &error) + .await?; + Err(error) + } + } } async fn enqueue_signed_workflow_event( sdk: &RadrootsClient, - request: SdkWorkflowEnqueueRequest<'_>, + request: &SdkWorkflowEnqueueRequest<'_>, signed_event: RadrootsSignedEvent, delivery_plan: SdkResolvedDeliveryPlan, ) -> Result<SdkWorkflowEnqueueReceipt, RadrootsSdkError> { let idempotency_key = request .idempotency_key + .clone() .ok_or_else(|| RadrootsSdkError::InvalidRequest { message: format!( "{} requires an explicit UUIDv7 idempotency key", @@ -90,7 +147,6 @@ async fn enqueue_signed_workflow_event( let signed_event_id = RadrootsEventId::parse(request.frozen_draft.expected_event_id_str()) .expect("frozen workflow draft has a valid expected event id"); let delivery_plan_value = delivery_plan.delivery_plan; - let idempotency_key_for_enqueue = idempotency_key.clone(); let mut tx = sdk._event_store .pool() @@ -99,15 +155,7 @@ async fn enqueue_signed_workflow_event( .map_err(|error| RadrootsSdkError::EventStore { message: error.to_string(), })?; - record_runtime_operation_journal( - &mut tx, - request.operation_kind, - request.actor, - request.frozen_draft, - &idempotency_key_for_enqueue, - observed_at_ms, - ) - .await?; + ensure_runtime_operation_can_commit(&mut tx, request, &idempotency_key).await?; let local_import_observation = RadrootsTransportObservation::new( RadrootsTransportKind::Local, SDK_LOCAL_EVENT_ENDPOINT_URI, @@ -125,7 +173,7 @@ async fn enqueue_signed_workflow_event( request.frozen_draft, signed_event, delivery_plan_value, - idempotency_key_for_enqueue.clone(), + idempotency_key.clone(), ingest_receipt.inserted, observed_at_ms, ); @@ -133,29 +181,24 @@ async fn enqueue_signed_workflow_event( ._outbox .enqueue_signed_operation_in_transaction(&mut tx, outbox_input) .await?; - complete_runtime_operation_journal( - &mut tx, - request.operation_kind, - request.actor, - &idempotency_key_for_enqueue, - observed_at_ms, - ) - .await?; - tx.commit() - .await - .map_err(|error| RadrootsSdkError::EventStore { - message: error.to_string(), - })?; let idempotency_digest_prefix = digest_prefix(outbox_receipt.operation_idempotency_digest.as_str()); - Ok(SdkWorkflowEnqueueReceipt { + let receipt = SdkWorkflowEnqueueReceipt { signed_event_id, local_event_seq: ingest_receipt.seq, outbox_operation_id: outbox_receipt.operation_id, outbox_event_id: outbox_receipt.outbox_event_id, state: outbox_receipt.status, idempotency_digest_prefix, - }) + }; + commit_runtime_operation_journal(&mut tx, request, &idempotency_key, &receipt, observed_at_ms) + .await?; + tx.commit() + .await + .map_err(|error| RadrootsSdkError::EventStore { + message: error.to_string(), + })?; + Ok(receipt) } struct SdkResolvedDeliveryPlan { @@ -283,6 +326,45 @@ fn digest_prefix(digest: &str) -> String { digest.chars().take(12).collect() } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SdkRuntimeOperationState { + Prepared, + SignaturePending, + Committed, + Rejected, + FailedRecoverable, +} + +impl SdkRuntimeOperationState { + fn as_str(self) -> &'static str { + match self { + Self::Prepared => "prepared", + Self::SignaturePending => "signature_pending", + Self::Committed => "committed", + Self::Rejected => "rejected", + Self::FailedRecoverable => "failed_recoverable", + } + } + + fn from_str(value: &str) -> Result<Self, RadrootsSdkError> { + match value { + "prepared" => Ok(Self::Prepared), + "signature_pending" => Ok(Self::SignaturePending), + "committed" => Ok(Self::Committed), + "rejected" => Ok(Self::Rejected), + "failed_recoverable" => Ok(Self::FailedRecoverable), + _ => Err(RadrootsSdkError::EventStore { + message: format!("unknown SDK runtime operation state `{value}`"), + }), + } + } +} + +struct PreparedRuntimeOperation { + idempotency_key: SdkIdempotencyKey, + committed_receipt: Option<SdkWorkflowEnqueueReceipt>, +} + #[cfg(test)] fn parse_event_id(value: &str, field: &str) -> Result<RadrootsEventId, RadrootsSdkError> { RadrootsEventId::parse(value).map_err(|error| RadrootsSdkError::InvalidRequest { @@ -311,53 +393,179 @@ fn signed_outbox_input( .with_idempotency_key(idempotency_key.into_string()) } -async fn record_runtime_operation_journal( - tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, - operation_kind: &'static str, - actor: &RadrootsActorContext, - frozen_draft: &RadrootsEventDraft, - idempotency_key: &SdkIdempotencyKey, - observed_at_ms: i64, -) -> Result<(), RadrootsSdkError> { - let request_digest = runtime_request_digest(operation_kind, actor, frozen_draft); - if let Some(row) = sqlx::query( - "SELECT request_digest_sha256_hex FROM sdk_runtime_operation_journal WHERE contract_version = ? AND operation_id = ? AND actor_pubkey = ? AND idempotency_key = ?", +async fn prepare_runtime_operation_journal( + sdk: &RadrootsClient, + request: &SdkWorkflowEnqueueRequest<'_>, + delivery_plan: &SdkResolvedDeliveryPlan, +) -> Result<PreparedRuntimeOperation, RadrootsSdkError> { + let idempotency_key = + request + .idempotency_key + .clone() + .ok_or_else(|| RadrootsSdkError::InvalidRequest { + message: format!( + "{} requires an explicit UUIDv7 idempotency key", + request.operation_kind + ), + })?; + let observed_at_ms = sdk_now_ms(sdk)?; + let command_hash = runtime_request_digest(request, &delivery_plan.delivery_plan); + let frozen_draft_json = frozen_draft_json(request.frozen_draft)?; + let expected_transport_id = request.frozen_draft.expected_event_id_str(); + let mut tx = + sdk._event_store + .pool() + .begin() + .await + .map_err(|error| RadrootsSdkError::EventStore { + message: error.to_string(), + })?; + let committed_receipt = if let Some(row) = sqlx::query( + "SELECT command_payload_hash, state, result_json FROM sdk_runtime_operation_journal WHERE contract_version = ? AND operation_kind = ? AND actor_pubkey = ? AND idempotency_key = ?", ) - .bind("1") - .bind(operation_kind) - .bind(actor.pubkey().as_str()) + .bind(SDK_RUNTIME_CONTRACT_VERSION) + .bind(request.operation_kind) + .bind(request.actor.pubkey().as_str()) .bind(idempotency_key.as_str()) - .fetch_optional(&mut **tx) + .fetch_optional(&mut *tx) .await .map_err(|error| RadrootsSdkError::EventStore { message: error.to_string(), })? { let existing_digest: String = row - .try_get("request_digest_sha256_hex") + .try_get("command_payload_hash") .map_err(|error| RadrootsSdkError::EventStore { message: error.to_string(), })?; - if existing_digest != request_digest { - return Err(RadrootsSdkError::IdempotencyConflict { - operation_kind: operation_kind.to_owned(), - expected_pubkey_prefix: actor.pubkey().as_str().chars().take(12).collect(), - existing_digest_prefix: digest_prefix(existing_digest.as_str()), - new_digest_prefix: digest_prefix(request_digest.as_str()), - }); + if existing_digest != command_hash { + let existing_digest_prefix = digest_prefix(existing_digest.as_str()); + let new_digest_prefix = digest_prefix(command_hash.as_str()); + let error = RadrootsSdkError::IdempotencyConflict { + operation_kind: request.operation_kind.to_owned(), + expected_pubkey_prefix: request.actor.pubkey().as_str().chars().take(12).collect(), + existing_digest_prefix: existing_digest_prefix.clone(), + new_digest_prefix: new_digest_prefix.clone(), + }; + sqlx::query( + "INSERT INTO sdk_runtime_recovery_receipt(recovery_code, operation_kind, actor_pubkey, idempotency_key, recovery_action, detail_json, created_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind("idempotency_conflict") + .bind(request.operation_kind) + .bind(request.actor.pubkey().as_str()) + .bind(idempotency_key.as_str()) + .bind("retry_operation_with_same_idempotency_key") + .bind( + serde_json::json!({ + "existing_digest_prefix": existing_digest_prefix, + "new_digest_prefix": new_digest_prefix + }) + .to_string(), + ) + .bind(observed_at_ms) + .execute(&mut *tx) + .await + .map_err(|error| RadrootsSdkError::EventStore { + message: error.to_string(), + })?; + tx.commit() + .await + .map_err(|error| RadrootsSdkError::EventStore { + message: error.to_string(), + })?; + return Err(error); } - return Ok(()); - } + let state: String = row.try_get("state").map_err(|error| RadrootsSdkError::EventStore { + message: error.to_string(), + })?; + if SdkRuntimeOperationState::from_str(state.as_str())? + == SdkRuntimeOperationState::Committed + { + let result_json: String = + row.try_get("result_json") + .map_err(|error| RadrootsSdkError::EventStore { + message: error.to_string(), + })?; + Some(workflow_receipt_from_result_json(result_json.as_str())?) + } else { + sqlx::query( + "UPDATE sdk_runtime_operation_journal SET frozen_draft_json = ?, expected_transport_id = ?, state = ?, last_error_code = NULL, last_error_detail = NULL, updated_at_ms = ? WHERE contract_version = ? AND operation_kind = ? AND actor_pubkey = ? AND idempotency_key = ?", + ) + .bind(frozen_draft_json.as_str()) + .bind(expected_transport_id) + .bind(SdkRuntimeOperationState::Prepared.as_str()) + .bind(observed_at_ms) + .bind(SDK_RUNTIME_CONTRACT_VERSION) + .bind(request.operation_kind) + .bind(request.actor.pubkey().as_str()) + .bind(idempotency_key.as_str()) + .execute(&mut *tx) + .await + .map_err(|error| RadrootsSdkError::EventStore { + message: error.to_string(), + })?; + None + } + } else { + sqlx::query( + "INSERT INTO sdk_runtime_operation_journal(contract_version, operation_kind, actor_pubkey, idempotency_key, command_payload_hash, frozen_draft_json, expected_transport_id, mutation_id, state, result_json, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(SDK_RUNTIME_CONTRACT_VERSION) + .bind(request.operation_kind) + .bind(request.actor.pubkey().as_str()) + .bind(idempotency_key.as_str()) + .bind(command_hash.as_str()) + .bind(frozen_draft_json.as_str()) + .bind(expected_transport_id) + .bind(mutation_id_from_draft(request.frozen_draft)) + .bind(SdkRuntimeOperationState::Prepared.as_str()) + .bind(Option::<String>::None) + .bind(observed_at_ms) + .bind(observed_at_ms) + .execute(&mut *tx) + .await + .map_err(|error| RadrootsSdkError::EventStore { + message: error.to_string(), + })?; + None + }; + tx.commit() + .await + .map_err(|error| RadrootsSdkError::EventStore { + message: error.to_string(), + })?; + Ok(PreparedRuntimeOperation { + idempotency_key, + committed_receipt, + }) +} +async fn mark_runtime_operation_state( + sdk: &RadrootsClient, + request: &SdkWorkflowEnqueueRequest<'_>, + idempotency_key: &SdkIdempotencyKey, + state: SdkRuntimeOperationState, + error: Option<&RadrootsSdkError>, +) -> Result<(), RadrootsSdkError> { + let observed_at_ms = sdk_now_ms(sdk)?; + let (last_error_code, last_error_detail) = match error { + Some(error) => ( + Some(error.code().to_owned()), + Some(error.detail_json().to_string()), + ), + None => (None, None), + }; sqlx::query( - "INSERT INTO sdk_runtime_operation_journal(contract_version, operation_id, actor_pubkey, idempotency_key, request_digest_sha256_hex, created_at_ms) VALUES (?, ?, ?, ?, ?, ?)", + "UPDATE sdk_runtime_operation_journal SET state = ?, last_error_code = ?, last_error_detail = ?, updated_at_ms = ? WHERE contract_version = ? AND operation_kind = ? AND actor_pubkey = ? AND idempotency_key = ?", ) - .bind("1") - .bind(operation_kind) - .bind(actor.pubkey().as_str()) - .bind(idempotency_key.as_str()) - .bind(request_digest.as_str()) + .bind(state.as_str()) + .bind(last_error_code) + .bind(last_error_detail) .bind(observed_at_ms) - .execute(&mut **tx) + .bind(SDK_RUNTIME_CONTRACT_VERSION) + .bind(request.operation_kind) + .bind(request.actor.pubkey().as_str()) + .bind(idempotency_key.as_str()) + .execute(sdk._event_store.pool()) .await .map(|_| ()) .map_err(|error| RadrootsSdkError::EventStore { @@ -365,22 +573,101 @@ async fn record_runtime_operation_journal( }) } -async fn complete_runtime_operation_journal( +async fn record_runtime_operation_failure( + sdk: &RadrootsClient, + request: &SdkWorkflowEnqueueRequest<'_>, + idempotency_key: &SdkIdempotencyKey, + error: &RadrootsSdkError, +) -> Result<(), RadrootsSdkError> { + let state = match error { + RadrootsSdkError::SignerRequestRejected { .. } + | RadrootsSdkError::SignerReturnedEventDrift { .. } + | RadrootsSdkError::SignerPubkeyMismatch { .. } + | RadrootsSdkError::UnauthorizedActor { .. } => SdkRuntimeOperationState::Rejected, + _ => SdkRuntimeOperationState::FailedRecoverable, + }; + mark_runtime_operation_state(sdk, request, idempotency_key, state, Some(error)).await?; + let recovery = match error { + RadrootsSdkError::SignerRequestTimedOut { .. } => Some(( + "signer_timeout", + "retry_operation_with_same_idempotency_key", + )), + RadrootsSdkError::IdempotencyConflict { .. } => Some(( + "idempotency_conflict", + "retry_operation_with_same_idempotency_key", + )), + _ => None, + }; + if let Some((recovery_code, recovery_action)) = recovery { + record_runtime_recovery_receipt( + sdk._event_store.pool(), + recovery_code, + Some(request.operation_kind), + Some(request.actor.pubkey().as_str()), + Some(idempotency_key.as_str()), + recovery_action, + error.detail_json(), + sdk_now_ms(sdk)?, + ) + .await?; + } + Ok(()) +} + +async fn ensure_runtime_operation_can_commit( tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, - operation_kind: &'static str, - actor: &RadrootsActorContext, + request: &SdkWorkflowEnqueueRequest<'_>, + idempotency_key: &SdkIdempotencyKey, +) -> Result<(), RadrootsSdkError> { + let row = sqlx::query( + "SELECT state FROM sdk_runtime_operation_journal WHERE contract_version = ? AND operation_kind = ? AND actor_pubkey = ? AND idempotency_key = ?", + ) + .bind(SDK_RUNTIME_CONTRACT_VERSION) + .bind(request.operation_kind) + .bind(request.actor.pubkey().as_str()) + .bind(idempotency_key.as_str()) + .fetch_one(tx.as_mut()) + .await + .map_err(|error| RadrootsSdkError::EventStore { + message: error.to_string(), + })?; + let state: String = row + .try_get("state") + .map_err(|error| RadrootsSdkError::EventStore { + message: error.to_string(), + })?; + match SdkRuntimeOperationState::from_str(state.as_str())? { + SdkRuntimeOperationState::Prepared + | SdkRuntimeOperationState::SignaturePending + | SdkRuntimeOperationState::FailedRecoverable => Ok(()), + SdkRuntimeOperationState::Committed => Err(RadrootsSdkError::InvalidRequest { + message: "SDK runtime operation is already committed".to_owned(), + }), + SdkRuntimeOperationState::Rejected => Err(RadrootsSdkError::InvalidRequest { + message: "SDK runtime operation is rejected".to_owned(), + }), + } +} + +async fn commit_runtime_operation_journal( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + request: &SdkWorkflowEnqueueRequest<'_>, idempotency_key: &SdkIdempotencyKey, + receipt: &SdkWorkflowEnqueueReceipt, observed_at_ms: i64, ) -> Result<(), RadrootsSdkError> { + let result_json = workflow_receipt_result_json(receipt); sqlx::query( - "UPDATE sdk_runtime_operation_journal SET completed_at_ms = ? WHERE contract_version = ? AND operation_id = ? AND actor_pubkey = ? AND idempotency_key = ?", + "UPDATE sdk_runtime_operation_journal SET state = ?, result_json = ?, last_error_code = NULL, last_error_detail = NULL, updated_at_ms = ? WHERE contract_version = ? AND operation_kind = ? AND actor_pubkey = ? AND idempotency_key = ?", ) + .bind(SdkRuntimeOperationState::Committed.as_str()) + .bind(result_json.to_string()) .bind(observed_at_ms) - .bind("1") - .bind(operation_kind) - .bind(actor.pubkey().as_str()) + .bind(SDK_RUNTIME_CONTRACT_VERSION) + .bind(request.operation_kind) + .bind(request.actor.pubkey().as_str()) .bind(idempotency_key.as_str()) - .execute(&mut **tx) + .execute(tx.as_mut()) .await .map(|_| ()) .map_err(|error| RadrootsSdkError::EventStore { @@ -388,17 +675,169 @@ async fn complete_runtime_operation_journal( }) } +fn workflow_receipt_result_json(receipt: &SdkWorkflowEnqueueReceipt) -> serde_json::Value { + serde_json::json!({ + "api_version": 1, + "state": "committed", + "signed_event_id": receipt.signed_event_id.as_str(), + "local_event_seq": receipt.local_event_seq, + "outbox_operation_id": receipt.outbox_operation_id, + "outbox_event_id": receipt.outbox_event_id, + "outbox_state": outbox_enqueue_status_str(receipt.state), + "idempotency_digest_prefix": receipt.idempotency_digest_prefix + }) +} + +fn workflow_receipt_from_result_json( + result_json: &str, +) -> Result<SdkWorkflowEnqueueReceipt, RadrootsSdkError> { + let value: serde_json::Value = + serde_json::from_str(result_json).map_err(|error| RadrootsSdkError::EventStore { + message: error.to_string(), + })?; + let signed_event_id = value + .get("signed_event_id") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| RadrootsSdkError::EventStore { + message: "committed SDK operation receipt is missing signed_event_id".to_owned(), + })?; + let local_event_seq = value + .get("local_event_seq") + .and_then(serde_json::Value::as_i64) + .ok_or_else(|| RadrootsSdkError::EventStore { + message: "committed SDK operation receipt is missing local_event_seq".to_owned(), + })?; + let outbox_operation_id = value + .get("outbox_operation_id") + .and_then(serde_json::Value::as_i64) + .ok_or_else(|| RadrootsSdkError::EventStore { + message: "committed SDK operation receipt is missing outbox_operation_id".to_owned(), + })?; + let outbox_event_id = value + .get("outbox_event_id") + .and_then(serde_json::Value::as_i64) + .ok_or_else(|| RadrootsSdkError::EventStore { + message: "committed SDK operation receipt is missing outbox_event_id".to_owned(), + })?; + let outbox_state = value + .get("outbox_state") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| RadrootsSdkError::EventStore { + message: "committed SDK operation receipt is missing outbox_state".to_owned(), + })?; + let idempotency_digest_prefix = value + .get("idempotency_digest_prefix") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| RadrootsSdkError::EventStore { + message: "committed SDK operation receipt is missing idempotency_digest_prefix" + .to_owned(), + })? + .to_owned(); + Ok(SdkWorkflowEnqueueReceipt { + signed_event_id: RadrootsEventId::parse(signed_event_id).map_err(|error| { + RadrootsSdkError::EventStore { + message: error.to_string(), + } + })?, + local_event_seq, + outbox_operation_id, + outbox_event_id, + state: outbox_enqueue_status_from_str(outbox_state)?, + idempotency_digest_prefix, + }) +} + +fn outbox_enqueue_status_str(status: RadrootsOutboxEnqueueStatus) -> &'static str { + match status { + RadrootsOutboxEnqueueStatus::Inserted => "inserted", + RadrootsOutboxEnqueueStatus::Existing => "existing", + } +} + +fn outbox_enqueue_status_from_str( + status: &str, +) -> Result<RadrootsOutboxEnqueueStatus, RadrootsSdkError> { + match status { + "inserted" => Ok(RadrootsOutboxEnqueueStatus::Inserted), + "existing" => Ok(RadrootsOutboxEnqueueStatus::Existing), + _ => Err(RadrootsSdkError::EventStore { + message: format!("unknown outbox enqueue status `{status}`"), + }), + } +} + +fn frozen_draft_json(frozen_draft: &RadrootsEventDraft) -> Result<String, RadrootsSdkError> { + serde_json::to_string(&serde_json::json!({ + "contract_id": frozen_draft.contract_id(), + "contract_registry_version": frozen_draft.contract_registry_version(), + "kind": frozen_draft.kind_u32(), + "created_at": frozen_draft.created_at_u64(), + "tags": frozen_draft.tags_as_vec(), + "content": frozen_draft.content(), + "expected_pubkey": frozen_draft.expected_pubkey_str(), + "expected_event_id": frozen_draft.expected_event_id_str() + })) + .map_err(|error| RadrootsSdkError::EventStore { + message: error.to_string(), + }) +} + +fn mutation_id_from_draft(frozen_draft: &RadrootsEventDraft) -> Option<String> { + if !radroots_event::kinds::TRADE_MUTATION_EVENT_KINDS.contains(&frozen_draft.kind_u32()) { + return None; + } + radroots_event::trade::trade_mutation_from_canonical_content(frozen_draft.content()) + .ok() + .and_then(|envelope| { + envelope + .mutation_id + .map(|mutation_id| mutation_id.to_string()) + }) +} + fn runtime_request_digest( - operation_kind: &'static str, - actor: &RadrootsActorContext, - frozen_draft: &RadrootsEventDraft, + request: &SdkWorkflowEnqueueRequest<'_>, + delivery_plan: &RadrootsOutboxDeliveryPlanInput, ) -> String { + let mut targets = delivery_plan + .targets + .iter() + .map(|target| { + serde_json::json!({ + "kind": target.kind.canonical_label(), + "uri": target.uri.as_str(), + "scope": target.scope.as_ref().map(|scope| scope.as_str()), + "label": target.label.as_ref().map(|label| label.as_str()), + "fingerprint": target.fingerprint.as_str() + }) + }) + .collect::<Vec<_>>(); + targets.sort_by(|left, right| { + left.get("fingerprint") + .and_then(serde_json::Value::as_str) + .cmp(&right.get("fingerprint").and_then(serde_json::Value::as_str)) + }); let digest_document = serde_json::json!({ - "contract_version": "1", - "operation_id": operation_kind, - "actor_pubkey": actor.pubkey().as_str(), - "expected_event_id": frozen_draft.expected_event_id_str(), - "expected_pubkey": frozen_draft.expected_pubkey_str(), + "contract_version": SDK_RUNTIME_CONTRACT_VERSION, + "operation_kind": request.operation_kind, + "actor_pubkey": request.actor.pubkey().as_str(), + "draft": { + "contract_id": request.frozen_draft.contract_id(), + "contract_registry_version": request.frozen_draft.contract_registry_version(), + "kind": request.frozen_draft.kind_u32(), + "created_at": request.frozen_draft.created_at_u64(), + "tags": request.frozen_draft.tags_as_vec(), + "content_sha256": hex::encode(Sha256::digest(request.frozen_draft.content().as_bytes())), + "expected_pubkey": request.frozen_draft.expected_pubkey_str(), + "expected_event_id": request.frozen_draft.expected_event_id_str() + }, + "delivery_plan": { + "transport_profile_id": delivery_plan.transport_profile_id.as_str(), + "target_policy_version": delivery_plan.target_policy_version, + "satisfaction_policy": &delivery_plan.satisfaction_policy, + "reticulum_behavior": delivery_plan.reticulum_behavior.as_str(), + "targets": targets + } }); let bytes = serde_json::to_vec(&digest_document).expect("runtime journal digest document serializes"); diff --git a/crates/sdk/tests/farms_runtime.rs b/crates/sdk/tests/farms_runtime.rs @@ -695,7 +695,9 @@ async fn farm_enqueue_publish_returns_sanitized_signer_errors_before_mutation() farmer_actor(), farm(FARM_C_D_TAG, "North Farm"), TargetPolicy::default_profile(), - ); + ) + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000238") + .expect("idempotency key"); let error = sdk .farms() .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(OTHER)) @@ -776,7 +778,7 @@ async fn farm_enqueue_publish_uses_explicit_idempotency_key_across_equivalent_ta first_receipt.idempotency_digest_prefix, second_receipt.idempotency_digest_prefix ); - assert_eq!(second_receipt.state, SdkMutationState::AlreadyQueued); + assert_eq!(second_receipt.state, SdkMutationState::StoredAndQueued); let paths = sdk.storage_paths().expect("paths"); let outbox = RadrootsOutbox::open_file(&paths.runtime_path) diff --git a/crates/sdk/tests/listings_runtime.rs b/crates/sdk/tests/listings_runtime.rs @@ -582,7 +582,10 @@ async fn enqueue_prepared_publish_returns_structured_actor_errors() { &non_seller_actor(), prepared, TargetPolicy::default_profile(), - None, + Some( + SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-00000000023e") + .expect("idempotency key"), + ), &FixtureSigner::new(SELLER), ) .await @@ -608,7 +611,10 @@ async fn enqueue_prepared_publish_returns_sanitized_signer_errors() { &actor, prepared, TargetPolicy::default_profile(), - None, + Some( + SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-00000000023f") + .expect("idempotency key"), + ), &FixtureSigner::new(OTHER), ) .await @@ -680,7 +686,9 @@ async fn enqueue_publish_returns_sanitized_signer_errors() { actor(), listing(LISTING_C_D_TAG, "Coffee"), TargetPolicy::default_profile(), - ); + ) + .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000023d") + .expect("idempotency key"); let error = sdk .listings() .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(OTHER)) @@ -788,7 +796,7 @@ async fn enqueue_publish_rolls_back_event_and_journal_when_outbox_conflicts_afte .await .expect("seed event store"); let conflicting_operation = sqlx::query( - "INSERT INTO outbox_operations(operation_kind, expected_pubkey, idempotency_key, operation_idempotency_digest, status, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, 'queued', ?, ?)", + "INSERT INTO outbox_operations(operation_kind, expected_pubkey, semantic_scope, trade_id, mutation_id, canonical_payload_sha256, idempotency_key, operation_idempotency_digest, status, created_at_ms, updated_at_ms) VALUES (?, ?, 'generic_event', NULL, NULL, NULL, ?, ?, 'queued', ?, ?)", ) .bind(LISTING_PUBLISH_OPERATION_KIND) .bind(SELLER) @@ -847,13 +855,33 @@ async fn enqueue_publish_rolls_back_event_and_journal_when_outbox_conflicts_afte .expect("event lookup") .is_none() ); - let journal_count: i64 = sqlx::query("SELECT COUNT(*) FROM sdk_runtime_operation_journal") + let journal_row = sqlx::query( + "SELECT state, last_error_code FROM sdk_runtime_operation_journal WHERE operation_kind = ? AND actor_pubkey = ? AND idempotency_key = ?", + ) + .bind(LISTING_PUBLISH_OPERATION_KIND) + .bind(SELLER) + .bind(idempotency_key) + .fetch_one(event_store.pool()) + .await + .expect("journal row"); + let journal_state: String = journal_row.try_get("state").expect("journal state"); + let last_error_code: String = journal_row + .try_get("last_error_code") + .expect("journal error code"); + assert_eq!(journal_state, "failed_recoverable"); + assert_eq!(last_error_code, "idempotency_conflict"); + let recovery_count: i64 = sqlx::query( + "SELECT COUNT(*) FROM sdk_runtime_recovery_receipt WHERE recovery_code = 'idempotency_conflict' AND operation_kind = ? AND actor_pubkey = ? AND idempotency_key = ?", + ) + .bind(LISTING_PUBLISH_OPERATION_KIND) + .bind(SELLER) + .bind(idempotency_key) .fetch_one(event_store.pool()) .await - .expect("journal count") + .expect("recovery count") .try_get(0) - .expect("journal count value"); - assert_eq!(journal_count, 0); + .expect("recovery count value"); + assert_eq!(recovery_count, 1); let outbox = RadrootsOutbox::open_file(&paths.runtime_path) .await .expect("outbox"); @@ -909,7 +937,7 @@ async fn enqueue_publish_uses_explicit_idempotency_key_across_equivalent_target_ first_receipt.idempotency_digest_prefix, second_receipt.idempotency_digest_prefix ); - assert_eq!(second_receipt.state, SdkMutationState::AlreadyQueued); + assert_eq!(second_receipt.state, SdkMutationState::StoredAndQueued); let paths = sdk.storage_paths().expect("paths"); let outbox = RadrootsOutbox::open_file(&paths.runtime_path) diff --git a/crates/sdk/tests/market_runtime.rs b/crates/sdk/tests/market_runtime.rs @@ -1,232 +0,0 @@ -#![cfg(feature = "runtime")] - -use radroots_authority::{ - RadrootsActorContext, RadrootsEventSigner, RadrootsSignerError, RadrootsSignerIdentity, -}; -use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, - RadrootsCoreQuantityPrice, RadrootsCoreUnit, -}; -use radroots_event::{ - contract::RadrootsActorRole, - draft::{RadrootsEventDraft, RadrootsSignedEvent}, - farm::RadrootsFarmRef, - ids::{RadrootsDTag, RadrootsInventoryBinId}, - listing::{ - RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, - RadrootsListingDeliveryMethod, RadrootsListingProduct, RadrootsListingPublicLocation, - RadrootsListingStatus, - }, -}; -use radroots_nostr::prelude::{ - RadrootsNostrKeys, RadrootsNostrSecretKey, radroots_nostr_sign_frozen_draft, -}; -use radroots_sdk::{ - ListingEnqueuePublishRequest, MarketSearchRequest, NostrProfile, NostrRelayUrlPolicy, - RadrootsClient, RadrootsSdkError, RadrootsSdkTimestamp, SyncProjectionRefreshRequest, - TargetPolicy, TransportProfile, -}; - -const SELLER_SECRET_KEY_HEX: &str = - "59392e9068f66431b12f70218fb61281cb6b433d7f27c55d61f1a63fe1a96ff8"; -const SELLER_PUBLIC_KEY_HEX: &str = - "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; -const FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; -const LISTING_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAg"; -const RELAY: &str = "wss://relay.radroots.test"; - -#[derive(Clone)] -struct FixtureSigner { - identity: RadrootsSignerIdentity, - keys: RadrootsNostrKeys, -} - -impl FixtureSigner { - fn new(secret_key_hex: &str) -> Self { - let secret_key = RadrootsNostrSecretKey::from_hex(secret_key_hex).expect("secret key"); - let keys = RadrootsNostrKeys::new(secret_key); - let pubkey = keys.public_key().to_hex(); - Self { - identity: RadrootsSignerIdentity::new(pubkey).expect("identity"), - keys, - } - } -} - -impl RadrootsEventSigner for FixtureSigner { - fn pubkey(&self) -> &radroots_event::ids::RadrootsPublicKey { - self.identity.pubkey() - } - - fn sign_frozen_draft( - &self, - draft: &RadrootsEventDraft, - ) -> Result<RadrootsSignedEvent, RadrootsSignerError> { - radroots_nostr_sign_frozen_draft(&self.keys, draft).map_err(|error| { - RadrootsSignerError::SigningFailed { - message: error.to_string(), - } - }) - } -} - -fn seller_actor() -> RadrootsActorContext { - RadrootsActorContext::test(SELLER_PUBLIC_KEY_HEX, [RadrootsActorRole::Seller]).expect("actor") -} - -fn decimal(raw: &str) -> RadrootsCoreDecimal { - raw.parse().expect("decimal") -} - -fn listing(title: &str) -> RadrootsListing { - RadrootsListing { - d_tag: RadrootsDTag::parse(LISTING_D_TAG).expect("d tag"), - published_at: Some(1_700_000_000), - farm: RadrootsFarmRef { - pubkey: SELLER_PUBLIC_KEY_HEX.to_owned(), - d_tag: FARM_D_TAG.to_owned(), - }, - product: RadrootsListingProduct { - key: "blueberries".to_owned(), - title: title.to_owned(), - category: "fruit".to_owned(), - summary: Some("Fresh field berries".to_owned()), - process: None, - lot: None, - location: None, - profile: None, - year: None, - }, - primary_bin_id: RadrootsInventoryBinId::parse("pint").expect("bin id"), - bins: vec![RadrootsListingBin { - bin_id: RadrootsInventoryBinId::parse("pint").expect("bin id"), - quantity: RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::Each), - price_per_canonical_unit: RadrootsCoreQuantityPrice { - amount: RadrootsCoreMoney::new(decimal("6"), RadrootsCoreCurrency::USD), - quantity: RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::Each), - }, - display_amount: None, - display_unit: None, - display_label: None, - display_price: None, - display_price_unit: None, - }], - resource_area: None, - plot: None, - discounts: None, - inventory_available: Some(decimal("12")), - availability: Some(RadrootsListingAvailability::Status { - status: RadrootsListingStatus::Active, - }), - delivery_method: Some(RadrootsListingDeliveryMethod::Pickup), - location: Some(RadrootsListingPublicLocation { - primary: "Fernwood".to_owned(), - city: Some("Victoria".to_owned()), - region: Some("BC".to_owned()), - country: Some("CA".to_owned()), - geohash: "c2b2q".to_owned(), - }), - images: None, - } -} - -async fn directory_sdk() -> (tempfile::TempDir, RadrootsClient) { - let tempdir = tempfile::tempdir().expect("tempdir"); - let sdk = RadrootsClient::builder() - .directory_storage(tempdir.path().join("sdk")) - .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)) - .transport_profile(TransportProfile::nostr( - NostrProfile::new([RELAY], NostrRelayUrlPolicy::Public).expect("Nostr profile"), - )) - .build() - .await - .expect("sdk"); - (tempdir, sdk) -} - -#[tokio::test] -async fn market_search_refreshes_local_projection_and_reads_fts() { - let (_tempdir, sdk) = directory_sdk().await; - let publish = ListingEnqueuePublishRequest::new( - seller_actor(), - listing("Blueberries"), - TargetPolicy::default_profile(), - ) - .try_with_nostr_targets([RELAY], NostrRelayUrlPolicy::Public) - .expect("target relays") - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000240") - .expect("idempotency key"); - let receipt = sdk - .listings() - .enqueue_publish_with_explicit_signer(publish, &FixtureSigner::new(SELLER_SECRET_KEY_HEX)) - .await - .expect("publish"); - - let search = sdk - .market() - .search(MarketSearchRequest::new("blueberries victoria").with_limit(10)) - .await - .expect("search"); - - assert_eq!( - search.source, - radroots_sdk::MarketSearchSource::LocalProjectionFts - ); - assert_eq!( - search.refresh.projection_id, - "radroots.product_projection.v1" - ); - assert_eq!(search.refresh.projection_version, 1); - assert_eq!(search.refresh.refreshed_at_ms, 1_700_000_000_000); - assert_eq!(search.refresh.scanned_events, 1); - assert_eq!(search.refresh.listing_upserts, 1); - assert_eq!(search.refresh.trade_upserts, 0); - assert_eq!(search.refresh.validation_receipts, 0); - assert_eq!(search.listings.len(), 1); - assert_eq!(search.listings[0].listing_event_id, receipt.signed_event_id); - assert_eq!( - search.listings[0].seller_pubkey.as_str(), - SELLER_PUBLIC_KEY_HEX - ); - assert_eq!(search.listings[0].title, "Blueberries"); - assert_eq!(search.listings[0].product_type, "fruit"); - assert_eq!( - search.listings[0].locality_city.as_deref(), - Some("Victoria") - ); - assert_eq!(search.listings[0].geohash5, "c2b2q"); - - let second = sdk - .market() - .search(MarketSearchRequest::new("").with_limit(10)) - .await - .expect("second search"); - assert_eq!(second.refresh.scanned_events, 0); - assert_eq!(second.listings.len(), 1); -} - -#[tokio::test] -async fn projection_refresh_limits_are_request_errors() { - let (_tempdir, sdk) = directory_sdk().await; - - let sync_error = sdk - .sync() - .refresh_projections(SyncProjectionRefreshRequest::new().with_limit(0)) - .await - .expect_err("sync limit"); - assert!(matches!( - sync_error, - RadrootsSdkError::InvalidRequest { .. } - )); - assert_eq!(sync_error.code(), "invalid_request"); - - let market_error = sdk - .market() - .search(MarketSearchRequest::new("berries").with_limit(0)) - .await - .expect_err("market limit"); - assert!(matches!( - market_error, - RadrootsSdkError::InvalidRequest { .. } - )); -} diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs @@ -1,4054 +0,0 @@ -#![cfg(feature = "runtime")] - -#[cfg(all(feature = "signer-adapters", feature = "local-signer"))] -use std::path::Path; -#[cfg(feature = "transport-nostr-runtime")] -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; - -#[cfg(feature = "transport-nostr-runtime")] -use futures::future::BoxFuture; -#[cfg(feature = "transport-nostr-runtime")] -use nostr::JsonUtil; -#[cfg(feature = "signer-adapters")] -use radroots_authority::RadrootsActorContext; -#[cfg(all(feature = "signer-adapters", feature = "local-signer"))] -use radroots_authority::RadrootsLocalEventSigner; -use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, -}; -#[cfg(feature = "signer-adapters")] -use radroots_event::contract::RadrootsActorRole; -use radroots_event::ids::RadrootsPublicKey; -use radroots_event::wire::RadrootsNip01EventWireParts; -use radroots_event::{ - RadrootsEventEnvelope, RadrootsEventPtr, - draft::RadrootsSignedEvent, - ids::{RadrootsEventId, RadrootsListingAddress, RadrootsOrderId}, - kinds::{KIND_LISTING, KIND_ORDER_DECISION, KIND_ORDER_REQUEST, KIND_POST}, - order::{ - RadrootsOrderDecision, RadrootsOrderDecisionOutcome, RadrootsOrderEconomicItem, - RadrootsOrderEconomicLine, RadrootsOrderEconomics, RadrootsOrderInventoryCommitment, - RadrootsOrderItem, RadrootsOrderPricingBasis, RadrootsOrderRequest, - }, -}; -use radroots_event_store::{RadrootsEventIngest, RadrootsEventStore}; -use radroots_nostr::prelude::{ - RadrootsNostrKeys, RadrootsNostrSecretKey, RadrootsNostrTimestamp, radroots_event_from_nostr, - radroots_nostr_build_event, -}; -#[cfg(feature = "signer-adapters")] -use radroots_outbox::RadrootsOutbox; -#[cfg(any(feature = "signer-adapters", feature = "transport-nostr-runtime"))] -use radroots_sdk::{NostrProfile, NostrRelayUrlPolicy, TransportProfile}; -#[cfg(feature = "signer-adapters")] -use radroots_sdk::{ - PrivacyPreflightConfirmation, PrivacyPreflightStatus, ProductSensitivityField, PublishMode, - RadrootsSdkRecoveryAction, SatisfactionPolicy, SdkMutationState, TargetPolicy, TargetSet, -}; -use radroots_sdk::{ - RadrootsClient, RadrootsSdkError, RadrootsSdkTimestamp, RadrootsTradeValidationTrustPolicy, - RadrootsTradeValidationTrustState, SdkTradeStatusIssue, SdkTradeStatusIssueKind, - SdkTradeStatusSource, TRADE_STATUS_DEFAULT_LIMIT, TRADE_STATUS_MAX_LIMIT, - TRADE_STATUS_WATCH_MAX_CAPACITY, TradeEvidenceIngestRequest, TradeRequestEvidenceIngestRequest, - TradeStatusKind, TradeStatusNextActionKind, TradeStatusRequest, TradeStatusWatchCancelState, - TradeStatusWatchRequest, -}; -#[cfg(all(feature = "signer-adapters", feature = "local-signer"))] -use radroots_sdk::{RadrootsSdkLocalKeySigner, RadrootsSdkSignerProvider}; -#[cfg(feature = "signer-adapters")] -use radroots_sdk::{ - TRADE_SUBMIT_OPERATION_KIND, TradeAcceptRequest, TradeCancelRequest, TradeDeclineRequest, - TradeEvidenceMode, TradeMutationOutcome, TradeProposeRequest, -}; -#[cfg(feature = "transport-nostr-runtime")] -use radroots_sdk::{ - TradeEvidenceQueryBranchKind, TradeResyncNostrRelayOutcomeKind, - TradeResyncNostrRelayTransportOutcomeKind, TradeResyncRequest, TradeSellerInboxRequest, - TradeValidationReceiptInspectRequest, TradeValidationReceiptListRequest, - TradeValidationReceiptVerifyRequest, -}; -#[cfg(feature = "transport-nostr-runtime")] -use radroots_trade::identity::RadrootsTradeLocator; -use radroots_trade::order::RadrootsOrderIssue; -use radroots_trade::validation_receipt::{ - RadrootsTradeValidationReceipt, RadrootsValidationReceiptProof, - RadrootsValidationReceiptProofSystem, RadrootsValidationReceiptResult, - RadrootsValidationReceiptStatement, RadrootsValidationReceiptType, RadrootsValidatorSetV1, - validation_receipt_event_build, validation_receipt_public_values_hash_hex, - validator_set_address_from_str, -}; -#[cfg(feature = "transport-nostr-runtime")] -use radroots_transport_nostr::{ - RadrootsMockRelayFetchAdapter, RadrootsRelayFetchAdapter, RadrootsRelayFetchItem, - RadrootsRelayFetchRequest, RadrootsRelayTransportError, -}; -use serde::Serialize; -use serde::ser::{self, SerializeStruct}; - -const BUYER_SECRET_KEY_HEX: &str = - "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; -const BUYER_PUBLIC_KEY_HEX: &str = - "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; -const SELLER_SECRET_KEY_HEX: &str = - "59392e9068f66431b12f70218fb61281cb6b433d7f27c55d61f1a63fe1a96ff8"; -const SELLER_PUBLIC_KEY_HEX: &str = - "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; -const SERVICE_SECRET_KEY_HEX: &str = - "48314941f2c9c01ef99f531df7b1d59a8de23dbeb45a498e5aa5f671e921931f"; -const RELAY: &str = "wss://relay.radroots.test"; - -fn signed_event_from_envelope(event: RadrootsEventEnvelope) -> RadrootsSignedEvent { - let wire = event.to_nip01_wire(); - let raw_json = serde_json::to_string(&wire).expect("raw event json"); - RadrootsSignedEvent::from_wire_verified_id(wire, raw_json).expect("signed event") -} -#[cfg(feature = "transport-nostr-runtime")] -const RELAY_B: &str = "wss://relay-b.radroots.test"; -const PERF_TOTAL_LOCAL_EVENTS: i64 = 100_000; -const PERF_TRADE_RELEVANT_EVENTS: i64 = 25_000; -const PERF_ACTIVE_TRADES: usize = 1_000; -const PERF_STATUS_P95_TARGET: Duration = Duration::from_millis(50); -const STATUS_NOISE_NON_TRADE_EVENTS: i64 = 128; -const STATUS_NOISE_TRADE_BACKGROUND_EVENTS: i64 = 64; - -#[derive(Clone, Copy)] -enum FailingSerializeFailure { - Start, - Field(usize), - End, -} - -struct FailingStructSerializer { - failure: FailingSerializeFailure, -} - -#[cfg(feature = "transport-nostr-runtime")] -#[derive(Clone, Default)] -struct CapturingRelayFetchAdapter { - filters_json: Arc<Mutex<Vec<String>>>, -} - -#[cfg(feature = "transport-nostr-runtime")] -impl CapturingRelayFetchAdapter { - fn filters_json(&self) -> Vec<String> { - self.filters_json - .lock() - .expect("captured filters lock") - .clone() - } -} - -#[cfg(feature = "transport-nostr-runtime")] -impl RadrootsRelayFetchAdapter for CapturingRelayFetchAdapter { - fn fetch<'a>( - &'a self, - request: RadrootsRelayFetchRequest, - ) -> BoxFuture<'a, Result<Vec<RadrootsRelayFetchItem>, RadrootsRelayTransportError>> { - Box::pin(async move { - let filters = request - .filters() - .iter() - .map(JsonUtil::as_json) - .collect::<Vec<_>>(); - *self.filters_json.lock().expect("captured filters lock") = filters; - Ok(vec![relay_eose(RELAY)]) - }) - } -} - -struct FailingSerializeStruct { - field_index: usize, - failure: FailingSerializeFailure, -} - -#[derive(Debug)] -struct FailingSerializeError; - -impl core::fmt::Display for FailingSerializeError { - fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - formatter.write_str("intentional serializer failure") - } -} - -impl std::error::Error for FailingSerializeError {} - -impl ser::Error for FailingSerializeError { - fn custom<T>(_message: T) -> Self - where - T: core::fmt::Display, - { - Self - } -} - -impl FailingStructSerializer { - fn start() -> Self { - Self { - failure: FailingSerializeFailure::Start, - } - } - - fn field(field_index: usize) -> Self { - Self { - failure: FailingSerializeFailure::Field(field_index), - } - } - - fn end() -> Self { - Self { - failure: FailingSerializeFailure::End, - } - } -} - -impl ser::Serializer for FailingStructSerializer { - type Ok = (); - type Error = FailingSerializeError; - type SerializeSeq = ser::Impossible<(), FailingSerializeError>; - type SerializeTuple = ser::Impossible<(), FailingSerializeError>; - type SerializeTupleStruct = ser::Impossible<(), FailingSerializeError>; - type SerializeTupleVariant = ser::Impossible<(), FailingSerializeError>; - type SerializeMap = ser::Impossible<(), FailingSerializeError>; - type SerializeStruct = FailingSerializeStruct; - type SerializeStructVariant = ser::Impossible<(), FailingSerializeError>; - - fn serialize_bool(self, _value: bool) -> Result<Self::Ok, Self::Error> { - Err(FailingSerializeError) - } - - fn serialize_i8(self, _value: i8) -> Result<Self::Ok, Self::Error> { - Err(FailingSerializeError) - } - - fn serialize_i16(self, _value: i16) -> Result<Self::Ok, Self::Error> { - Err(FailingSerializeError) - } - - fn serialize_i32(self, _value: i32) -> Result<Self::Ok, Self::Error> { - Err(FailingSerializeError) - } - - fn serialize_i64(self, _value: i64) -> Result<Self::Ok, Self::Error> { - Err(FailingSerializeError) - } - - fn serialize_u8(self, _value: u8) -> Result<Self::Ok, Self::Error> { - Err(FailingSerializeError) - } - - fn serialize_u16(self, _value: u16) -> Result<Self::Ok, Self::Error> { - Err(FailingSerializeError) - } - - fn serialize_u32(self, _value: u32) -> Result<Self::Ok, Self::Error> { - Err(FailingSerializeError) - } - - fn serialize_u64(self, _value: u64) -> Result<Self::Ok, Self::Error> { - Err(FailingSerializeError) - } - - fn serialize_f32(self, _value: f32) -> Result<Self::Ok, Self::Error> { - Err(FailingSerializeError) - } - - fn serialize_f64(self, _value: f64) -> Result<Self::Ok, Self::Error> { - Err(FailingSerializeError) - } - - fn serialize_char(self, _value: char) -> Result<Self::Ok, Self::Error> { - Err(FailingSerializeError) - } - - fn serialize_str(self, _value: &str) -> Result<Self::Ok, Self::Error> { - Err(FailingSerializeError) - } - - fn serialize_bytes(self, _value: &[u8]) -> Result<Self::Ok, Self::Error> { - Err(FailingSerializeError) - } - - fn serialize_none(self) -> Result<Self::Ok, Self::Error> { - Err(FailingSerializeError) - } - - fn serialize_some<T>(self, _value: &T) -> Result<Self::Ok, Self::Error> - where - T: ?Sized + Serialize, - { - Err(FailingSerializeError) - } - - fn serialize_unit(self) -> Result<Self::Ok, Self::Error> { - Err(FailingSerializeError) - } - - fn serialize_unit_struct(self, _name: &'static str) -> Result<Self::Ok, Self::Error> { - Err(FailingSerializeError) - } - - fn serialize_unit_variant( - self, - _name: &'static str, - _variant_index: u32, - _variant: &'static str, - ) -> Result<Self::Ok, Self::Error> { - Err(FailingSerializeError) - } - - fn serialize_newtype_struct<T>( - self, - _name: &'static str, - _value: &T, - ) -> Result<Self::Ok, Self::Error> - where - T: ?Sized + Serialize, - { - Err(FailingSerializeError) - } - - fn serialize_newtype_variant<T>( - self, - _name: &'static str, - _variant_index: u32, - _variant: &'static str, - _value: &T, - ) -> Result<Self::Ok, Self::Error> - where - T: ?Sized + Serialize, - { - Err(FailingSerializeError) - } - - fn serialize_seq(self, _len: Option<usize>) -> Result<Self::SerializeSeq, Self::Error> { - Err(FailingSerializeError) - } - - fn serialize_tuple(self, _len: usize) -> Result<Self::SerializeTuple, Self::Error> { - Err(FailingSerializeError) - } - - fn serialize_tuple_struct( - self, - _name: &'static str, - _len: usize, - ) -> Result<Self::SerializeTupleStruct, Self::Error> { - Err(FailingSerializeError) - } - - fn serialize_tuple_variant( - self, - _name: &'static str, - _variant_index: u32, - _variant: &'static str, - _len: usize, - ) -> Result<Self::SerializeTupleVariant, Self::Error> { - Err(FailingSerializeError) - } - - fn serialize_map(self, _len: Option<usize>) -> Result<Self::SerializeMap, Self::Error> { - Err(FailingSerializeError) - } - - fn serialize_struct( - self, - _name: &'static str, - _len: usize, - ) -> Result<Self::SerializeStruct, Self::Error> { - match self.failure { - FailingSerializeFailure::Start => Err(FailingSerializeError), - failure => Ok(FailingSerializeStruct { - field_index: 0, - failure, - }), - } - } - - fn serialize_struct_variant( - self, - _name: &'static str, - _variant_index: u32, - _variant: &'static str, - _len: usize, - ) -> Result<Self::SerializeStructVariant, Self::Error> { - Err(FailingSerializeError) - } -} - -impl SerializeStruct for FailingSerializeStruct { - type Ok = (); - type Error = FailingSerializeError; - - fn serialize_field<T>(&mut self, _key: &'static str, _value: &T) -> Result<(), Self::Error> - where - T: ?Sized + Serialize, - { - self.field_index += 1; - match self.failure { - FailingSerializeFailure::Field(field) if self.field_index == field => { - Err(FailingSerializeError) - } - _ => Ok(()), - } - } - - fn end(self) -> Result<Self::Ok, Self::Error> { - match self.failure { - FailingSerializeFailure::End => Err(FailingSerializeError), - _ => Ok(()), - } - } -} - -fn assert_struct_serialize_error_paths<T>(value: &T, field_count: usize) -where - T: Serialize, -{ - value - .serialize(FailingStructSerializer::start()) - .expect_err("struct start failure"); - for field_index in 1..=field_count { - value - .serialize(FailingStructSerializer::field(field_index)) - .expect_err("struct field failure"); - } - value - .serialize(FailingStructSerializer::end()) - .expect_err("struct end failure"); -} - -async fn directory_sdk_and_store() -> (tempfile::TempDir, RadrootsClient, RadrootsEventStore) { - let tempdir = tempfile::tempdir().expect("tempdir"); - let sdk = RadrootsClient::builder() - .directory_storage(tempdir.path().join("sdk")) - .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)) - .build() - .await - .expect("sdk"); - let store = RadrootsEventStore::open_file(&sdk.storage_paths().expect("paths").runtime_path) - .await - .expect("event store"); - (tempdir, sdk, store) -} - -#[cfg(feature = "transport-nostr-runtime")] -async fn directory_sdk_and_store_with_relays( - relays: &[&str], -) -> (tempfile::TempDir, RadrootsClient, RadrootsEventStore) { - let tempdir = tempfile::tempdir().expect("tempdir"); - let mut builder = RadrootsClient::builder() - .directory_storage(tempdir.path().join("sdk")) - .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)); - if !relays.is_empty() { - builder = builder.transport_profile(TransportProfile::nostr( - NostrProfile::new(relays.iter().copied(), NostrRelayUrlPolicy::Public) - .expect("Nostr profile"), - )); - } - let sdk = builder.build().await.expect("sdk"); - let store = RadrootsEventStore::open_file(&sdk.storage_paths().expect("paths").runtime_path) - .await - .expect("event store"); - (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 { - directory_sdk_with_signer_and_relays(storage_root, secret_key_hex, &[]).await -} - -#[cfg(all(feature = "signer-adapters", feature = "local-signer"))] -async fn directory_sdk_with_signer_and_relays( - storage_root: &Path, - secret_key_hex: &str, - relays: &[&str], -) -> RadrootsClient { - let secret_key = RadrootsNostrSecretKey::from_hex(secret_key_hex).expect("secret key"); - let signer_keys = RadrootsNostrKeys::new(secret_key); - let mut builder = RadrootsClient::builder() - .directory_storage(storage_root) - .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)) - .signer_provider(RadrootsSdkSignerProvider::LocalKey( - RadrootsSdkLocalKeySigner::from_event_signer( - RadrootsLocalEventSigner::new(signer_keys).expect("local event signer"), - ) - .expect("local signer"), - )); - if !relays.is_empty() { - builder = builder.transport_profile(TransportProfile::nostr( - NostrProfile::new(relays.iter().copied(), NostrRelayUrlPolicy::Public) - .expect("Nostr profile"), - )); - } - builder.build().await.expect("sdk") -} - -fn order_id(raw: &str) -> RadrootsOrderId { - RadrootsOrderId::parse(raw).expect("order id") -} - -fn status_request(raw: &str) -> TradeStatusRequest { - TradeStatusRequest::parse(raw).expect("order status request") -} - -#[cfg(feature = "signer-adapters")] -fn buyer_actor() -> RadrootsActorContext { - RadrootsActorContext::test(BUYER_PUBLIC_KEY_HEX, [RadrootsActorRole::Buyer]).expect("actor") -} - -#[cfg(feature = "signer-adapters")] -fn seller_actor() -> RadrootsActorContext { - RadrootsActorContext::test(SELLER_PUBLIC_KEY_HEX, [RadrootsActorRole::Seller]).expect("actor") -} - -fn listing_address() -> RadrootsListingAddress { - RadrootsListingAddress::parse(format!( - "{KIND_LISTING}:{SELLER_PUBLIC_KEY_HEX}:AAAAAAAAAAAAAAAAAAAAAg" - )) - .expect("listing address") -} - -fn listing_event_ptr() -> RadrootsEventPtr { - RadrootsEventPtr { - id: deterministic_event_id("listing-event").into_string(), - relays: Some(RELAY.to_owned()), - } -} - -#[cfg(feature = "signer-adapters")] -fn explicit_trade_relays() -> TargetPolicy { - TargetPolicy::explicit( - TargetSet::nostr_relays([RELAY], NostrRelayUrlPolicy::Public).expect("transport targets"), - ) -} - -#[cfg(feature = "signer-adapters")] -fn public_note_confirmation() -> PrivacyPreflightConfirmation { - PrivacyPreflightConfirmation::new().confirm(ProductSensitivityField::PublicButSensitiveNotes) -} - -#[cfg(feature = "signer-adapters")] -fn expect_enqueued<Plan, Receipt>(outcome: TradeMutationOutcome<Plan, Receipt>) -> Receipt { - match outcome { - TradeMutationOutcome::Enqueued { receipt } => receipt, - TradeMutationOutcome::DryRun { .. } => panic!("expected enqueue outcome"), - TradeMutationOutcome::Published { .. } => panic!("expected enqueue outcome"), - } -} - -fn deterministic_event_id(raw: &str) -> RadrootsEventId { - let mut bytes = [0u8; 32]; - for (index, byte) in raw.bytes().enumerate() { - let primary = index % bytes.len(); - let secondary = (index * 7 + 13) % bytes.len(); - bytes[primary] = bytes[primary] - .wrapping_add(byte) - .wrapping_add((index as u8).wrapping_mul(31)); - bytes[secondary] ^= byte.rotate_left((index % 8) as u32); - } - let mut hex = String::with_capacity(64); - for byte in bytes { - use core::fmt::Write as _; - write!(&mut hex, "{byte:02x}").expect("write hex"); - } - RadrootsEventId::parse(hex).expect("event id") -} - -fn validator_set_id() -> &'static str { - "018f3d99-7d35-7c0c-8a0f-7f3b645abcde" -} - -fn validator_set_event_id() -> RadrootsEventId { - deterministic_event_id("validator-set-event") -} - -fn validator_set_addr_raw(author_pubkey: &str) -> String { - format!("30381:{author_pubkey}:{}", validator_set_id()) -} - -fn validator_set_policy_for_validator_pubkey( - validator_pubkey: &str, -) -> RadrootsTradeValidationTrustPolicy { - let validator_set = RadrootsValidatorSetV1 { - set_id: validator_set_id().to_owned(), - validator_pubkey: RadrootsPublicKey::parse(validator_pubkey).expect("validator pubkey"), - threshold: 1, - valid_from: 1_700_000_000, - valid_until: 1_800_000_000, - protocol_contract_hash: hash32('7'), - operator_name: "Radroots validation operator".to_owned(), - operator_contact: None, - }; - RadrootsTradeValidationTrustPolicy::production().with_validator_set( - validator_set, - validator_set_address_from_str(validator_set_addr_raw(validator_pubkey)) - .expect("validator set address"), - validator_set_event_id().into_string(), - ) -} - -fn decimal(raw: &str) -> RadrootsCoreDecimal { - raw.parse().expect("decimal") -} - -fn usd(raw: &str) -> RadrootsCoreMoney { - RadrootsCoreMoney::new(decimal(raw), RadrootsCoreCurrency::USD) -} - -fn economics() -> RadrootsOrderEconomics { - RadrootsOrderEconomics { - quote_id: "quote-1".parse().expect("quote id"), - quote_version: 1, - pricing_basis: RadrootsOrderPricingBasis::ListingEvent, - currency: RadrootsCoreCurrency::USD, - items: vec![RadrootsOrderEconomicItem { - bin_id: "bin-1".parse().expect("bin id"), - bin_count: 2, - quantity_amount: decimal("1"), - quantity_unit: RadrootsCoreUnit::Each, - unit_price_amount: decimal("5"), - unit_price_currency: RadrootsCoreCurrency::USD, - line_subtotal: usd("10"), - }], - discounts: Vec::<RadrootsOrderEconomicLine>::new(), - adjustments: Vec::<RadrootsOrderEconomicLine>::new(), - subtotal: usd("10"), - discount_total: usd("0"), - adjustment_total: usd("0"), - total: usd("10"), - } -} - -fn order_request(raw_order_id: &str) -> RadrootsOrderRequest { - RadrootsOrderRequest { - order_id: order_id(raw_order_id), - listing_addr: listing_address(), - buyer_pubkey: BUYER_PUBLIC_KEY_HEX.parse().expect("buyer pubkey"), - seller_pubkey: SELLER_PUBLIC_KEY_HEX.parse().expect("seller pubkey"), - items: vec![RadrootsOrderItem { - bin_id: "bin-1".parse().expect("bin id"), - bin_count: 2, - }], - economics: economics(), - } -} - -#[cfg(feature = "signer-adapters")] -fn trade_propose_request( - raw_order_id: &str, - publish_mode: PublishMode, - satisfaction_policy: SatisfactionPolicy, -) -> TradeProposeRequest { - let order = order_request(raw_order_id); - TradeProposeRequest::new( - buyer_actor(), - listing_event_ptr(), - order, - explicit_trade_relays(), - publish_mode, - satisfaction_policy, - ) -} - -#[cfg(all( - feature = "signer-adapters", - feature = "local-signer", - feature = "transport-nostr-runtime" -))] -#[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_and_relays( - storage_root.as_path(), - BUYER_SECRET_KEY_HEX, - &[RELAY], - ) - .await; - let propose_receipt = expect_enqueued( - buyer_sdk - .trades() - .buyer() - .propose_trade( - trade_propose_request( - "trade-product-facade-flow", - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000204") - .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_and_relays( - storage_root.as_path(), - SELLER_SECRET_KEY_HEX, - &[RELAY], - ) - .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 = expect_enqueued( - 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, - SatisfactionPolicy::NoWait, - TradeEvidenceMode::LocalOnly, - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000205") - .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(TradeStatusRequest::new(propose_receipt.locator.clone())) - .await - .expect("facade status"); - assert_eq!(status.status, TradeStatusKind::AgreedPendingValidation); - assert_eq!( - status - .decision_event_id - .as_ref() - .map(RadrootsEventId::as_str), - Some(accept_receipt.signed_event_id.as_str()) - ); - - let resync_adapter = RadrootsMockRelayFetchAdapter::new(vec![relay_eose(RELAY)]); - let resync = seller_sdk - .trades() - .resync() - .resync_with_fetch_adapter( - TradeResyncRequest::new(propose_receipt.locator), - &resync_adapter, - ) - .await - .expect("facade resync"); - assert_eq!(resync.nostr_relay_urls, vec![RELAY.to_owned()]); - assert_eq!(resync.evidence.eose_count, 1); - assert_eq!( - resync.status.status, - TradeStatusKind::AgreedPendingValidation - ); - assert_eq!( - resync.status.last_event_id, - Some(accept_receipt.signed_event_id) - ); -} - -#[cfg(all( - feature = "signer-adapters", - feature = "local-signer", - feature = "transport-nostr-runtime" -))] -#[tokio::test] -async fn trade_product_clients_resync_committed_after_rhi_validation_receipt() { - let tempdir = tempfile::tempdir().expect("tempdir"); - let buyer_storage_root = tempdir.path().join("buyer-sdk"); - let seller_storage_root = tempdir.path().join("seller-sdk"); - let buyer_sdk = directory_sdk_with_signer_and_relays( - buyer_storage_root.as_path(), - BUYER_SECRET_KEY_HEX, - &[RELAY], - ) - .await; - let seller_sdk = directory_sdk_with_signer_and_relays( - seller_storage_root.as_path(), - SELLER_SECRET_KEY_HEX, - &[RELAY], - ) - .await; - let buyer_store = - RadrootsEventStore::open_file(&buyer_sdk.storage_paths().expect("paths").runtime_path) - .await - .expect("buyer event store"); - let seller_store = - RadrootsEventStore::open_file(&seller_sdk.storage_paths().expect("paths").runtime_path) - .await - .expect("seller event store"); - let propose_receipt = expect_enqueued( - buyer_sdk - .trades() - .buyer() - .propose_trade( - trade_propose_request( - "trade-product-committed-resync", - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000206") - .expect("propose idempotency"), - ) - .await - .expect("propose trade"), - ); - assert_eq!( - seller_store - .status_summary() - .await - .expect("seller isolated before proposal import") - .total_events, - 0 - ); - let seller_proposal_resync_adapter = RadrootsMockRelayFetchAdapter::new(vec![ - relay_event_item_from_store(&buyer_store, &propose_receipt.signed_event_id, RELAY, 4_000) - .await, - relay_eose(RELAY), - ]); - let seller_proposal_resync = seller_sdk - .trades() - .resync() - .resync_with_fetch_adapter( - TradeResyncRequest::new(propose_receipt.locator.clone()), - &seller_proposal_resync_adapter, - ) - .await - .expect("seller proposal resync"); - assert_eq!( - seller_proposal_resync.status.status, - TradeStatusKind::Requested - ); - assert_eq!(seller_proposal_resync.evidence.inserted_count, 1); - let accept_receipt = expect_enqueued( - 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, - SatisfactionPolicy::NoWait, - TradeEvidenceMode::LocalOnly, - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000207") - .expect("accept idempotency"), - ) - .await - .expect("accept trade"), - ); - let receipt_raw_event = signed_raw_validation_receipt_event( - "trade-product-committed-resync", - &propose_receipt.listing_event_id, - &propose_receipt.signed_event_id, - &accept_receipt.signed_event_id, - 33, - ); - let receipt_event = radroots_event_from_nostr(&receipt_raw_event); - let receipt_event_id = - RadrootsEventId::parse(receipt_raw_event.id.to_hex().as_str()).expect("receipt id"); - let service_pubkey = public_key_hex_for_secret(SERVICE_SECRET_KEY_HEX); - - seller_store - .ingest_event(RadrootsEventIngest::new( - signed_event_from_envelope(receipt_event), - 4_040, - )) - .await - .expect("ingest validation receipt"); - - let seller_resync = seller_sdk - .trades() - .resync() - .resync_with_fetch_adapter( - TradeResyncRequest::new(propose_receipt.locator.clone()), - &RadrootsMockRelayFetchAdapter::new(vec![relay_eose(RELAY)]), - ) - .await - .expect("seller resync"); - assert_eq!( - seller_resync.status.status, - TradeStatusKind::AgreedPendingValidation - ); - assert_eq!( - seller_resync.status.rhi_receipt_event_id, - Some(receipt_event_id.clone()) - ); - assert_eq!( - seller_resync.status.last_event_id, - Some(accept_receipt.signed_event_id.clone()) - ); - assert_eq!( - seller_resync - .status - .validation_trust - .as_ref() - .map(|trust| trust.state), - Some(RadrootsTradeValidationTrustState::Untrusted) - ); - - let trusted_local_policy = validator_set_policy_for_validator_pubkey(service_pubkey.as_str()); - let seller_trusted_local = seller_sdk - .trades() - .status( - TradeStatusRequest::new(propose_receipt.locator.clone()) - .with_validation_trust_policy(trusted_local_policy), - ) - .await - .expect("seller trusted local status"); - assert_eq!(seller_trusted_local.status, TradeStatusKind::Committed); - let seller_trust = seller_trusted_local - .validation_trust - .as_ref() - .expect("seller validation trust"); - assert_eq!( - seller_trust.state, - RadrootsTradeValidationTrustState::ValidatorSetCommitted - ); - assert!(seller_trust.production_committed); - assert_eq!( - seller_trust - .receipt_author - .as_ref() - .map(|pubkey| pubkey.as_str()), - Some(service_pubkey.as_str()) - ); - - let buyer_committed_resync_adapter = RadrootsMockRelayFetchAdapter::new(vec![ - relay_event_item_from_store(&seller_store, &accept_receipt.signed_event_id, RELAY, 4_100) - .await, - relay_raw_event_item(&receipt_raw_event, RELAY, 4_200), - relay_eose(RELAY), - ]); - let buyer_resync = buyer_sdk - .trades() - .resync() - .resync_with_fetch_adapter( - TradeResyncRequest::new(propose_receipt.locator), - &buyer_committed_resync_adapter, - ) - .await - .expect("buyer resync"); - assert_eq!( - buyer_resync.status.status, - TradeStatusKind::AgreedPendingValidation - ); - assert_eq!( - buyer_resync.status.rhi_receipt_event_id, - Some(receipt_event_id) - ); - assert_eq!(buyer_resync.evidence.inserted_count, 2); -} - -#[tokio::test] -async fn trade_status_trust_policy_requires_trusted_cryptographic_receipt_for_committed_confidence() -{ - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let order_id = "trade-status-trusted-crypto"; - let request_event = signed_order_request_event(order_id, 70); - let request_event_id = RadrootsEventId::parse(request_event.id_str()).expect("request id"); - let decision_event = signed_order_decision_event(order_id, &request_event_id, 71); - let decision_event_id = RadrootsEventId::parse(decision_event.id_str()).expect("decision id"); - let listing_event_id = deterministic_event_id("listing-event"); - let receipt_raw_event = signed_raw_sp1_validation_receipt_event( - order_id, - &listing_event_id, - &request_event_id, - &decision_event_id, - 72, - ); - let receipt_event = radroots_event_from_nostr(&receipt_raw_event); - let receipt_event_id = - RadrootsEventId::parse(receipt_raw_event.id.to_hex()).expect("receipt id"); - for (event, observed_at_ms) in [ - (request_event, 7_000), - (decision_event, 7_100), - (receipt_event, 7_200), - ] { - store - .ingest_event(RadrootsEventIngest::new( - signed_event_from_envelope(event), - observed_at_ms, - )) - .await - .expect("ingest trade status trust event"); - } - - let default_status = sdk - .trades() - .status(status_request(order_id)) - .await - .expect("default status"); - assert_eq!( - default_status.status, - TradeStatusKind::AgreedPendingValidation - ); - assert_eq!( - default_status - .validation_trust - .as_ref() - .map(|trust| trust.state), - Some(RadrootsTradeValidationTrustState::Untrusted) - ); - - let service_pubkey = public_key_hex_for_secret(SERVICE_SECRET_KEY_HEX); - let trusted_policy = validator_set_policy_for_validator_pubkey(service_pubkey.as_str()); - let trusted_status = sdk - .trades() - .status(status_request(order_id).with_validation_trust_policy(trusted_policy)) - .await - .expect("trusted status"); - - assert_eq!(trusted_status.status, TradeStatusKind::Committed); - assert_eq!( - trusted_status.rhi_receipt_event_id, - Some(receipt_event_id.clone()) - ); - assert!(trusted_status.lifecycle_terminal); - assert_eq!( - trusted_status.next_action, - TradeStatusNextActionKind::Terminal - ); - let trust = trusted_status.validation_trust.expect("trusted decision"); - assert_eq!( - trust.state, - RadrootsTradeValidationTrustState::CryptographicCommitted - ); - assert!(trust.production_committed); - assert!(trust.cryptographic_proof_required); - assert!(trust.cryptographic_proof_verified); - assert_eq!(trust.proof_system.as_deref(), Some("sp1_core")); - assert_eq!( - trust.receipt_author.as_ref().map(|pubkey| pubkey.as_str()), - Some(service_pubkey.as_str()) - ); -} - -#[cfg(all( - feature = "signer-adapters", - feature = "local-signer", - feature = "transport-nostr-runtime" -))] -#[tokio::test] -async fn trade_product_accept_resync_before_mutation_imports_relay_visible_request() { - let tempdir = tempfile::tempdir().expect("tempdir"); - let buyer_storage_root = tempdir.path().join("buyer-sdk"); - let seller_storage_root = tempdir.path().join("seller-sdk"); - let buyer_sdk = directory_sdk_with_signer_and_relays( - buyer_storage_root.as_path(), - BUYER_SECRET_KEY_HEX, - &[RELAY], - ) - .await; - let seller_sdk = directory_sdk_with_signer_and_relays( - seller_storage_root.as_path(), - SELLER_SECRET_KEY_HEX, - &[RELAY], - ) - .await; - let buyer_store = - RadrootsEventStore::open_file(&buyer_sdk.storage_paths().expect("paths").runtime_path) - .await - .expect("buyer event store"); - let seller_store = - RadrootsEventStore::open_file(&seller_sdk.storage_paths().expect("paths").runtime_path) - .await - .expect("seller event store"); - let propose_receipt = expect_enqueued( - buyer_sdk - .trades() - .buyer() - .propose_trade( - trade_propose_request( - "01890f0e-6c00-7000-8000-000000000209", - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000208") - .expect("propose idempotency"), - ) - .await - .expect("propose trade"), - ); - assert_eq!( - seller_store - .status_summary() - .await - .expect("seller isolated before mutation") - .total_events, - 0 - ); - let adapter = RadrootsMockRelayFetchAdapter::new(vec![ - relay_event_item_from_store(&buyer_store, &propose_receipt.signed_event_id, RELAY, 4_300) - .await, - relay_eose(RELAY), - ]); - let accept_receipt = expect_enqueued( - seller_sdk - .trades() - .seller() - .accept_trade_with_fetch_adapter( - 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, - SatisfactionPolicy::NoWait, - TradeEvidenceMode::ResyncBeforeMutation, - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000209") - .expect("accept idempotency"), - &adapter, - ) - .await - .expect("accept trade"), - ); - assert_eq!( - accept_receipt.request_event_id, - propose_receipt.signed_event_id - ); - let status = seller_sdk - .trades() - .status(TradeStatusRequest::new(propose_receipt.locator)) - .await - .expect("seller status after accept"); - assert_eq!(status.status, TradeStatusKind::AgreedPendingValidation); - assert_eq!( - status.decision_event_id, - Some(accept_receipt.signed_event_id) - ); - assert_eq!( - seller_store - .status_summary() - .await - .expect("seller imported request and decision") - .total_events, - 2 - ); -} - -#[cfg(all( - feature = "signer-adapters", - feature = "local-signer", - feature = "transport-nostr-runtime" -))] -#[tokio::test] -async fn trade_product_accept_local_only_does_not_fetch_nostr_evidence() { - let tempdir = tempfile::tempdir().expect("tempdir"); - let buyer_storage_root = tempdir.path().join("buyer-sdk"); - let seller_storage_root = tempdir.path().join("seller-sdk"); - let buyer_sdk = directory_sdk_with_signer_and_relays( - buyer_storage_root.as_path(), - BUYER_SECRET_KEY_HEX, - &[RELAY], - ) - .await; - let seller_sdk = directory_sdk_with_signer_and_relays( - seller_storage_root.as_path(), - SELLER_SECRET_KEY_HEX, - &[RELAY], - ) - .await; - let seller_store = - RadrootsEventStore::open_file(&seller_sdk.storage_paths().expect("paths").runtime_path) - .await - .expect("seller event store"); - let propose_receipt = expect_enqueued( - buyer_sdk - .trades() - .buyer() - .propose_trade( - trade_propose_request( - "trade-product-local-only-no-fetch", - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000020a") - .expect("propose idempotency"), - ) - .await - .expect("propose trade"), - ); - let adapter = RadrootsMockRelayFetchAdapter::new(vec![relay_closed(RELAY, "must not fetch")]); - let error = seller_sdk - .trades() - .seller() - .accept_trade_with_fetch_adapter( - TradeAcceptRequest::new( - seller_actor(), - propose_receipt.locator, - vec![RadrootsOrderInventoryCommitment { - bin_id: "bin-1".parse().expect("bin id"), - bin_count: 2, - }], - explicit_trade_relays(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - TradeEvidenceMode::LocalOnly, - ), - &adapter, - ) - .await - .expect_err("local-only accept without local evidence"); - let RadrootsSdkError::InvalidRequest { message } = error else { - panic!("expected invalid request"); - }; - assert!(message.contains("trade.accept requires a locally projected trade")); - assert_eq!( - seller_store - .status_summary() - .await - .expect("seller remains empty") - .total_events, - 0 - ); -} - -#[cfg(all(feature = "signer-adapters", feature = "local-signer"))] -#[tokio::test] -async fn trade_product_accept_require_explicit_evidence_ingests_supplied_request() { - let tempdir = tempfile::tempdir().expect("tempdir"); - let buyer_storage_root = tempdir.path().join("buyer-sdk"); - let seller_storage_root = tempdir.path().join("seller-sdk"); - let buyer_sdk = - directory_sdk_with_signer(buyer_storage_root.as_path(), BUYER_SECRET_KEY_HEX).await; - let seller_sdk = - directory_sdk_with_signer(seller_storage_root.as_path(), SELLER_SECRET_KEY_HEX).await; - let buyer_store = - RadrootsEventStore::open_file(&buyer_sdk.storage_paths().expect("paths").runtime_path) - .await - .expect("buyer event store"); - let seller_store = - RadrootsEventStore::open_file(&seller_sdk.storage_paths().expect("paths").runtime_path) - .await - .expect("seller event store"); - let propose_receipt = expect_enqueued( - buyer_sdk - .trades() - .buyer() - .propose_trade( - trade_propose_request( - "01890f0e-6c00-7000-8000-00000000020c", - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000020b") - .expect("propose idempotency"), - ) - .await - .expect("propose trade"), - ); - let request_event = event_from_store(&buyer_store, &propose_receipt.signed_event_id).await; - let accept_receipt = expect_enqueued( - 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, - SatisfactionPolicy::NoWait, - TradeEvidenceMode::require_explicit_evidence([ - TradeEvidenceIngestRequest::new(signed_event_from_envelope(request_event)), - ]), - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000020c") - .expect("accept idempotency"), - ) - .await - .expect("accept trade"), - ); - assert_eq!( - accept_receipt.request_event_id, - propose_receipt.signed_event_id - ); - let status = seller_sdk - .trades() - .status(TradeStatusRequest::new(propose_receipt.locator)) - .await - .expect("seller explicit status after accept"); - assert_eq!(status.status, TradeStatusKind::AgreedPendingValidation); - assert_eq!( - seller_store - .status_summary() - .await - .expect("seller imported request and decision") - .total_events, - 2 - ); -} - -#[cfg(all(feature = "signer-adapters", feature = "local-signer"))] -#[tokio::test] -async fn trade_product_accept_require_explicit_evidence_rejects_empty_evidence() { - let tempdir = tempfile::tempdir().expect("tempdir"); - let buyer_storage_root = tempdir.path().join("buyer-sdk"); - let seller_storage_root = tempdir.path().join("seller-sdk"); - let buyer_sdk = - directory_sdk_with_signer(buyer_storage_root.as_path(), BUYER_SECRET_KEY_HEX).await; - let seller_sdk = - directory_sdk_with_signer(seller_storage_root.as_path(), SELLER_SECRET_KEY_HEX).await; - let seller_store = - RadrootsEventStore::open_file(&seller_sdk.storage_paths().expect("paths").runtime_path) - .await - .expect("seller event store"); - let propose_receipt = expect_enqueued( - buyer_sdk - .trades() - .buyer() - .propose_trade( - trade_propose_request( - "trade-product-empty-explicit-accept", - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000020d") - .expect("propose idempotency"), - ) - .await - .expect("propose trade"), - ); - let error = seller_sdk - .trades() - .seller() - .accept_trade(TradeAcceptRequest::new( - seller_actor(), - propose_receipt.locator, - vec![RadrootsOrderInventoryCommitment { - bin_id: "bin-1".parse().expect("bin id"), - bin_count: 2, - }], - explicit_trade_relays(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - TradeEvidenceMode::require_explicit_evidence(Vec::<TradeEvidenceIngestRequest>::new()), - )) - .await - .expect_err("empty explicit evidence"); - let RadrootsSdkError::InvalidRequest { message } = error else { - panic!("expected invalid request"); - }; - assert_eq!(message, "trade.accept requires explicit trade evidence"); - assert_eq!( - seller_store - .status_summary() - .await - .expect("seller remains empty") - .total_events, - 0 - ); -} - -#[cfg(feature = "transport-nostr-runtime")] -#[tokio::test] -async fn trade_validation_receipts_fetch_from_relays_and_inspect_receipt() { - let (_tempdir, sdk, store) = directory_sdk_and_store_with_relays(&[RELAY]).await; - let order_id = "trade-validation-receipts-sdk"; - let listing_event_id = deterministic_event_id("validation-receipt-listing"); - let root_event_id = deterministic_event_id("validation-receipt-request"); - let target_event_id = deterministic_event_id("validation-receipt-decision"); - let receipt_raw_event = signed_raw_validation_receipt_event( - order_id, - &listing_event_id, - &root_event_id, - &target_event_id, - 33, - ); - let receipt_event_id = - RadrootsEventId::parse(receipt_raw_event.id.to_hex()).expect("receipt event id"); - let adapter = RadrootsMockRelayFetchAdapter::new(vec![ - relay_raw_event_item(&receipt_raw_event, RELAY, 4_000), - relay_eose(RELAY), - ]); - - let list = sdk - .trades() - .validation_receipts() - .list_with_fetch_adapter( - TradeValidationReceiptListRequest::parse(order_id).expect("list request"), - &adapter, - ) - .await - .expect("validation receipt list"); - - assert_eq!(list.nostr_relay_urls, vec![RELAY.to_owned()]); - assert_eq!(list.receipts.len(), 1); - assert!(list.invalid_receipts.is_empty()); - assert_eq!(list.nostr_evidence.out_of_filter_count, 0); - assert_eq!(list.receipts[0].event.id_str(), receipt_event_id.as_str()); - assert_eq!( - list.receipts[0].tags.root_event_id.as_str(), - root_event_id.as_str() - ); - - let inspect = sdk - .trades() - .validation_receipts() - .inspect_with_fetch_adapter( - TradeValidationReceiptInspectRequest::new(receipt_event_id.clone()), - &adapter, - ) - .await - .expect("validation receipt inspect"); - - assert_eq!(inspect.receipt_event_id, receipt_event_id); - assert!(inspect.invalid_receipt.is_none()); - assert_eq!( - inspect - .receipt - .as_ref() - .map(|receipt| receipt.tags.target_event_id.as_str()), - Some(target_event_id.as_str()) - ); - - let verify = sdk - .trades() - .validation_receipts() - .verify_with_fetch_adapter( - TradeValidationReceiptVerifyRequest::new(receipt_event_id.clone()), - &adapter, - ) - .await - .expect("validation receipt verify"); - assert_eq!( - verify - .receipt - .as_ref() - .map(|receipt| receipt.tags.listing_event_id.as_str()), - Some(listing_event_id.as_str()) - ); - assert!( - store - .get_event(receipt_event_id.as_str()) - .await - .expect("stored receipt lookup") - .is_some() - ); -} - -#[cfg(feature = "transport-nostr-runtime")] -#[tokio::test] -async fn trade_validation_receipt_list_rejects_out_of_filter_order_receipts() { - let (_tempdir, sdk, store) = directory_sdk_and_store_with_relays(&[RELAY]).await; - let requested_order_id = "validation-receipt-list-requested"; - let unrelated_order_id = "validation-receipt-list-unrelated"; - let unrelated_receipt = signed_raw_validation_receipt_event( - unrelated_order_id, - &deterministic_event_id("validation-receipt-list-unrelated-listing"), - &deterministic_event_id("validation-receipt-list-unrelated-request"), - &deterministic_event_id("validation-receipt-list-unrelated-decision"), - 36, - ); - let unrelated_receipt_id = - RadrootsEventId::parse(unrelated_receipt.id.to_hex()).expect("unrelated receipt id"); - let adapter = RadrootsMockRelayFetchAdapter::new(vec![ - relay_raw_event_item(&unrelated_receipt, RELAY, 4_010), - relay_eose(RELAY), - ]); - - let list = sdk - .trades() - .validation_receipts() - .list_with_fetch_adapter( - TradeValidationReceiptListRequest::parse(requested_order_id).expect("list request"), - &adapter, - ) - .await - .expect("validation receipt list"); - - assert!(list.receipts.is_empty()); - assert!(list.invalid_receipts.is_empty()); - assert_eq!(list.nostr_evidence.inserted_count, 0); - assert_eq!(list.nostr_evidence.out_of_filter_count, 1); - assert!(list.nostr_evidence.events[0].out_of_filter); - assert!( - store - .get_event(unrelated_receipt_id.as_str()) - .await - .expect("unrelated receipt lookup") - .is_none() - ); -} - -#[cfg(feature = "transport-nostr-runtime")] -#[tokio::test] -async fn trade_validation_receipt_inspect_rejects_unrequested_relay_receipts() { - let (_tempdir, sdk, store) = directory_sdk_and_store_with_relays(&[RELAY]).await; - let requested_receipt = signed_raw_validation_receipt_event( - "validation-receipt-inspect-requested", - &deterministic_event_id("validation-receipt-inspect-requested-listing"), - &deterministic_event_id("validation-receipt-inspect-requested-request"), - &deterministic_event_id("validation-receipt-inspect-requested-decision"), - 37, - ); - let requested_receipt_id = - RadrootsEventId::parse(requested_receipt.id.to_hex()).expect("requested receipt id"); - let unrelated_receipt = signed_raw_validation_receipt_event( - "validation-receipt-inspect-unrelated", - &deterministic_event_id("validation-receipt-inspect-unrelated-listing"), - &deterministic_event_id("validation-receipt-inspect-unrelated-request"), - &deterministic_event_id("validation-receipt-inspect-unrelated-decision"), - 38, - ); - let unrelated_receipt_id = - RadrootsEventId::parse(unrelated_receipt.id.to_hex()).expect("unrelated receipt id"); - let adapter = RadrootsMockRelayFetchAdapter::new(vec![ - relay_raw_event_item(&unrelated_receipt, RELAY, 4_020), - relay_eose(RELAY), - ]); - - let inspect = sdk - .trades() - .validation_receipts() - .inspect_with_fetch_adapter( - TradeValidationReceiptInspectRequest::new(requested_receipt_id.clone()), - &adapter, - ) - .await - .expect("validation receipt inspect"); - - assert_eq!(inspect.receipt_event_id, requested_receipt_id); - assert!(inspect.receipt.is_none()); - assert!(inspect.invalid_receipt.is_none()); - assert_eq!(inspect.nostr_evidence.inserted_count, 0); - assert_eq!(inspect.nostr_evidence.out_of_filter_count, 1); - assert!(inspect.nostr_evidence.events[0].out_of_filter); - assert!( - store - .get_event(unrelated_receipt_id.as_str()) - .await - .expect("unrelated receipt lookup") - .is_none() - ); -} - -#[cfg(feature = "transport-nostr-runtime")] -#[tokio::test] -async fn trade_validation_receipt_inspect_skips_noise_before_exact_receipt_match() { - let (_tempdir, sdk, store) = directory_sdk_and_store_with_relays(&[RELAY]).await; - let requested_receipt = signed_raw_validation_receipt_event( - "validation-receipt-inspect-noise-requested", - &deterministic_event_id("validation-receipt-inspect-noise-requested-listing"), - &deterministic_event_id("validation-receipt-inspect-noise-requested-request"), - &deterministic_event_id("validation-receipt-inspect-noise-requested-decision"), - 39, - ); - let requested_receipt_id = - RadrootsEventId::parse(requested_receipt.id.to_hex()).expect("requested receipt id"); - let unrelated_receipt = signed_raw_validation_receipt_event( - "validation-receipt-inspect-noise-unrelated", - &deterministic_event_id("validation-receipt-inspect-noise-unrelated-listing"), - &deterministic_event_id("validation-receipt-inspect-noise-unrelated-request"), - &deterministic_event_id("validation-receipt-inspect-noise-unrelated-decision"), - 40, - ); - let adapter = RadrootsMockRelayFetchAdapter::new(vec![ - relay_malformed(RELAY), - relay_raw_event_item(&unrelated_receipt, RELAY, 4_021), - relay_raw_event_item(&requested_receipt, RELAY, 4_022), - relay_eose(RELAY), - ]); - - let inspect = sdk - .trades() - .validation_receipts() - .inspect_with_fetch_adapter( - TradeValidationReceiptInspectRequest::new(requested_receipt_id.clone()), - &adapter, - ) - .await - .expect("validation receipt inspect"); - - assert_eq!(inspect.receipt_event_id, requested_receipt_id); - assert!(inspect.receipt.is_some()); - assert!(inspect.invalid_receipt.is_none()); - assert_eq!(inspect.nostr_evidence.inserted_count, 1); - assert_eq!(inspect.nostr_evidence.malformed_count, 1); - assert_eq!(inspect.nostr_evidence.out_of_filter_count, 1); - assert_eq!(inspect.nostr_evidence.skipped_over_limit_count, 0); - assert!( - inspect - .nostr_evidence - .events - .iter() - .any(|event| event.malformed) - ); - assert!( - inspect - .nostr_evidence - .events - .iter() - .any(|event| event.out_of_filter) - ); - assert!( - store - .get_event(requested_receipt_id.as_str()) - .await - .expect("requested receipt lookup") - .is_some() - ); -} - -#[cfg(feature = "transport-nostr-runtime")] -#[tokio::test] -async fn trade_resync_imports_nostr_evidence_into_empty_local_store() { - let (_tempdir, sdk, store) = directory_sdk_and_store_with_relays(&[RELAY]).await; - let request_event = signed_raw_order_request_event("resync-empty-local-import", 41); - let request_event_id = - RadrootsEventId::parse(request_event.id.to_hex().as_str()).expect("event id"); - let adapter = RadrootsMockRelayFetchAdapter::new(vec![ - relay_raw_event_item(&request_event, RELAY, 5_000), - relay_eose(RELAY), - ]); - - let resync = sdk - .trades() - .resync() - .resync_with_fetch_adapter( - TradeResyncRequest::new(RadrootsTradeLocator::from_order_id(order_id( - "resync-empty-local-import", - ))), - &adapter, - ) - .await - .expect("resync"); - - assert_eq!(resync.status.status, TradeStatusKind::Requested); - assert_eq!(resync.evidence.inserted_count, 1); - assert_eq!(resync.evidence.duplicate_count, 0); - assert_eq!(resync.evidence.query_plan.branches.len(), 4); - assert!( - resync - .evidence - .query_plan - .branches - .iter() - .any(|branch| branch.kind == TradeEvidenceQueryBranchKind::RequestRoots) - ); - let request_branch = resync - .evidence - .branches - .iter() - .find(|branch| branch.branch == TradeEvidenceQueryBranchKind::RequestRoots) - .expect("request root branch"); - assert_eq!(request_branch.accepted_count, 1); - assert_eq!(request_branch.inserted_count, 1); - assert!(!request_branch.empty_result); - let lifecycle_branch = resync - .evidence - .branches - .iter() - .find(|branch| branch.branch == TradeEvidenceQueryBranchKind::LifecycleChain) - .expect("lifecycle branch"); - assert!(lifecycle_branch.empty_result); - assert_eq!( - resync.evidence.events[0].event_id.as_deref(), - Some(request_event_id.as_str()) - ); - assert!( - store - .get_event(request_event_id.as_str()) - .await - .expect("stored event") - .is_some() - ); -} - -#[cfg(feature = "transport-nostr-runtime")] -#[tokio::test] -async fn trade_status_local_only_ignores_failing_fetch_adapter() { - let (_tempdir, sdk, _store) = directory_sdk_and_store_with_relays(&[RELAY]).await; - let adapter = RadrootsMockRelayFetchAdapter::new(vec![relay_closed(RELAY, "must not fetch")]); - - let status = sdk - .trades() - .status_with_fetch_adapter( - status_request("status-local-only-no-fetch") - .with_source(SdkTradeStatusSource::LocalOnly), - &adapter, - ) - .await - .expect("local-only status"); - - assert_eq!(status.source, SdkTradeStatusSource::LocalOnly); - assert_eq!(status.status, TradeStatusKind::Missing); - assert!(status.online_evidence.is_none()); -} - -#[cfg(feature = "transport-nostr-runtime")] -#[tokio::test] -async fn trade_status_resync_then_local_fetches_evidence_before_status() { - let (_tempdir, sdk, store) = directory_sdk_and_store_with_relays(&[RELAY]).await; - let request_event = signed_raw_order_request_event("status-resync-then-local", 46); - let request_event_id = - RadrootsEventId::parse(request_event.id.to_hex().as_str()).expect("request id"); - let adapter = RadrootsMockRelayFetchAdapter::new(vec![ - relay_raw_event_item(&request_event, RELAY, 5_250), - relay_eose(RELAY), - ]); - - let status = sdk - .trades() - .status_with_fetch_adapter( - status_request("status-resync-then-local") - .with_source(SdkTradeStatusSource::ResyncThenLocal), - &adapter, - ) - .await - .expect("resync-then-local status"); - - assert_eq!(status.source, SdkTradeStatusSource::ResyncThenLocal); - assert_eq!(status.status, TradeStatusKind::Requested); - assert_eq!(status.request_event_id, Some(request_event_id.clone())); - let evidence = status.online_evidence.as_ref().expect("online evidence"); - assert_eq!(evidence.inserted_count, 1); - let request_branch = evidence - .branches - .iter() - .find(|branch| branch.branch == TradeEvidenceQueryBranchKind::RequestRoots) - .expect("request branch"); - assert_eq!(request_branch.accepted_count, 1); - assert_eq!( - request_branch.events[0].event_id.as_deref(), - Some(request_event_id.as_str()) - ); - assert!( - store - .get_event(request_event_id.as_str()) - .await - .expect("stored event") - .is_some() - ); -} - -#[cfg(feature = "transport-nostr-runtime")] -#[tokio::test] -async fn trade_resync_skips_noise_before_matching_trade_event() { - let (_tempdir, sdk, store) = directory_sdk_and_store_with_relays(&[RELAY]).await; - let requested_order_id = order_id("resync-noise-requested"); - let request_event = signed_raw_order_request_event(requested_order_id.as_str(), 44); - let request_event_id = - RadrootsEventId::parse(request_event.id.to_hex()).expect("request event id"); - let unrelated_event = signed_raw_order_request_event("resync-noise-unrelated", 45); - let adapter = RadrootsMockRelayFetchAdapter::new(vec![ - relay_malformed(RELAY), - relay_raw_event_item(&unrelated_event, RELAY, 5_020), - relay_raw_event_item(&request_event, RELAY, 5_021), - relay_eose(RELAY), - ]); - - let resync = sdk - .trades() - .resync() - .resync_with_fetch_adapter( - TradeResyncRequest::new(RadrootsTradeLocator::from_order_id(requested_order_id)), - &adapter, - ) - .await - .expect("resync"); - - assert_eq!(resync.status.status, TradeStatusKind::Requested); - assert_eq!(resync.evidence.inserted_count, 1); - assert_eq!(resync.evidence.malformed_count, 1); - assert_eq!(resync.evidence.out_of_filter_count, 1); - assert_eq!(resync.evidence.skipped_over_limit_count, 0); - assert!( - store - .get_event(request_event_id.as_str()) - .await - .expect("stored event") - .is_some() - ); -} - -#[cfg(feature = "transport-nostr-runtime")] -#[tokio::test] -async fn trade_resync_duplicate_replay_is_idempotent() { - let (_tempdir, sdk, _store) = directory_sdk_and_store_with_relays(&[RELAY]).await; - let request_event = signed_raw_order_request_event("resync-duplicate-replay", 42); - let adapter = RadrootsMockRelayFetchAdapter::new(vec![ - relay_raw_event_item(&request_event, RELAY, 5_100), - relay_eose(RELAY), - ]); - let locator = RadrootsTradeLocator::from_order_id(order_id("resync-duplicate-replay")); - - let first = sdk - .trades() - .resync() - .resync_with_fetch_adapter(TradeResyncRequest::new(locator.clone()), &adapter) - .await - .expect("first resync"); - let second = sdk - .trades() - .resync() - .resync_with_fetch_adapter(TradeResyncRequest::new(locator), &adapter) - .await - .expect("second resync"); - - assert_eq!(first.evidence.inserted_count, 1); - assert_eq!(second.evidence.inserted_count, 0); - assert_eq!(second.evidence.duplicate_count, 1); - assert_eq!(second.status.status, TradeStatusKind::Requested); -} - -#[cfg(feature = "transport-nostr-runtime")] -#[tokio::test] -async fn trade_resync_splits_lifecycle_branch_into_single_kind_filters() { - let (_tempdir, sdk, _store) = directory_sdk_and_store_with_relays(&[RELAY]).await; - let adapter = CapturingRelayFetchAdapter::default(); - sdk.trades() - .resync() - .resync_with_fetch_adapter( - TradeResyncRequest::new(RadrootsTradeLocator::from_order_id(order_id( - "resync-lifecycle-filter-shape", - ))), - &adapter, - ) - .await - .expect("resync"); - - let filters = adapter.filters_json(); - assert_eq!(filters.len(), 4); - for kind in [3422, 3423, 3432, 3440] { - assert!( - filters - .iter() - .any(|filter| filter.contains(format!("\"kinds\":[{kind}]").as_str())), - "missing single-kind filter for {kind}: {filters:?}" - ); - } - assert!( - !filters - .iter() - .any(|filter| filter.contains("\"kinds\":[3423,3424,3425,3432]")) - ); -} - -#[cfg(feature = "transport-nostr-runtime")] -#[tokio::test] -async fn trade_resync_reports_malformed_evidence_without_poisoning_store() { - let (_tempdir, sdk, store) = directory_sdk_and_store_with_relays(&[RELAY]).await; - let adapter = - RadrootsMockRelayFetchAdapter::new(vec![relay_malformed(RELAY), relay_eose(RELAY)]); - - let resync = sdk - .trades() - .resync() - .resync_with_fetch_adapter( - TradeResyncRequest::new(RadrootsTradeLocator::from_order_id(order_id( - "resync-malformed-evidence", - ))), - &adapter, - ) - .await - .expect("resync"); - - assert_eq!(resync.status.status, TradeStatusKind::Missing); - assert_eq!(resync.evidence.malformed_count, 1); - assert_eq!(resync.evidence.inserted_count, 0); - assert_eq!( - store - .status_summary() - .await - .expect("store summary") - .total_events, - 0 - ); -} - -#[cfg(feature = "transport-nostr-runtime")] -#[tokio::test] -async fn trade_resync_rejects_out_of_filter_evidence_without_poisoning_store() { - let (_tempdir, sdk, store) = directory_sdk_and_store_with_relays(&[RELAY]).await; - let unrelated_event = signed_raw_order_request_event("resync-out-of-filter-unrelated", 43); - let unrelated_event_id = - RadrootsEventId::parse(unrelated_event.id.to_hex()).expect("unrelated event id"); - let adapter = RadrootsMockRelayFetchAdapter::new(vec![ - relay_raw_event_item(&unrelated_event, RELAY, 5_150), - relay_eose(RELAY), - ]); - - let resync = sdk - .trades() - .resync() - .resync_with_fetch_adapter( - TradeResyncRequest::new(RadrootsTradeLocator::from_order_id(order_id( - "resync-out-of-filter-requested", - ))), - &adapter, - ) - .await - .expect("resync"); - - assert_eq!(resync.status.status, TradeStatusKind::Missing); - assert_eq!(resync.evidence.inserted_count, 0); - assert_eq!(resync.evidence.out_of_filter_count, 1); - assert!(resync.evidence.events[0].out_of_filter); - assert!( - store - .get_event(unrelated_event_id.as_str()) - .await - .expect("unrelated event lookup") - .is_none() - ); -} - -#[cfg(feature = "transport-nostr-runtime")] -#[tokio::test] -async fn trade_resync_errors_on_total_relay_failure() { - let (_tempdir, sdk, _store) = directory_sdk_and_store_with_relays(&[RELAY, RELAY_B]).await; - let adapter = RadrootsMockRelayFetchAdapter::new(vec![ - relay_closed(RELAY, "timeout: relay offline"), - relay_closed(RELAY_B, "error: relay unavailable"), - ]); - - let error = sdk - .trades() - .resync() - .resync_with_fetch_adapter( - TradeResyncRequest::new(RadrootsTradeLocator::from_order_id(order_id( - "resync-total-relay-failure", - ))), - &adapter, - ) - .await - .expect_err("total relay failure"); - - assert_eq!(error.code(), "product_sync_transport_setup_failure"); -} - -#[cfg(feature = "transport-nostr-runtime")] -#[tokio::test] -async fn trade_resync_reports_partial_relay_failure() { - let (_tempdir, sdk, _store) = directory_sdk_and_store_with_relays(&[RELAY, RELAY_B]).await; - let request_event = signed_raw_order_request_event("resync-partial-relay-failure", 43); - let adapter = RadrootsMockRelayFetchAdapter::new(vec![ - relay_closed(RELAY_B, "timeout: relay unavailable"), - relay_raw_event_item(&request_event, RELAY, 5_200), - relay_eose(RELAY), - ]); - - let resync = sdk - .trades() - .resync() - .resync_with_fetch_adapter( - TradeResyncRequest::new(RadrootsTradeLocator::from_order_id(order_id( - "resync-partial-relay-failure", - ))), - &adapter, - ) - .await - .expect("partial failure resync"); - - assert_eq!(resync.status.status, TradeStatusKind::Requested); - assert_eq!(resync.evidence.closed_count, 1); - assert_eq!(resync.evidence.eose_count, 1); - assert_eq!( - resync.evidence.nostr_relay_outcomes[0].outcome_kind, - TradeResyncNostrRelayOutcomeKind::Closed - ); - assert_eq!( - resync.evidence.nostr_relay_outcomes[0].transport_outcome_kind, - Some(TradeResyncNostrRelayTransportOutcomeKind::Timeout) - ); -} - -#[cfg(feature = "transport-nostr-runtime")] -async fn relay_event_item_from_store( - source: &RadrootsEventStore, - event_id: &RadrootsEventId, - relay_url: &str, - observed_at_ms: i64, -) -> RadrootsRelayFetchItem { - let stored = source - .get_event(event_id.as_str()) - .await - .expect("source event lookup") - .expect("source event"); - RadrootsRelayFetchItem::Event { - relay_url: relay_url.to_owned(), - raw_json: stored.raw_json, - observed_at_ms, - } -} - -#[cfg(feature = "signer-adapters")] -async fn event_from_store( - source: &RadrootsEventStore, - event_id: &RadrootsEventId, -) -> RadrootsEventEnvelope { - let stored = source - .get_event(event_id.as_str()) - .await - .expect("source event lookup") - .expect("source event"); - let event = - serde_json::from_str::<nostr::Event>(stored.raw_json.as_str()).expect("stored raw event"); - radroots_event_from_nostr(&event) -} - -#[cfg(feature = "transport-nostr-runtime")] -fn relay_raw_event_item( - event: &nostr::Event, - relay_url: &str, - observed_at_ms: i64, -) -> RadrootsRelayFetchItem { - RadrootsRelayFetchItem::Event { - relay_url: relay_url.to_owned(), - raw_json: event.as_json(), - observed_at_ms, - } -} - -#[cfg(feature = "transport-nostr-runtime")] -fn relay_eose(relay_url: &str) -> RadrootsRelayFetchItem { - RadrootsRelayFetchItem::Eose { - relay_url: relay_url.to_owned(), - } -} - -#[cfg(feature = "transport-nostr-runtime")] -fn relay_closed(relay_url: &str, message: &str) -> RadrootsRelayFetchItem { - RadrootsRelayFetchItem::Closed { - relay_url: relay_url.to_owned(), - message: message.to_owned(), - } -} - -#[cfg(feature = "transport-nostr-runtime")] -fn relay_malformed(relay_url: &str) -> RadrootsRelayFetchItem { - RadrootsRelayFetchItem::Event { - relay_url: relay_url.to_owned(), - raw_json: "{".to_owned(), - observed_at_ms: 4_999, - } -} - -#[cfg(all(feature = "signer-adapters", feature = "local-signer"))] -#[tokio::test] -async fn trade_product_propose_idempotency_replays_same_payload_and_conflicts_different_payload() { - 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 storage_paths = buyer_sdk.storage_paths().expect("storage paths"); - let store = RadrootsEventStore::open_file(&storage_paths.runtime_path) - .await - .expect("event store"); - let outbox = RadrootsOutbox::open_file(&storage_paths.runtime_path) - .await - .expect("outbox"); - let request = trade_propose_request( - "trade-product-idempotent", - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000020e") - .expect("idempotency"); - - let first = expect_enqueued( - buyer_sdk - .trades() - .buyer() - .propose_trade(request.clone()) - .await - .expect("first proposal"), - ); - let replay = expect_enqueued( - buyer_sdk - .trades() - .buyer() - .propose_trade(request) - .await - .expect("replay proposal"), - ); - assert_eq!(replay.state, SdkMutationState::AlreadyQueued); - assert_eq!(replay.signed_event_id, first.signed_event_id); - assert_eq!(replay.outbox_event_id, first.outbox_event_id); - assert!(replay.workflow.idempotency.replayed_existing_operation); - assert!( - replay - .workflow - .idempotency - .safe_to_retry_with_same_idempotency_key - ); - assert_eq!( - store - .status_summary() - .await - .expect("event store summary") - .total_events, - 1 - ); - assert_eq!( - outbox - .status_summary(i64::MAX) - .await - .expect("outbox summary") - .total_events, - 1 - ); - - let conflict = buyer_sdk - .trades() - .buyer() - .propose_trade( - trade_propose_request( - "trade-product-idempotent-conflict", - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000020e") - .expect("conflict idempotency"), - ) - .await - .expect_err("different payload conflict"); - - assert!(matches!( - conflict, - RadrootsSdkError::IdempotencyConflict { - ref operation_kind, - .. - } if operation_kind == TRADE_SUBMIT_OPERATION_KIND - )); - assert_eq!(conflict.code(), "idempotency_conflict"); - assert_eq!( - store - .status_summary() - .await - .expect("event store summary") - .total_events, - 1 - ); - assert_eq!( - outbox - .status_summary(i64::MAX) - .await - .expect("outbox summary") - .total_events, - 1 - ); -} - -#[cfg(all(feature = "signer-adapters", feature = "local-signer"))] -#[tokio::test] -async fn trade_product_propose_requires_public_note_privacy_confirmation() { - 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 missing_confirmation = buyer_sdk - .trades() - .buyer() - .propose_trade( - trade_propose_request( - "trade-product-propose-public-note", - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .with_public_note("please leave at the community table"), - ) - .await - .expect_err("missing public note confirmation"); - - let RadrootsSdkError::PrivacyPreflight { - operation, - status, - fields, - } = &missing_confirmation - else { - panic!("expected privacy preflight error"); - }; - assert_eq!(operation, "trade.propose"); - assert_eq!( - *status, - PrivacyPreflightStatus::ExplicitConfirmationRequired - ); - assert_eq!(fields, &[ProductSensitivityField::PublicButSensitiveNotes]); - assert_eq!(missing_confirmation.code(), "privacy_preflight"); - assert_eq!( - missing_confirmation.detail_json()["detail"]["fields"][0], - "public_but_sensitive_notes" - ); - - let store = - RadrootsEventStore::open_file(&buyer_sdk.storage_paths().expect("paths").runtime_path) - .await - .expect("event store"); - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 0 - ); -} - -#[cfg(all(feature = "signer-adapters", feature = "local-signer"))] -#[tokio::test] -async fn trade_product_propose_publishes_public_note_after_confirmation() { - 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 receipt = expect_enqueued( - buyer_sdk - .trades() - .buyer() - .propose_trade( - trade_propose_request( - "01890f0e-6c00-7000-8000-00000000020f", - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .with_public_note("please leave at the community table") - .with_privacy_confirmation(public_note_confirmation()) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000020f") - .expect("proposal idempotency"), - ) - .await - .expect("confirmed proposal"), - ); - - assert_eq!( - receipt.order_id.as_str(), - "01890f0e-6c00-7000-8000-00000000020f" - ); - let store = - RadrootsEventStore::open_file(&buyer_sdk.storage_paths().expect("paths").runtime_path) - .await - .expect("event store"); - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 1 - ); -} - -#[cfg(all(feature = "signer-adapters", feature = "local-signer"))] -#[tokio::test] -async fn trade_product_propose_blocks_sensitive_fulfillment_note_even_when_confirmed() { - 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 forbidden = buyer_sdk - .trades() - .buyer() - .propose_trade( - trade_propose_request( - "trade-product-propose-sensitive-note", - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .with_public_note("pickup address is 123 Farm Lane") - .with_privacy_confirmation(public_note_confirmation()), - ) - .await - .expect_err("forbidden public fulfillment details"); - - let RadrootsSdkError::PrivacyPreflight { - operation, - status, - fields, - } = &forbidden - else { - panic!("expected privacy preflight error"); - }; - assert_eq!(operation, "trade.propose"); - assert_eq!(*status, PrivacyPreflightStatus::ForbiddenPublicFields); - assert!(fields.contains(&ProductSensitivityField::SensitiveFulfillmentDetails)); - let store = - RadrootsEventStore::open_file(&buyer_sdk.storage_paths().expect("paths").runtime_path) - .await - .expect("event store"); - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 0 - ); -} - -#[cfg(all(feature = "signer-adapters", feature = "local-signer"))] -#[tokio::test] -async fn trade_product_decline_requires_public_reason_privacy_confirmation() { - 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 = expect_enqueued( - buyer_sdk - .trades() - .buyer() - .propose_trade( - trade_propose_request( - "trade-product-privacy-decline", - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000210") - .expect("propose idempotency"), - ) - .await - .expect("propose trade"), - ); - let seller_sdk = directory_sdk_with_signer(storage_root.as_path(), SELLER_SECRET_KEY_HEX).await; - - let missing_confirmation = seller_sdk - .trades() - .seller() - .decline_trade(TradeDeclineRequest::new( - seller_actor(), - propose_receipt.locator.clone(), - "sold elsewhere", - explicit_trade_relays(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - TradeEvidenceMode::LocalOnly, - )) - .await - .expect_err("missing public note confirmation"); - - let RadrootsSdkError::PrivacyPreflight { - operation, - status, - fields, - } = &missing_confirmation - else { - panic!("expected privacy preflight error"); - }; - assert_eq!(operation, "trade.decline"); - assert_eq!( - *status, - PrivacyPreflightStatus::ExplicitConfirmationRequired - ); - assert_eq!(fields, &[ProductSensitivityField::PublicButSensitiveNotes]); - assert_eq!(missing_confirmation.code(), "privacy_preflight"); - assert_eq!( - missing_confirmation.detail_json()["detail"]["fields"][0], - "public_but_sensitive_notes" - ); - let store = - RadrootsEventStore::open_file(&seller_sdk.storage_paths().expect("paths").runtime_path) - .await - .expect("event store"); - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 1 - ); - - let decline_receipt = expect_enqueued( - seller_sdk - .trades() - .seller() - .decline_trade( - TradeDeclineRequest::new( - seller_actor(), - propose_receipt.locator.clone(), - "sold elsewhere", - explicit_trade_relays(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - TradeEvidenceMode::LocalOnly, - ) - .with_privacy_confirmation(public_note_confirmation()) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000211") - .expect("decline idempotency"), - ) - .await - .expect("confirmed decline"), - ); - let status = seller_sdk - .trades() - .status(TradeStatusRequest::new(propose_receipt.locator)) - .await - .expect("status"); - assert_eq!(status.status, TradeStatusKind::Declined); - assert_eq!( - status.decision_event_id, - Some(decline_receipt.signed_event_id) - ); -} - -#[cfg(all(feature = "signer-adapters", feature = "local-signer"))] -#[tokio::test] -async fn trade_product_cancel_blocks_sensitive_fulfillment_reason_before_mutation() { - 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 = expect_enqueued( - buyer_sdk - .trades() - .buyer() - .propose_trade( - trade_propose_request( - "trade-product-privacy-cancel", - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000212") - .expect("propose idempotency"), - ) - .await - .expect("propose trade"), - ); - - let forbidden = buyer_sdk - .trades() - .buyer() - .cancel_trade( - TradeCancelRequest::new( - buyer_actor(), - propose_receipt.locator, - "pickup address is 123 Farm Lane", - explicit_trade_relays(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - TradeEvidenceMode::LocalOnly, - ) - .with_privacy_confirmation(public_note_confirmation()), - ) - .await - .expect_err("forbidden public fulfillment details"); - - let RadrootsSdkError::PrivacyPreflight { - operation, - status, - fields, - } = &forbidden - else { - panic!("expected privacy preflight error"); - }; - assert_eq!(operation, "trade.cancel"); - assert_eq!(*status, PrivacyPreflightStatus::ForbiddenPublicFields); - assert!(fields.contains(&ProductSensitivityField::SensitiveFulfillmentDetails)); - let store = - RadrootsEventStore::open_file(&buyer_sdk.storage_paths().expect("paths").runtime_path) - .await - .expect("event store"); - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 1 - ); -} - -#[cfg(all(feature = "signer-adapters", feature = "local-signer"))] -#[tokio::test] -async fn trade_product_cancel_enqueues_with_locator_and_updates_status() { - 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 = expect_enqueued( - buyer_sdk - .trades() - .buyer() - .propose_trade( - trade_propose_request( - "01890f0e-6c00-7000-8000-000000000214", - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000213") - .expect("propose idempotency"), - ) - .await - .expect("propose trade"), - ); - - let cancellation = expect_enqueued( - buyer_sdk - .trades() - .buyer() - .cancel_trade( - TradeCancelRequest::new( - buyer_actor(), - propose_receipt.locator.clone(), - "changed plan", - explicit_trade_relays(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - TradeEvidenceMode::LocalOnly, - ) - .with_privacy_confirmation(public_note_confirmation()) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000214") - .expect("cancel idempotency"), - ) - .await - .expect("cancel trade"), - ); - - assert_eq!(cancellation.locator, propose_receipt.locator); - assert_eq!(cancellation.root_event_id, propose_receipt.signed_event_id); - assert_eq!( - cancellation.previous_event_id, - propose_receipt.signed_event_id - ); - let status = buyer_sdk - .trades() - .status(TradeStatusRequest::new(propose_receipt.locator)) - .await - .expect("status"); - assert_eq!(status.status, TradeStatusKind::Cancelled); - assert_eq!( - status.cancellation_event_id, - Some(cancellation.signed_event_id) - ); - assert_eq!(status.next_action, TradeStatusNextActionKind::Terminal); -} - -#[cfg(feature = "signer-adapters")] -#[tokio::test] -async fn trade_product_propose_dry_run_returns_plan_without_local_side_effects() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let outcome = sdk - .trades() - .buyer() - .propose_trade(trade_propose_request( - "trade-product-dry-run", - PublishMode::DryRun, - SatisfactionPolicy::NoWait, - )) - .await - .expect("dry-run proposal"); - let plan = match outcome { - TradeMutationOutcome::DryRun { plan } => plan, - TradeMutationOutcome::Enqueued { .. } => panic!("expected dry-run outcome"), - TradeMutationOutcome::Published { .. } => panic!("expected dry-run outcome"), - }; - - assert_eq!(plan.order_id.as_str(), "trade-product-dry-run"); - assert_eq!(plan.frozen_draft.kind_u32(), KIND_ORDER_REQUEST); - assert_eq!(plan.expected_event_id, plan.workflow.expected_event_id); - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 0 - ); - let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path) - .await - .expect("outbox"); - assert!( - outbox - .claim_next_ready_event("worker", "claim", 2_000, 1_700_000_000_000) - .await - .expect("claim") - .is_none() - ); -} - -fn order_decision(raw_order_id: &str) -> RadrootsOrderDecision { - RadrootsOrderDecision { - order_id: order_id(raw_order_id), - listing_addr: listing_address(), - buyer_pubkey: BUYER_PUBLIC_KEY_HEX.parse().expect("buyer pubkey"), - seller_pubkey: SELLER_PUBLIC_KEY_HEX.parse().expect("seller pubkey"), - decision: RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: "bin-1".parse().expect("bin id"), - bin_count: 2, - }], - }, - } -} - -#[cfg(feature = "transport-nostr-runtime")] -fn signed_raw_validation_receipt_event( - raw_order_id: &str, - listing_event_id: &RadrootsEventId, - root_event_id: &RadrootsEventId, - target_event_id: &RadrootsEventId, - created_at: u32, -) -> nostr::Event { - signed_raw_event( - SERVICE_SECRET_KEY_HEX, - created_at, - validation_receipt_wire_parts( - raw_order_id, - listing_event_id, - root_event_id, - target_event_id, - ), - ) -} - -fn signed_raw_sp1_validation_receipt_event( - raw_order_id: &str, - listing_event_id: &RadrootsEventId, - root_event_id: &RadrootsEventId, - target_event_id: &RadrootsEventId, - created_at: u32, -) -> nostr::Event { - signed_raw_event( - SERVICE_SECRET_KEY_HEX, - created_at, - validation_receipt_wire_parts_with_proof( - raw_order_id, - listing_event_id, - root_event_id, - target_event_id, - RadrootsValidationReceiptProofSystem::Sp1Core, - ), - ) -} - -#[cfg(feature = "transport-nostr-runtime")] -fn validation_receipt_wire_parts( - raw_order_id: &str, - listing_event_id: &RadrootsEventId, - root_event_id: &RadrootsEventId, - target_event_id: &RadrootsEventId, -) -> RadrootsNip01EventWireParts { - validation_receipt_wire_parts_with_proof( - raw_order_id, - listing_event_id, - root_event_id, - target_event_id, - RadrootsValidationReceiptProofSystem::None, - ) -} - -fn validation_receipt_wire_parts_with_proof( - raw_order_id: &str, - listing_event_id: &RadrootsEventId, - root_event_id: &RadrootsEventId, - target_event_id: &RadrootsEventId, - proof_system: RadrootsValidationReceiptProofSystem, -) -> RadrootsNip01EventWireParts { - let proof = match proof_system { - RadrootsValidationReceiptProofSystem::None => RadrootsValidationReceiptProof { - inline_proof_base64: None, - mode: None, - program_hash: None, - proof_reference: None, - system: RadrootsValidationReceiptProofSystem::None, - verifying_key_hash: None, - }, - RadrootsValidationReceiptProofSystem::Sp1Core => RadrootsValidationReceiptProof { - inline_proof_base64: Some("AQID".to_owned()), - mode: Some("core".to_owned()), - program_hash: Some(hash32('a')), - proof_reference: None, - system: RadrootsValidationReceiptProofSystem::Sp1Core, - verifying_key_hash: Some(hash32('b')), - }, - RadrootsValidationReceiptProofSystem::Sp1Compressed => RadrootsValidationReceiptProof { - inline_proof_base64: Some("AQID".to_owned()), - mode: Some("compressed".to_owned()), - program_hash: Some(hash32('a')), - proof_reference: None, - system: RadrootsValidationReceiptProofSystem::Sp1Compressed, - verifying_key_hash: Some(hash32('b')), - }, - RadrootsValidationReceiptProofSystem::Sp1Groth16 => RadrootsValidationReceiptProof { - inline_proof_base64: Some("AQID".to_owned()), - mode: Some("groth16".to_owned()), - program_hash: Some(hash32('a')), - proof_reference: None, - system: RadrootsValidationReceiptProofSystem::Sp1Groth16, - verifying_key_hash: Some(hash32('b')), - }, - RadrootsValidationReceiptProofSystem::Sp1Plonk => RadrootsValidationReceiptProof { - inline_proof_base64: Some("AQID".to_owned()), - mode: Some("plonk".to_owned()), - program_hash: Some(hash32('a')), - proof_reference: None, - system: RadrootsValidationReceiptProofSystem::Sp1Plonk, - verifying_key_hash: Some(hash32('b')), - }, - }; - let receipt = RadrootsTradeValidationReceipt { - changed_records_root: hash32('6'), - domain: "radroots.receipt".to_owned(), - error_bitmap: "0x00000000000000000000000000000000".to_owned(), - event_set_root: hash32('c'), - new_state_root: hash32('4'), - previous_state_root: hash32('3'), - proof, - public_values_hash: validation_receipt_public_values_hash_hex(br#"{"schema_version":1}"#), - receipt_type: RadrootsValidationReceiptType::TradeTransition, - result: RadrootsValidationReceiptResult::Valid, - statement: RadrootsValidationReceiptStatement { - listing_event_id: listing_event_id.as_str().to_owned(), - root_event_id: root_event_id.as_str().to_owned(), - target_event_id: target_event_id.as_str().to_owned(), - validator_set_addr: validator_set_address_from_str(validator_set_addr_raw( - public_key_hex_for_secret(SERVICE_SECRET_KEY_HEX).as_str(), - )) - .expect("validator set address"), - validator_set_event_id: validator_set_event_id().into_string(), - statement_type: RadrootsValidationReceiptType::TradeTransition, - }, - version: 1, - }; - validation_receipt_event_build(raw_order_id, &receipt).expect("receipt event") -} - -fn public_key_hex_for_secret(secret_key_hex: &str) -> String { - let secret_key = RadrootsNostrSecretKey::from_hex(secret_key_hex).expect("secret key"); - RadrootsNostrKeys::new(secret_key).public_key().to_hex() -} - -async fn insert_perf_non_trade_events(store: &RadrootsEventStore, base: i64, count: i64) { - let mut inserted = 0; - while inserted < count { - let batch = (count - inserted).min(1_000); - sqlx::query( - "WITH RECURSIVE seq(n) AS (SELECT 0 UNION ALL SELECT n + 1 FROM seq WHERE n + 1 < ?) - INSERT INTO event_envelopes(event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms) - SELECT lower(printf('%064x', ? + n)), ?, 1700000000 + n, 1, json_array(), '{}', ?, '{}', 'verified', 'unsupported_kind', NULL, NULL, 0, 1700000000000 + n, 1700000000000 + n FROM seq", - ) - .bind(batch) - .bind(base + inserted) - .bind(SELLER_PUBLIC_KEY_HEX) - .bind(perf_sig()) - .execute(store.pool()) - .await - .expect("non-trade perf seed"); - inserted += batch; - } -} - -async fn insert_perf_trade_background_events(store: &RadrootsEventStore, base: i64, count: i64) { - let mut inserted = 0; - while inserted < count { - let batch = (count - inserted).min(1_000); - sqlx::query( - "WITH RECURSIVE seq(n) AS (SELECT 0 UNION ALL SELECT n + 1 FROM seq WHERE n + 1 < ?) - INSERT INTO event_envelopes(event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms) - SELECT lower(printf('%064x', ? + n)), ?, 1700000000 + n, ?, json_array(json_array('d', 'perf-bg-' || printf('%06d', ? + n))), '{}', ?, '{}', 'verified', 'supported', 'radroots.order.request.v1', 'regular', 1, 1700000000000 + n, 1700000000000 + n FROM seq", - ) - .bind(batch) - .bind(base + inserted) - .bind(BUYER_PUBLIC_KEY_HEX) - .bind(i64::from(KIND_ORDER_REQUEST)) - .bind(base + inserted) - .bind(perf_sig()) - .execute(store.pool()) - .await - .expect("trade perf event seed"); - sqlx::query( - "WITH RECURSIVE seq(n) AS (SELECT 0 UNION ALL SELECT n + 1 FROM seq WHERE n + 1 < ?) - INSERT INTO event_envelope_tags(event_id, tag_index, tag_name, tag_value, tag_json, contract_semantic, contract_value_type, relay_indexed) - SELECT lower(printf('%064x', ? + n)), 0, 'd', 'perf-bg-' || printf('%06d', ? + n), json_array('d', 'perf-bg-' || printf('%06d', ? + n)), NULL, NULL, 0 FROM seq", - ) - .bind(batch) - .bind(base + inserted) - .bind(base + inserted) - .bind(base + inserted) - .execute(store.pool()) - .await - .expect("trade perf tag seed"); - inserted += batch; - } -} - -async fn ingest_status_noise_events( - store: &RadrootsEventStore, - non_trade_count: i64, - trade_count: i64, -) { - for index in 0..non_trade_count { - store - .ingest_event(RadrootsEventIngest::new( - signed_event_from_envelope(signed_status_noise_post_event( - index, - 32_000 + index as u32, - )), - 1_700_200_000_000 + index, - )) - .await - .expect("non-trade status noise ingest"); - } - - for index in 0..trade_count { - let order_id = format!("status-noise-background-{index:03}"); - store - .ingest_event(RadrootsEventIngest::new( - signed_event_from_envelope(signed_order_request_event( - &order_id, - 33_000 + index as u32, - )), - 1_700_200_100_000 + index, - )) - .await - .expect("trade status noise ingest"); - } -} - -fn perf_sig() -> String { - "0".repeat(128) -} - -fn hash32(ch: char) -> String { - format!("0x{}", ch.to_string().repeat(64)) -} - -fn signed_event( - secret_key_hex: &str, - created_at: u32, - parts: RadrootsNip01EventWireParts, -) -> RadrootsEventEnvelope { - let event = signed_raw_event(secret_key_hex, created_at, parts); - radroots_event_from_nostr(&event) -} - -fn signed_raw_event( - secret_key_hex: &str, - created_at: u32, - parts: RadrootsNip01EventWireParts, -) -> nostr::Event { - let secret_key = RadrootsNostrSecretKey::from_hex(secret_key_hex).expect("secret key"); - let keys = RadrootsNostrKeys::new(secret_key); - radroots_nostr_build_event(parts.kind, parts.content, parts.tags) - .expect("event builder") - .custom_created_at(RadrootsNostrTimestamp::from_secs(u64::from(created_at))) - .sign_with_keys(&keys) - .expect("signed event") -} - -fn signed_order_request_event(raw_order_id: &str, created_at: u32) -> RadrootsEventEnvelope { - let draft = radroots_event_codec::order::order_request_event_build( - &listing_event_ptr(), - &order_request(raw_order_id), - ) - .expect("request draft"); - signed_event(BUYER_SECRET_KEY_HEX, created_at, draft) -} - -#[cfg(feature = "transport-nostr-runtime")] -fn signed_raw_order_request_event(raw_order_id: &str, created_at: u32) -> nostr::Event { - let draft = radroots_event_codec::order::order_request_event_build( - &listing_event_ptr(), - &order_request(raw_order_id), - ) - .expect("request draft"); - signed_raw_event(BUYER_SECRET_KEY_HEX, created_at, draft) -} - -fn signed_order_decision_event( - raw_order_id: &str, - root_event_id: &RadrootsEventId, - created_at: u32, -) -> RadrootsEventEnvelope { - let draft = radroots_event_codec::order::order_decision_event_build( - root_event_id, - root_event_id, - &order_decision(raw_order_id), - ) - .expect("decision draft"); - signed_event(SELLER_SECRET_KEY_HEX, created_at, draft) -} - -fn signed_status_noise_post_event(index: i64, created_at: u32) -> RadrootsEventEnvelope { - signed_event( - SELLER_SECRET_KEY_HEX, - created_at, - RadrootsNip01EventWireParts { - kind: KIND_POST, - content: format!("local status noise {index}"), - tags: Vec::new(), - }, - ) -} - -fn signed_non_order_event(created_at: u32) -> RadrootsEventEnvelope { - signed_event( - SELLER_SECRET_KEY_HEX, - created_at, - RadrootsNip01EventWireParts { - kind: KIND_LISTING, - content: "{}".to_owned(), - tags: vec![vec!["d".to_owned(), "not-an-order".to_owned()]], - }, - ) -} - -#[tokio::test] -async fn order_evidence_ingest_stores_lifecycle_evidence_for_projection() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let request_event = signed_order_request_event("order-evidence-ingest", 39); - let request_event_id = RadrootsEventId::parse(request_event.id_str()).expect("request id"); - let decision_event = - signed_order_decision_event("order-evidence-ingest", &request_event_id, 40); - - let request_receipt = sdk - .trades() - .ingest_evidence(TradeEvidenceIngestRequest::new(signed_event_from_envelope( - request_event.clone(), - ))) - .await - .expect("request evidence"); - assert_eq!(request_receipt.order_id.as_str(), "order-evidence-ingest"); - assert_eq!(request_receipt.event_kind, KIND_ORDER_REQUEST); - assert_eq!(request_receipt.local_event_seq, 1); - assert!(request_receipt.inserted); - - let decision_receipt = sdk - .trades() - .ingest_evidence(TradeEvidenceIngestRequest::new(signed_event_from_envelope( - decision_event.clone(), - ))) - .await - .expect("decision evidence"); - assert_eq!(decision_receipt.order_id.as_str(), "order-evidence-ingest"); - assert_eq!(decision_receipt.event_kind, KIND_ORDER_DECISION); - assert_eq!(decision_receipt.local_event_seq, 2); - assert!(decision_receipt.inserted); - - let duplicate_receipt = sdk - .trades() - .ingest_evidence(TradeEvidenceIngestRequest::new(signed_event_from_envelope( - decision_event, - ))) - .await - .expect("duplicate decision evidence"); - assert_eq!(duplicate_receipt.local_event_seq, 2); - assert!(!duplicate_receipt.inserted); - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 2 - ); - - let status = sdk - .trades() - .status(status_request("order-evidence-ingest")) - .await - .expect("status"); - assert_eq!(status.status, TradeStatusKind::AgreedPendingValidation); - assert_eq!(status.event_count, 2); - assert_eq!( - status - .decision_event_id - .as_ref() - .map(RadrootsEventId::as_str), - Some(decision_receipt.event_id.as_str()) - ); -} - -#[tokio::test] -async fn order_evidence_ingest_rejects_non_order_events() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let error = sdk - .trades() - .ingest_evidence(TradeEvidenceIngestRequest::new(signed_event_from_envelope( - signed_non_order_event(41), - ))) - .await - .expect_err("non order event"); - - assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. })); - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 0 - ); -} - -#[tokio::test] -async fn order_request_evidence_ingest_rejects_non_request_events() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let root_event_id = deterministic_event_id("non-request-root"); - let decision_event = signed_order_decision_event("non-request-root", &root_event_id, 40); - - let error = sdk - .trades() - .ingest_request_evidence(TradeRequestEvidenceIngestRequest::new( - signed_event_from_envelope(decision_event), - )) - .await - .expect_err("non request event"); - - assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. })); - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 0 - ); -} - -#[tokio::test] -async fn order_status_returns_not_found_for_missing_local_order() { - let (_tempdir, sdk, _store) = directory_sdk_and_store().await; - let request = status_request("order-1"); - - assert_eq!(request.limit, TRADE_STATUS_DEFAULT_LIMIT); - - let receipt = sdk.trades().status(request).await.expect("status"); - - assert!(!receipt.found); - assert_eq!(receipt.order_id.as_str(), "order-1"); - assert_eq!(receipt.source, SdkTradeStatusSource::LocalOnly); - assert_eq!(receipt.event_count, 0); - assert_eq!(receipt.limit_applied, TRADE_STATUS_DEFAULT_LIMIT); - assert!(receipt.event_ids.is_empty()); - assert_eq!(receipt.status, TradeStatusKind::Missing); - assert!(receipt.listing_addr.is_none()); - assert!(receipt.buyer_pubkey.is_none()); - assert!(receipt.seller_pubkey.is_none()); - assert!(receipt.economics.is_none()); - assert_eq!(receipt.next_action, TradeStatusNextActionKind::NoLocalOrder); - assert_eq!(receipt.evidence.event_count, 0); - assert_eq!(receipt.evidence.limit_applied, TRADE_STATUS_DEFAULT_LIMIT); - assert!(!receipt.evidence.has_request); - assert!(!receipt.evidence.has_issues); - assert!(!receipt.eligibility.can_decide); - assert!(!receipt.eligibility.can_cancel); - assert!(receipt.issues.is_empty()); -} - -#[tokio::test] -async fn order_status_query_uses_indexed_order_id_under_background_event_noise() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - ingest_status_noise_events( - &store, - STATUS_NOISE_NON_TRADE_EVENTS, - STATUS_NOISE_TRADE_BACKGROUND_EVENTS, - ) - .await; - - let request_event = signed_order_request_event("order-status-noise-active", 31_000); - let request_event_id = request_event.id().clone(); - store - .ingest_event(RadrootsEventIngest::new( - signed_event_from_envelope(request_event), - 1_700_200_000_000, - )) - .await - .expect("active order ingest"); - - let status = sdk - .trades() - .status(status_request("order-status-noise-active")) - .await - .expect("status"); - let summary = store.status_summary().await.expect("status summary"); - - assert_eq!( - summary.total_events, - STATUS_NOISE_NON_TRADE_EVENTS + STATUS_NOISE_TRADE_BACKGROUND_EVENTS + 1 - ); - assert_eq!(status.status, TradeStatusKind::Requested); - assert_eq!(status.event_count, 1); - assert_eq!(status.limit_applied, TRADE_STATUS_DEFAULT_LIMIT); - assert_eq!(status.event_ids, vec![request_event_id]); - assert_eq!( - status.next_action, - TradeStatusNextActionKind::AwaitSellerDecision - ); -} - -#[tokio::test] -async fn order_status_rejects_invalid_limits_before_querying() { - let (_tempdir, sdk, _store) = directory_sdk_and_store().await; - - let zero = sdk - .trades() - .status(status_request("order-1").with_limit(0)) - .await - .expect_err("zero limit"); - let too_large = sdk - .trades() - .status(status_request("order-1").with_limit(TRADE_STATUS_MAX_LIMIT + 1)) - .await - .expect_err("too large"); - - assert!(matches!( - zero, - RadrootsSdkError::TradeStatusLimitInvalid { - limit: 0, - min: 1, - max: TRADE_STATUS_MAX_LIMIT - } - )); - assert!(matches!( - too_large, - RadrootsSdkError::TradeStatusLimitInvalid { - limit, - min: 1, - max: TRADE_STATUS_MAX_LIMIT - } if limit == TRADE_STATUS_MAX_LIMIT + 1 - )); -} - -#[test] -fn order_status_parse_rejects_invalid_order_ids() { - let error = TradeStatusRequest::parse("bad order id").expect_err("invalid order id"); - - assert!(matches!(error, RadrootsSdkError::InvalidTradeId { .. })); -} - -#[test] -fn order_status_parse_accepts_root_specific_selectors() { - let root_event_id = deterministic_event_id("order-status-root-selector"); - let request = TradeStatusRequest::parse(&format!("order-1@{}", root_event_id.as_str())) - .expect("root-specific status request"); - - assert_eq!(request.locator.order_id().as_str(), "order-1"); - assert_eq!( - request - .locator - .root_event_id - .as_ref() - .map(RadrootsEventId::as_str), - Some(root_event_id.as_str()) - ); - assert_eq!( - TradeStatusRequest::locator_selector(&request.locator), - format!("order-1@{}", root_event_id.as_str()) - ); -} - -#[test] -fn order_status_parse_rejects_malformed_root_selectors() { - for selector in ["order-1@", "@aaaaaaaa", "order-1@aaaaaaaa@bbbbbbbb"] { - let error = TradeStatusRequest::parse(selector).expect_err("malformed root selector"); - assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. })); - } -} - -#[tokio::test] -async fn order_status_contract_dtos_serialize_deterministically() { - let (_tempdir, sdk, _store) = directory_sdk_and_store().await; - let request = status_request("order-1").with_limit(25); - let request_json = serde_json::to_value(&request).expect("request json"); - assert_struct_serialize_error_paths(&request, 4); - - assert_eq!( - request_json, - serde_json::json!({ - "locator": { - "trade_id": "order-1", - "root_event_id": null, - "listing_addr": null, - "buyer_pubkey": null, - "seller_pubkey": null - }, - "limit": 25, - "source": "local_only", - "validation_trust_policy": { - "validator_set": null, - "validator_set_addr": null, - "validator_set_event_id": null, - "require_cryptographic_proof": false - } - }) - ); - - let receipt = sdk.trades().status(request).await.expect("status"); - let receipt_json = serde_json::to_value(&receipt).expect("receipt json"); - - assert_eq!(receipt_json["source"], "local_only"); - assert_eq!(receipt_json["status"], "missing"); - assert_eq!(receipt_json["locator"], request_json["locator"]); - assert_eq!(receipt_json["order_id"], "order-1"); - assert_eq!(receipt_json["root_event_id"], serde_json::Value::Null); - assert_eq!(receipt_json["ambiguity_candidates"], serde_json::json!([])); - assert_eq!(receipt_json["listing_addr"], serde_json::Value::Null); - assert_eq!(receipt_json["buyer_pubkey"], serde_json::Value::Null); - assert_eq!(receipt_json["seller_pubkey"], serde_json::Value::Null); - assert_eq!( - receipt_json["rhi_receipt_event_id"], - serde_json::Value::Null - ); - assert_eq!(receipt_json["economics"], serde_json::Value::Null); - assert_eq!(receipt_json["validation_trust"], serde_json::Value::Null); - assert_eq!(receipt_json["online_evidence"], serde_json::Value::Null); - assert_eq!(receipt_json["next_action"], "no_local_order"); - assert_eq!(receipt_json["evidence"]["event_count"], 0); - assert_eq!(receipt_json["evidence"]["limit_applied"], 25); - assert_eq!(receipt_json["evidence"]["has_request"], false); - assert_eq!(receipt_json["evidence"]["has_validation_receipt"], false); - assert_eq!(receipt_json["eligibility"]["can_decide"], false); - assert_eq!(receipt_json["eligibility"]["can_cancel"], false); - - let issue = SdkTradeStatusIssue { - kind: SdkTradeStatusIssueKind::DecisionPayloadInvalid, - event_ids: vec![deterministic_event_id("issue-event")], - }; - assert_eq!(issue.code(), "decision_payload_invalid"); - assert_struct_serialize_error_paths(&issue, 3); - assert_eq!( - serde_json::to_value(issue).expect("issue json"), - serde_json::json!({ - "code": "decision_payload_invalid", - "kind": "decision_payload_invalid", - "event_ids": [deterministic_event_id("issue-event")] - }) - ); -} - -#[test] -fn order_status_issue_mapping_preserves_kind_codes_and_event_ids() { - macro_rules! single_issue { - ($variant:ident, $kind:ident, $code:literal) => {{ - let event_id = deterministic_event_id($code); - ( - RadrootsOrderIssue::$variant { - event_id: event_id.clone(), - }, - SdkTradeStatusIssueKind::$kind, - $code, - vec![event_id], - ) - }}; - } - - macro_rules! multi_issue { - ($variant:ident, $kind:ident, $code:literal) => {{ - let event_ids = vec![ - deterministic_event_id(concat!($code, "-a")), - deterministic_event_id(concat!($code, "-b")), - ]; - ( - RadrootsOrderIssue::$variant { - event_ids: event_ids.clone(), - }, - SdkTradeStatusIssueKind::$kind, - $code, - event_ids, - ) - }}; - } - - let cases = vec![ - ( - RadrootsOrderIssue::MissingRequest, - SdkTradeStatusIssueKind::MissingRequest, - "missing_request", - Vec::new(), - ), - multi_issue!(MultipleRequests, MultipleRequests, "multiple_requests"), - single_issue!( - RequestPayloadInvalid, - RequestPayloadInvalid, - "request_payload_invalid" - ), - single_issue!( - RequestOrderIdMismatch, - RequestOrderIdMismatch, - "request_order_id_mismatch" - ), - single_issue!( - RequestAuthorMismatch, - RequestAuthorMismatch, - "request_author_mismatch" - ), - single_issue!( - RequestListingAddressInvalid, - RequestListingAddressInvalid, - "request_listing_address_invalid" - ), - single_issue!( - RequestSellerListingMismatch, - RequestSellerListingMismatch, - "request_seller_listing_mismatch" - ), - single_issue!( - DecisionPayloadInvalid, - DecisionPayloadInvalid, - "decision_payload_invalid" - ), - single_issue!( - DecisionOrderIdMismatch, - DecisionOrderIdMismatch, - "decision_order_id_mismatch" - ), - single_issue!( - DecisionAuthorMismatch, - DecisionAuthorMismatch, - "decision_author_mismatch" - ), - single_issue!( - DecisionCounterpartyMismatch, - DecisionCounterpartyMismatch, - "decision_counterparty_mismatch" - ), - single_issue!( - DecisionBuyerMismatch, - DecisionBuyerMismatch, - "decision_buyer_mismatch" - ), - single_issue!( - DecisionSellerMismatch, - DecisionSellerMismatch, - "decision_seller_mismatch" - ), - single_issue!( - DecisionListingAddressInvalid, - DecisionListingAddressInvalid, - "decision_listing_address_invalid" - ), - single_issue!( - DecisionListingMismatch, - DecisionListingMismatch, - "decision_listing_mismatch" - ), - single_issue!( - DecisionRootMismatch, - DecisionRootMismatch, - "decision_root_mismatch" - ), - single_issue!( - DecisionPreviousMismatch, - DecisionPreviousMismatch, - "decision_previous_mismatch" - ), - single_issue!( - DecisionMissingInventoryCommitments, - DecisionMissingInventoryCommitments, - "decision_missing_inventory_commitments" - ), - single_issue!( - DecisionInventoryCommitmentMismatch, - DecisionInventoryCommitmentMismatch, - "decision_inventory_commitment_mismatch" - ), - single_issue!( - DecisionMissingReason, - DecisionMissingReason, - "decision_missing_reason" - ), - multi_issue!( - ConflictingDecisions, - ConflictingDecisions, - "conflicting_decisions" - ), - single_issue!( - CancellationWithoutCancellableOrder, - CancellationWithoutCancellableOrder, - "cancellation_without_cancellable_order" - ), - single_issue!( - CancellationPayloadInvalid, - CancellationPayloadInvalid, - "cancellation_payload_invalid" - ), - single_issue!( - CancellationOrderIdMismatch, - CancellationOrderIdMismatch, - "cancellation_order_id_mismatch" - ), - single_issue!( - CancellationAuthorMismatch, - CancellationAuthorMismatch, - "cancellation_author_mismatch" - ), - single_issue!( - CancellationCounterpartyMismatch, - CancellationCounterpartyMismatch, - "cancellation_counterparty_mismatch" - ), - single_issue!( - CancellationBuyerMismatch, - CancellationBuyerMismatch, - "cancellation_buyer_mismatch" - ), - single_issue!( - CancellationSellerMismatch, - CancellationSellerMismatch, - "cancellation_seller_mismatch" - ), - single_issue!( - CancellationListingAddressInvalid, - CancellationListingAddressInvalid, - "cancellation_listing_address_invalid" - ), - single_issue!( - CancellationListingMismatch, - CancellationListingMismatch, - "cancellation_listing_mismatch" - ), - single_issue!( - CancellationRootMismatch, - CancellationRootMismatch, - "cancellation_root_mismatch" - ), - single_issue!( - CancellationPreviousMismatch, - CancellationPreviousMismatch, - "cancellation_previous_mismatch" - ), - multi_issue!(ForkedLifecycle, ForkedLifecycle, "forked_lifecycle"), - single_issue!( - ValidationReceiptWithoutPendingAgreement, - ValidationReceiptWithoutPendingAgreement, - "validation_receipt_without_pending_agreement" - ), - single_issue!( - ValidationReceiptOrderIdMismatch, - ValidationReceiptOrderIdMismatch, - "validation_receipt_order_id_mismatch" - ), - single_issue!( - ValidationReceiptTypeMismatch, - ValidationReceiptTypeMismatch, - "validation_receipt_type_mismatch" - ), - single_issue!( - ValidationReceiptRootMismatch, - ValidationReceiptRootMismatch, - "validation_receipt_root_mismatch" - ), - single_issue!( - ValidationReceiptTargetMismatch, - ValidationReceiptTargetMismatch, - "validation_receipt_target_mismatch" - ), - single_issue!( - ValidationReceiptListingMismatch, - ValidationReceiptListingMismatch, - "validation_receipt_listing_mismatch" - ), - multi_issue!( - ConflictingValidationReceipts, - ConflictingValidationReceipts, - "conflicting_validation_receipts" - ), - { - let event_id = deterministic_event_id("deterministic_validation_failure"); - ( - RadrootsOrderIssue::DeterministicValidationFailure { - event_id: event_id.clone(), - reason: "fixture validation failed".to_owned(), - }, - SdkTradeStatusIssueKind::DeterministicValidationFailure, - "deterministic_validation_failure", - vec![event_id], - ) - }, - { - let expected_event_id = deterministic_event_id("stale_listing_event_expected"); - let current_event_id = deterministic_event_id("stale_listing_event_current"); - ( - RadrootsOrderIssue::StaleListingEvent { - expected_event_id: expected_event_id.clone(), - current_event_id: current_event_id.clone(), - }, - SdkTradeStatusIssueKind::StaleListingEvent, - "stale_listing_event", - vec![expected_event_id, current_event_id], - ) - }, - ]; - - for (issue, expected_kind, expected_code, expected_event_ids) in cases { - let sdk_issue = SdkTradeStatusIssue::from(issue); - - assert_eq!(sdk_issue.kind, expected_kind); - assert_eq!(sdk_issue.code(), expected_code); - assert_eq!(sdk_issue.event_ids, expected_event_ids); - } -} - -#[tokio::test] -async fn order_status_projects_local_request_and_decision_events() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let request_event = signed_order_request_event("order-1", 20); - let request_event_id = RadrootsEventId::parse(request_event.id_str()).expect("request id"); - let decision_event = signed_order_decision_event("order-1", &request_event_id, 21); - - for (event, observed_at_ms) in [ - (request_event.clone(), 2_000), - (decision_event.clone(), 2_100), - ] { - store - .ingest_event(RadrootsEventIngest::new( - signed_event_from_envelope(event), - observed_at_ms, - )) - .await - .expect("ingest"); - } - - let receipt = sdk - .trades() - .status(status_request("order-1").with_limit(1_000)) - .await - .expect("status"); - - assert!(receipt.found); - assert_eq!(receipt.order_id.as_str(), "order-1"); - assert_eq!(receipt.source, SdkTradeStatusSource::LocalOnly); - assert_eq!(receipt.event_count, 2); - assert_eq!(receipt.limit_applied, 1_000); - assert_eq!( - receipt - .event_ids - .iter() - .map(RadrootsEventId::as_str) - .collect::<Vec<_>>(), - vec![request_event.id_str(), decision_event.id_str()] - ); - assert_eq!(receipt.status, TradeStatusKind::AgreedPendingValidation); - assert_eq!( - receipt - .request_event_id - .as_ref() - .map(RadrootsEventId::as_str), - Some(request_event.id_str()) - ); - assert_eq!( - receipt - .decision_event_id - .as_ref() - .map(RadrootsEventId::as_str), - Some(decision_event.id_str()) - ); - assert_eq!( - receipt.last_event_id.as_ref().map(RadrootsEventId::as_str), - Some(decision_event.id_str()) - ); - assert_eq!(receipt.listing_addr, Some(listing_address())); - assert_eq!( - receipt.buyer_pubkey.as_ref().map(ToString::to_string), - Some(BUYER_PUBLIC_KEY_HEX.to_owned()) - ); - assert_eq!( - receipt.seller_pubkey.as_ref().map(ToString::to_string), - Some(SELLER_PUBLIC_KEY_HEX.to_owned()) - ); - assert_eq!(receipt.economics, Some(economics())); - assert!(receipt.issues.is_empty()); - assert!(!receipt.lifecycle_terminal); - assert_eq!( - receipt.next_action, - TradeStatusNextActionKind::AwaitValidation - ); - assert_eq!(receipt.evidence.event_count, 2); - assert!(receipt.evidence.has_request); - assert!(receipt.evidence.has_decision); - assert!(receipt.evidence.has_agreement); - assert!(!receipt.evidence.has_issues); - assert!(!receipt.eligibility.can_decide); - assert!(!receipt.eligibility.can_cancel); -} - -#[tokio::test] -async fn order_status_reports_limited_local_results() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let request_event = signed_order_request_event("order-1", 25); - let request_event_id = RadrootsEventId::parse(request_event.id_str()).expect("request id"); - let decision_event = signed_order_decision_event("order-1", &request_event_id, 26); - - for (event, observed_at_ms) in [(request_event.clone(), 2_500), (decision_event, 2_600)] { - store - .ingest_event(RadrootsEventIngest::new( - signed_event_from_envelope(event), - observed_at_ms, - )) - .await - .expect("ingest"); - } - - let receipt = sdk - .trades() - .status(status_request("order-1").with_limit(1)) - .await - .expect("status"); - - assert!(receipt.found); - assert_eq!(receipt.status, TradeStatusKind::Requested); - assert_eq!(receipt.event_count, 1); - assert_eq!(receipt.limit_applied, 1); - assert_eq!( - receipt - .event_ids - .iter() - .map(RadrootsEventId::as_str) - .collect::<Vec<_>>(), - vec![request_event.id_str()] - ); - assert_eq!( - receipt - .request_event_id - .as_ref() - .map(RadrootsEventId::as_str), - Some(request_event.id_str()) - ); - assert!(receipt.decision_event_id.is_none()); - assert_eq!( - receipt.last_event_id.as_ref().map(RadrootsEventId::as_str), - Some(request_event.id_str()) - ); - assert!(receipt.issues.is_empty()); -} - -#[tokio::test] -async fn order_status_reports_root_ambiguity_for_reused_trade_ids() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let first_request_event = signed_order_request_event("order-1", 27); - let second_request_event = signed_order_request_event("order-1", 28); - - for (event, observed_at_ms) in [ - (first_request_event.clone(), 2_700), - (second_request_event.clone(), 2_800), - ] { - store - .ingest_event(RadrootsEventIngest::new( - signed_event_from_envelope(event), - observed_at_ms, - )) - .await - .expect("ingest"); - } - - let receipt = sdk - .trades() - .status(status_request("order-1")) - .await - .expect("status"); - - assert!(!receipt.found); - assert_eq!(receipt.status, TradeStatusKind::Ambiguous); - assert_eq!(receipt.event_count, 2); - assert_eq!( - receipt - .event_ids - .iter() - .map(RadrootsEventId::as_str) - .collect::<Vec<_>>(), - vec![first_request_event.id_str(), second_request_event.id_str()] - ); - assert!(receipt.issues.is_empty()); - let candidate_roots = receipt - .ambiguity_candidates - .iter() - .map(|candidate| { - candidate - .locator - .root_event_id - .as_ref() - .map(RadrootsEventId::as_str) - .expect("root event id") - }) - .collect::<Vec<_>>(); - assert_eq!( - candidate_roots, - vec![first_request_event.id_str(), second_request_event.id_str()] - ); - assert_eq!( - receipt - .ambiguity_candidates - .iter() - .map(|candidate| TradeStatusRequest::locator_selector(&candidate.locator)) - .collect::<Vec<_>>(), - vec![ - format!("order-1@{}", first_request_event.id_str()), - format!("order-1@{}", second_request_event.id_str()) - ] - ); - - let root_specific = sdk - .trades() - .status( - TradeStatusRequest::parse(&format!("order-1@{}", second_request_event.id_str())) - .expect("root status request"), - ) - .await - .expect("root-specific status"); - - assert!(root_specific.found); - assert_eq!(root_specific.status, TradeStatusKind::Requested); - assert_eq!( - root_specific - .request_event_id - .as_ref() - .map(RadrootsEventId::as_str), - Some(second_request_event.id_str()) - ); - assert!(root_specific.ambiguity_candidates.is_empty()); -} - -#[cfg(feature = "signer-adapters")] -#[tokio::test] -async fn trade_product_mutation_returns_structured_ambiguity() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let first_request_event = signed_order_request_event("order-1", 27); - let second_request_event = signed_order_request_event("order-1", 28); - - for (event, observed_at_ms) in [ - (first_request_event.clone(), 2_700), - (second_request_event.clone(), 2_800), - ] { - store - .ingest_event(RadrootsEventIngest::new( - signed_event_from_envelope(event), - observed_at_ms, - )) - .await - .expect("ingest"); - } - - let error = sdk - .trades() - .seller() - .accept_trade(TradeAcceptRequest::new( - seller_actor(), - status_request("order-1").locator, - vec![RadrootsOrderInventoryCommitment { - bin_id: "bin-1".parse().expect("bin id"), - bin_count: 1, - }], - explicit_trade_relays(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - TradeEvidenceMode::LocalOnly, - )) - .await - .expect_err("ambiguous product mutation"); - - let RadrootsSdkError::TradeAmbiguous { - operation, - locator, - candidates, - } = &error - else { - panic!("expected structured trade ambiguity error"); - }; - assert_eq!(operation, "trade.accept"); - assert_eq!(locator.order_id().as_str(), "order-1"); - assert_eq!(candidates.len(), 2); - assert_eq!( - candidates - .iter() - .map(|candidate| { - candidate - .root_event_id - .as_ref() - .map(RadrootsEventId::as_str) - .expect("root event id") - }) - .collect::<Vec<_>>(), - vec![first_request_event.id_str(), second_request_event.id_str()] - ); - assert_eq!( - error.recovery_actions(), - vec![RadrootsSdkRecoveryAction::SelectTradeRoot] - ); - let detail = error.detail_json(); - assert_eq!(detail["code"], "trade_ambiguous"); - assert_eq!(detail["detail"]["operation"], "trade.accept"); - assert_eq!( - detail["detail"]["candidates"] - .as_array() - .expect("candidates") - .len(), - 2 - ); -} - -#[tokio::test] -async fn order_status_maps_malformed_local_data_to_sanitized_error() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let request_event = signed_order_request_event("order-1", 30); - let raw_event_json = serde_json::to_string(&request_event).expect("raw event json"); - store - .ingest_event(RadrootsEventIngest::new( - signed_event_from_envelope(request_event.clone()), - 3_000, - )) - .await - .expect("ingest"); - sqlx::query("UPDATE event_envelopes SET tags_json = '[' WHERE event_id = ?") - .bind(request_event.id_str()) - .execute(store.pool()) - .await - .expect("corrupt tags"); - - let error = sdk - .trades() - .status(status_request("order-1")) - .await - .expect_err("projection error"); - let message = error.to_string(); - - assert!(matches!(error, RadrootsSdkError::Projection { .. })); - assert!(message.contains("contains invalid tags_json")); - assert!(!message.contains(raw_event_json.as_str())); - assert!(!message.contains(request_event.sig_str())); - assert!(!message.contains("\"tags\"")); - assert!(!message.contains("\"content\"")); -} - -#[tokio::test] -async fn trade_status_watch_emits_finite_refresh_window() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let order_id = "watch-finite-refresh-window"; - let request_event = signed_order_request_event(order_id, 820); - store - .ingest_event(RadrootsEventIngest::new( - signed_event_from_envelope(request_event), - 1_700_400_000_000, - )) - .await - .expect("request ingest"); - - let mut watch = sdk - .trades() - .watch( - TradeStatusWatchRequest::new(status_request(order_id)) - .with_capacity(2) - .with_refresh_interval_ms(1) - .with_refresh_limit(2), - ) - .await - .expect("watch"); - - let first = watch.next().await.expect("first").expect("first update"); - let second = watch.next().await.expect("second").expect("second update"); - let closed = watch.next().await.expect("closed"); - let cancel = watch.cancel().await; - - assert_eq!(watch.capacity(), 2); - assert_eq!(first.sequence, 1); - assert_eq!(second.sequence, 2); - assert_eq!(first.status.status, TradeStatusKind::Requested); - assert_eq!(second.status.status, TradeStatusKind::Requested); - assert!(closed.is_none()); - assert_eq!(cancel.state, TradeStatusWatchCancelState::AlreadyFinished); -} - -#[tokio::test] -async fn trade_status_watch_backpressures_slow_consumer_with_bounded_buffer() { - let (_tempdir, sdk, _store) = directory_sdk_and_store().await; - let mut watch = sdk - .trades() - .watch( - TradeStatusWatchRequest::parse("watch-slow-consumer") - .expect("watch request") - .with_capacity(1) - .with_refresh_interval_ms(1) - .with_refresh_limit(50), - ) - .await - .expect("watch"); - - tokio::time::sleep(Duration::from_millis(25)).await; - let buffered_len = watch.buffered_len(); - let cancel = watch.cancel().await; - let post_cancel = watch.next().await.expect("post cancel"); - - assert_eq!(buffered_len, 1); - assert_eq!(cancel.state, TradeStatusWatchCancelState::Cancelled); - assert_eq!(cancel.buffered_updates_dropped, 1); - assert!(post_cancel.is_none()); -} - -#[tokio::test] -async fn trade_status_watch_cancel_drains_buffer_and_closes_stream() { - let (_tempdir, sdk, _store) = directory_sdk_and_store().await; - let mut watch = sdk - .trades() - .watch( - TradeStatusWatchRequest::parse("watch-cancel-close") - .expect("watch request") - .with_capacity(4) - .with_refresh_interval_ms(1), - ) - .await - .expect("watch"); - let first = watch.next().await.expect("first").expect("first update"); - - let cancel = watch.cancel().await; - tokio::time::sleep(Duration::from_millis(5)).await; - let post_cancel = watch.next().await.expect("post cancel"); - - assert_eq!(first.sequence, 1); - assert_eq!(first.status.status, TradeStatusKind::Missing); - assert_eq!(cancel.state, TradeStatusWatchCancelState::Cancelled); - assert!(post_cancel.is_none()); -} - -#[tokio::test] -async fn trade_status_watch_closes_after_producer_error() { - let sdk = RadrootsClient::builder().build().await.expect("sdk"); - let mut watch = sdk - .trades() - .watch( - TradeStatusWatchRequest::new( - TradeStatusRequest::parse("watch-producer-error") - .expect("status request") - .with_source(SdkTradeStatusSource::ResyncThenLocal), - ) - .with_refresh_interval_ms(1) - .with_refresh_limit(2) - .with_capacity(2), - ) - .await - .expect("watch"); - - let error = watch.next().await.expect_err("producer error"); - let closed = watch.next().await.expect("closed"); - - assert!(!error.to_string().is_empty()); - assert!(closed.is_none()); -} - -#[tokio::test] -async fn trade_status_watch_rejects_unbounded_capacity() { - let sdk = RadrootsClient::builder().build().await.expect("sdk"); - let result = sdk - .trades() - .watch( - TradeStatusWatchRequest::parse("watch-invalid-capacity") - .expect("watch request") - .with_capacity(TRADE_STATUS_WATCH_MAX_CAPACITY + 1), - ) - .await; - let Err(error) = result else { - panic!("expected capacity error"); - }; - - assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. })); - assert!(error.to_string().contains("capacity")); -} - -#[tokio::test] -#[ignore = "manual expensive release-gate lane for the 100k local-event status target"] -async fn manual_local_status_perf_gate_measures_100k_events() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let background_non_trade_events = PERF_TOTAL_LOCAL_EVENTS - PERF_TRADE_RELEVANT_EVENTS; - let background_trade_events = PERF_TRADE_RELEVANT_EVENTS - PERF_ACTIVE_TRADES as i64; - insert_perf_non_trade_events(&store, 10_000_000, background_non_trade_events).await; - insert_perf_trade_background_events(&store, 20_000_000, background_trade_events).await; - - let mut active_order_ids = Vec::with_capacity(PERF_ACTIVE_TRADES); - for index in 0..PERF_ACTIVE_TRADES { - let order_id = format!("perf-active-{index:04}"); - let event = signed_order_request_event(&order_id, 20_000 + index as u32); - store - .ingest_event(RadrootsEventIngest::new( - signed_event_from_envelope(event), - 1_700_100_000_000 + index as i64, - )) - .await - .expect("active trade ingest"); - active_order_ids.push(order_id); - } - - let summary = store.status_summary().await.expect("status summary"); - assert_eq!(summary.total_events, PERF_TOTAL_LOCAL_EVENTS); - - let mut durations = Vec::with_capacity(active_order_ids.len()); - for order_id in &active_order_ids { - let started = Instant::now(); - let status = sdk - .trades() - .status(status_request(order_id)) - .await - .expect("status"); - durations.push(started.elapsed()); - assert_eq!(status.status, TradeStatusKind::Requested); - assert_eq!(status.event_count, 1); - } - - durations.sort_unstable(); - let p95 = durations[(durations.len() * 95 / 100).saturating_sub(1)]; - let cargo_target_dir = - std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "<unset>".to_owned()); - println!( - "manual local status performance gate p95_us={} target_us={} total_local_events={PERF_TOTAL_LOCAL_EVENTS} trade_relevant_events={PERF_TRADE_RELEVANT_EVENTS} active_trades={PERF_ACTIVE_TRADES} os={} arch={} cargo_target_dir={cargo_target_dir}", - p95.as_micros(), - PERF_STATUS_P95_TARGET.as_micros(), - std::env::consts::OS, - std::env::consts::ARCH - ); - assert!( - p95 <= PERF_STATUS_P95_TARGET, - "local status p95 {}us exceeded target {}us for {PERF_TOTAL_LOCAL_EVENTS} local events, {PERF_TRADE_RELEVANT_EVENTS} trade-relevant events, and {PERF_ACTIVE_TRADES} active trades", - p95.as_micros(), - PERF_STATUS_P95_TARGET.as_micros() - ); -} diff --git a/crates/sdk/tests/runtime_foundation.rs b/crates/sdk/tests/runtime_foundation.rs @@ -181,7 +181,11 @@ async fn sdk_directory_storage_creates_deterministic_sqlite_files() { .iter() .any(|name| name == "listing_projection") ); - assert!(runtime_tables.iter().any(|name| name == "trade_projection")); + assert!( + runtime_tables + .iter() + .any(|name| name == "sdk_runtime_trade_projection_checkpoint") + ); assert!( runtime_tables .iter() @@ -200,15 +204,15 @@ async fn sdk_directory_storage_creates_deterministic_sqlite_files() { assert!(!runtime_tables.iter().any(|name| name == "nostr_event")); assert!(!runtime_tables.iter().any(|name| name == "nostr_event_tag")); assert_eq!( - sqlite_trade_projection_primary_key(&paths.runtime_path).await, - vec!["order_id", "root_event_id", "projection_version"] + sqlite_runtime_projection_checkpoint_primary_key(&paths.runtime_path).await, + vec!["projection_name"] ); assert!(!runtime_tables.iter().any(|name| name == "outbox_operation")); let private_tables = sqlite_table_names(&paths.private_path).await; assert!( private_tables .iter() - .any(|name| name == "sdk_private_farm_location") + .any(|name| name == "private_farm_location") ); let studio_tables = sqlite_table_names(&paths.studio_path).await; assert!(studio_tables.iter().any(|name| name == "sdk_studio_state")); @@ -1008,14 +1012,14 @@ async fn sqlite_table_names(path: &Path) -> Vec<String> { names } -async fn sqlite_trade_projection_primary_key(path: &Path) -> Vec<String> { +async fn sqlite_runtime_projection_checkpoint_primary_key(path: &Path) -> Vec<String> { let options = SqliteConnectOptions::new().filename(path).read_only(true); let pool = SqlitePoolOptions::new() .max_connections(1) .connect_with(options) .await .expect("open sqlite for trade projection inspection"); - let rows = sqlx::query("PRAGMA table_info(trade_projection)") + let rows = sqlx::query("PRAGMA table_info(sdk_runtime_trade_projection_checkpoint)") .fetch_all(&pool) .await .expect("trade projection table info"); @@ -1089,7 +1093,8 @@ fn sdk_examples_stay_on_product_api_boundary() { assert!(local_enqueue.contains("enqueue_prepared_publish")); assert!(!local_enqueue.contains("enqueue_prepared_publish_with_explicit_signer")); assert!(local_enqueue.contains("push_outbox_with_transport")); - assert!(local_enqueue.contains("TradeStatusRequest")); + assert!(!local_enqueue.contains("TradeStatusRequest")); + assert!(!local_enqueue.contains(".trades()")); let myc_setup = include_str!("../examples/sdk_v1_myc_nip46_signer_setup.rs"); assert!(myc_setup.contains("RadrootsSdkMycNip46Signer")); diff --git a/crates/sdk/tests/source_boundary.rs b/crates/sdk/tests/source_boundary.rs @@ -3,2809 +3,91 @@ use std::{ path::{Path, PathBuf}, }; -struct ForbiddenSdkConcept { - pattern: &'static str, - reason: &'static str, +fn manifest_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) } -const FORBIDDEN_SDK_SOURCE_CONCEPTS: &[ForbiddenSdkConcept] = &[ - ForbiddenSdkConcept { - pattern: "radroots_studio_app", - reason: "SDK source must not depend on the app crate family", - }, - ForbiddenSdkConcept { - pattern: "radroots_cli", - reason: "SDK source must not depend on the CLI crate family", - }, - ForbiddenSdkConcept { - pattern: "RadrootsApp", - reason: "SDK source must not import app product concepts", - }, - ForbiddenSdkConcept { - pattern: "RadrootsCli", - reason: "SDK source must not import CLI product concepts", - }, - ForbiddenSdkConcept { - pattern: "AppSdk", - reason: "app SDK adapters belong outside the SDK crate", - }, - ForbiddenSdkConcept { - pattern: "DesktopApp", - reason: "desktop app runtime concepts belong outside the SDK crate", - }, - ForbiddenSdkConcept { - pattern: "domains/radroots/studio_apps", - reason: "SDK source must remain standalone and path-agnostic", - }, -]; - -const FORBIDDEN_SDK_README_CONCEPTS: &[ForbiddenSdkConcept] = &[ - ForbiddenSdkConcept { - pattern: "RadrootsSdk::builder()", - reason: "SDK docs must describe RadrootsClient as the product runtime entrypoint", - }, - ForbiddenSdkConcept { - pattern: "sdk.orders()", - reason: "SDK docs must describe the current trade product clients", - }, - ForbiddenSdkConcept { - pattern: "OrderStatusRequest", - reason: "SDK docs must describe TradeStatusRequest as the status request DTO", - }, - ForbiddenSdkConcept { - pattern: "protocol::", - reason: "SDK docs must not advertise a public protocol workflow bypass", - }, - ForbiddenSdkConcept { - pattern: "sdk.trade_buyer()", - reason: "SDK docs must use grouped trade product handles", - }, - ForbiddenSdkConcept { - pattern: "sdk.trade_seller()", - reason: "SDK docs must use grouped trade product handles", - }, - ForbiddenSdkConcept { - pattern: "sdk.trade_status()", - reason: "SDK docs must use sdk.trades().status(...) as the only product status entrypoint", - }, - ForbiddenSdkConcept { - pattern: "sdk.trade_resync()", - reason: "SDK docs must use grouped trade product handles", - }, - ForbiddenSdkConcept { - pattern: "sdk.trade_validation()", - reason: "SDK docs must use sdk.trades().validation_receipts() for validation receipt inspection", - }, - ForbiddenSdkConcept { - pattern: "sdk.dvm()", - reason: "SDK docs must not advertise the retired DVM client surface", - }, - ForbiddenSdkConcept { - pattern: "`relay-client` is retained", - reason: "SDK docs must describe canonical feature behavior instead of retained surfaces", - }, - ForbiddenSdkConcept { - pattern: "direct relay publish callers", - reason: "SDK docs must describe adapter behavior rather than caller compatibility posture", - }, - ForbiddenSdkConcept { - pattern: "compatibility", - reason: "SDK docs must not frame target-state runtime surfaces as compatibility policy", - }, - ForbiddenSdkConcept { - pattern: "legacy", - reason: "SDK docs must not frame target-state runtime surfaces as legacy policy", - }, - ForbiddenSdkConcept { - pattern: "preserve serde", - reason: "SDK docs must describe serialized-field stability without serde-compat wording", - }, - ForbiddenSdkConcept { - pattern: "Nostr event timestamp", - reason: "SDK docs must describe event-envelope timestamps without generic Nostr wording", - }, -]; - -const FORBIDDEN_FOUNDATION_HARDENING_RETIRED_CONCEPTS: &[ForbiddenSdkConcept] = &[ - ForbiddenSdkConcept { - pattern: "WireEventParts", - reason: "event construction must use current Radroots NIP-01 wire part names", - }, - ForbiddenSdkConcept { - pattern: "RadrootsFrozenEventDraft", - reason: "event draft surfaces must use the current RadrootsEventDraft name", - }, - ForbiddenSdkConcept { - pattern: "RadrootsNostrEvent", - reason: "product-level event surfaces must use protocol-neutral event names", - }, - ForbiddenSdkConcept { - pattern: "RadrootsNostrEventRef", - reason: "product-level event references must use RadrootsEventRef", - }, - ForbiddenSdkConcept { - pattern: "RadrootsNostrEventPtr", - reason: "product-level event pointers must use RadrootsEventPtr", - }, - ForbiddenSdkConcept { - pattern: "pub type SignedEvent", - reason: "SDK signing adapters must not expose compatibility aliases for signed events", - }, - ForbiddenSdkConcept { - pattern: "pub type NostrEventBuilder", - reason: "SDK signing adapters must not expose compatibility aliases for event builders", - }, - ForbiddenSdkConcept { - pattern: "SignedNostrEvent", - reason: "generic signed-event surfaces must use product-neutral signed-event names", - }, - ForbiddenSdkConcept { - pattern: "RadrootsEventIndexIndexCheckpoint", - reason: "event-index checkpoint names must not duplicate the index noun", - }, - ForbiddenSdkConcept { - pattern: "RadrootsEventsIndexed", - reason: "event-indexed APIs must use the singular event-index crate family", - }, - ForbiddenSdkConcept { - pattern: "RADROOTS_EVENTS_VERSION", - reason: "event contract version constants must use the current singular event namespace", - }, - ForbiddenSdkConcept { - pattern: "radroots_events", - reason: "SDK crate and package surfaces must use the current singular event crate names", - }, - ForbiddenSdkConcept { - pattern: "radroots_events_codec", - reason: "SDK event codec surfaces must use the current singular event-codec name", - }, - ForbiddenSdkConcept { - pattern: "radroots_events_indexed", - reason: "SDK event index surfaces must use the current singular event-index name", - }, - ForbiddenSdkConcept { - pattern: "radroots_local_events", - reason: "SDK local event storage must not reintroduce retired local-events names", - }, - ForbiddenSdkConcept { - pattern: "radroots_local_store", - reason: "SDK runtime storage must not reintroduce retired local-store names", - }, - ForbiddenSdkConcept { - pattern: "radroots_types", - reason: "SDK shared type surfaces must use current crate ownership instead of retired types crates", - }, - ForbiddenSdkConcept { - pattern: "radroots_types_bindings", - reason: "SDK generated bindings must not reintroduce retired types-binding names", - }, - ForbiddenSdkConcept { - pattern: "radroots_nostr_ndb", - reason: "SDK Nostr database surfaces must not reintroduce retired ndb names", - }, - ForbiddenSdkConcept { - pattern: "radroots_replica_db", - reason: "SDK replica surfaces must use current replica-store ownership", - }, - ForbiddenSdkConcept { - pattern: "radroots_replica_db_schema", - reason: "SDK replica schema surfaces must use current replica-schema ownership", - }, - ForbiddenSdkConcept { - pattern: "radroots_sp1_guest_trade", - reason: "SDK trade SP1 surfaces must use the current trade_sp1 crate names", - }, - ForbiddenSdkConcept { - pattern: "radroots_sp1_host_trade", - reason: "SDK trade SP1 surfaces must use the current trade_sp1 crate names", - }, -]; - -const FORBIDDEN_FOUNDATION_HARDENING_DOC_CONCEPTS: &[ForbiddenSdkConcept] = &[ - ForbiddenSdkConcept { - pattern: "Nostr event timestamp", - reason: "SDK docs must describe event-envelope timestamps without generic Nostr wording", - }, - ForbiddenSdkConcept { - pattern: "Forwarded satisfies Delivered", - reason: "SDK docs must not imply forwarded evidence is strict delivery", - }, - ForbiddenSdkConcept { - pattern: "StoredByGateway satisfies Delivered", - reason: "SDK docs must not imply gateway storage evidence is strict delivery", - }, - ForbiddenSdkConcept { - pattern: "Seen satisfies Delivered", - reason: "SDK docs must not imply seen evidence is strict delivery", - }, -]; - -const FORBIDDEN_SDK_ROOT_TRADE_ALIAS_NAMES: &[&str] = &[ - "trade_buyer", - "trade_seller", - "trade_status", - "trade_resync", - "trade_validation", -]; - -const FORBIDDEN_DAEMON_PUBLISH_PROXY_IDENTIFIERS: &[&str] = &[ - concat!("\"radrootsd", "_", "pro", "xy\""), - concat!("radrootsd.publish", "_", "pro", "xy.v1"), - concat!("radroots_publish", "_", "pro", "xy_protocol"), - concat!("publish", "_", "pro", "xy_protocol"), - "publish.relays.resolve", -]; - -const REQUIRED_SDK_README_CONCEPTS: &[&str] = &[ - "RadrootsClient::builder()", - "sdk.trades()", - "TradeStatusRequest", - "sdk.trades().buyer()", - "sdk.trades().seller()", - "sdk.trades().status(...)", - "sdk.trades().resync()", - "sdk.trades().validation_receipts()", - "event-envelope timestamp", -]; - -const REQUIRED_TRADE_RUNTIME_EXPORTS: &[&str] = &[ - "TRADE_STATUS_DEFAULT_LIMIT", - "TRADE_STATUS_MAX_LIMIT", - "TradeEvidenceBranchReceipt", - "TradeEvidenceIngestReceipt", - "TradeEvidenceIngestRequest", - "TradeEvidenceQueryBranch", - "TradeEvidenceQueryBranchKind", - "TradeEvidenceQueryPlan", - "TradeEvidenceNostrRelayFilter", - "TradeEvidenceNostrRelayTagFilter", - "TradeRequestEvidenceIngestReceipt", - "TradeRequestEvidenceIngestRequest", - "TradeResyncEventImportReceipt", - "TradeResyncEvidenceReceipt", - "TradeResyncNostrRelayOutcomeKind", - "TradeResyncNostrRelayOutcomeReceipt", - "TradeResyncNostrRelayTransportOutcomeKind", - "TradeResyncReceipt", - "TradeResyncRequest", - "TradeSellerInboxReceipt", - "TradeSellerInboxRequest", - "TradeStatusAmbiguityCandidate", - "TradeStatusEligibility", - "TradeStatusEvidenceSummary", - "TradeStatusKind", - "TradeStatusNextActionKind", - "TradeStatusReceipt", - "TradeStatusRequest", - "TradeValidationReceiptEvent", - "TradeValidationReceiptInspectReceipt", - "TradeValidationReceiptInspectRequest", - "TradeValidationReceiptInvalidCandidate", - "TradeValidationReceiptListReceipt", - "TradeValidationReceiptListRequest", - "TradeValidationReceiptNostrEvidenceReceipt", - "TradeValidationReceiptNostrRelayOutcomeKind", - "TradeValidationReceiptNostrRelayOutcomeReceipt", - "TradeValidationReceiptNostrRelayTransportOutcomeKind", - "TradeValidationReceiptTags", - "TradeValidationReceiptVerifyRequest", - "TradeValidationTrustDecision", - "SdkTradeStatusIssue", - "SdkTradeStatusIssueKind", - "SdkTradeStatusSource", -]; - -const REQUIRED_TRADE_SIGNER_EXPORTS: &[&str] = &[ - "TRADE_CANCELLATION_OPERATION_KIND", - "TRADE_DECISION_OPERATION_KIND", - "TRADE_SUBMIT_OPERATION_KIND", - "TradeAcceptRequest", - "TradeCancelRequest", - "TradeCancellationPlan", - "TradeCancellationReceipt", - "TradeDecisionPlan", - "TradeDecisionReceipt", - "TradeDeclineRequest", - "TradeEvidenceMode", - "TradeMutationOutcome", - "TradeProposeRequest", - "TradeSubmitPlan", - "TradeSubmitReceipt", - "TradeWorkflowEnqueueReceipt", - "TradeWorkflowIdempotencyReceipt", - "TradeWorkflowKind", - "TradeWorkflowPlan", - "TradeWorkflowRetryAdvice", -]; - -const REQUIRED_IDENTITY_MODEL_EXPORTS: &[&str] = &[ - "DEFAULT_IDENTITY_PATH", - "IdentityError", - "RADROOTS_USERNAME_MAX_LEN", - "RADROOTS_USERNAME_MIN_LEN", - "RADROOTS_USERNAME_REGEX", - "RadrootsIdentity", - "RadrootsIdentityFile", - "RadrootsIdentityId", - "RadrootsIdentityProfile", - "RadrootsIdentityPublic", - "RadrootsIdentitySecretKeyFormat", - "radroots_username_is_valid", - "radroots_username_normalize", -]; - -const REQUIRED_IDENTITY_STORAGE_EXPORTS: &[&str] = &[ - "RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT", - "RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX", - "RadrootsEncryptedIdentityFile", - "encrypted_identity_wrapping_key_path", - "load_encrypted_identity", - "load_encrypted_identity_with_key_slot", - "load_identity_profile", - "rotate_encrypted_identity", - "rotate_encrypted_identity_with_key_slot", - "store_encrypted_identity", - "store_encrypted_identity_with_key_slot", - "store_identity_profile", -]; - -const REQUIRED_TRADE_POLICY_EXPORTS: &[&str] = &[ - "SatisfactionPolicy", - "PublishMode", - "TargetPolicy", - "SdkTradeIdempotencyRecord", - "PrivacyPreflightConfirmation", - "PrivacyPreflightReceipt", - "PrivacyPreflightStatus", - "ProductSensitivityField", - "RadrootsTradeValidationTrustPolicy", - "RadrootsTradeValidationTrustState", - "SDK_TRADE_PROJECTION_CACHE_VERSION", - "SdkTradeProjectionCache", - "SdkTradeProjectionCacheKey", - "SdkTradeProjectionCacheRecord", -]; - -const REQUIRED_TRADES_CLIENT_METHODS: &[&str] = &[ - "pub async fn ingest_evidence(", - "pub async fn ingest_request_evidence(", - "pub async fn status(", -]; - -const FORBIDDEN_TRADES_CLIENT_PUBLIC_METHODS: &[&str] = &[ - "pub fn prepare_submit(", - "pub async fn enqueue_submit(", - "pub async fn enqueue_prepared_submit(", - "pub async fn enqueue_submit_with_explicit_signer(", - "pub async fn enqueue_prepared_submit_with_explicit_signer(", - "pub fn prepare_decision(", - "pub async fn enqueue_decision(", - "pub async fn enqueue_prepared_decision(", - "pub async fn enqueue_decision_with_explicit_signer(", - "pub async fn enqueue_prepared_decision_with_explicit_signer(", - "pub fn prepare_revision_proposal(", - "pub async fn enqueue_revision_proposal(", - "pub async fn enqueue_prepared_revision_proposal(", - "pub async fn enqueue_revision_proposal_with_explicit_signer(", - "pub async fn enqueue_prepared_revision_proposal_with_explicit_signer(", - "pub fn prepare_revision_decision(", - "pub async fn enqueue_revision_decision(", - "pub async fn enqueue_prepared_revision_decision(", - "pub async fn enqueue_revision_decision_with_explicit_signer(", - "pub async fn enqueue_prepared_revision_decision_with_explicit_signer(", - "pub fn prepare_cancellation(", - "pub async fn enqueue_cancellation(", - "pub async fn enqueue_prepared_cancellation(", - "pub async fn enqueue_cancellation_with_explicit_signer(", - "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("]; - -const REQUIRED_TRADE_SELLER_CLIENT_METHODS: &[&str] = &[ - "pub async fn inbox(", - "pub async fn accept_trade(", - "pub async fn decline_trade(", -]; - -const REQUIRED_TRADE_RESYNC_CLIENT_METHODS: &[&str] = &["pub async fn resync("]; - -const REQUIRED_TRADE_VALIDATION_RECEIPTS_CLIENT_METHODS: &[&str] = &[ - "pub async fn list(", - "pub async fn inspect(", - "pub async fn verify(", -]; - -const FORBIDDEN_PRODUCT_CLIENT_HANDLES: &[&str] = &[ - "DvmClient", - "TradeStatusClient", - "TradeValidationClient", - "pub struct TradeStatusClient", - "pub struct TradeValidationClient", -]; - -const FORBIDDEN_PRODUCT_CLIENT_METHODS: &[&str] = &[ - "pub fn status_client(", - "pub fn validation(", - "pub fn root(&self) -> &'client RadrootsClient", -]; - -const FORBIDDEN_ORDER_RUNTIME_PUBLIC_EXPORTS: &[&str] = &[ - "CheckoutClient", - "EscrowClient", - "InvoiceClient", - concat!("Order", "FulfillmentStatusKind"), - concat!("Order", "FulfillmentUpdateEnqueueRequest"), - concat!("Order", "FulfillmentUpdatePlan"), - concat!("Order", "FulfillmentUpdatePrepareRequest"), - concat!("Order", "FulfillmentUpdateReceipt"), - concat!("Order", "Payment", "HandoffKind"), - concat!("Order", "PaymentRecordEnqueueRequest"), - concat!("Order", "PaymentRecordPrepareRequest"), - concat!("Order", "Payment", "Record", "Receipt"), - concat!("Order", "Payment", "StateKind"), - concat!("Order", "ReceiptRecordEnqueueRequest"), - concat!("Order", "ReceiptRecordPlan"), - concat!("Order", "ReceiptRecordPrepareRequest"), - concat!("Order", "ReceiptRecord", "Receipt"), - concat!("Order", "SettlementDecisionEnqueueRequest"), - concat!("Order", "SettlementDecisionPrepareRequest"), - concat!("Order", "SettlementDecisionReceipt"), - concat!("Order", "Settlement", "StateKind"), - concat!("Payment", "Client"), - concat!("Payments", "Client"), - "RefundClient", - "WalletClient", - "ORDER_FULFILLMENT_UPDATE_OPERATION_KIND", - "ORDER_PAYMENT_RECORD_OPERATION_KIND", - "ORDER_RECEIPT_RECORD_OPERATION_KIND", - "ORDER_SETTLEMENT_DECISION_OPERATION_KIND", -]; - -const FORBIDDEN_REMOVED_ORDER_PRODUCT_EXPORTS: &[&str] = &[ - "ORDER_CANCELLATION_OPERATION_KIND", - "ORDER_DECISION_OPERATION_KIND", - "ORDER_REVISION_DECISION_OPERATION_KIND", - "ORDER_REVISION_PROPOSAL_OPERATION_KIND", - "ORDER_STATUS_DEFAULT_LIMIT", - "ORDER_STATUS_MAX_LIMIT", - "ORDER_SUBMIT_OPERATION_KIND", - "OrderCancellationEnqueueRequest", - "OrderCancellationPlan", - "OrderCancellationPrepareRequest", - "OrderCancellationReceipt", - "OrderDecisionEnqueueRequest", - "OrderDecisionPlan", - "OrderDecisionPrepareRequest", - "OrderDecisionReceipt", - "OrderEvidenceIngestReceipt", - "OrderEvidenceIngestRequest", - "OrderRequestEvidenceIngestReceipt", - "OrderRequestEvidenceIngestRequest", - "OrderRevisionDecisionEnqueueRequest", - "OrderRevisionDecisionPlan", - "OrderRevisionDecisionPrepareRequest", - "OrderRevisionDecisionReceipt", - "OrderRevisionProposalEnqueueRequest", - "OrderRevisionProposalPlan", - "OrderRevisionProposalPrepareRequest", - "OrderRevisionProposalReceipt", - "OrderStatusEligibility", - "OrderStatusEvidenceSummary", - "OrderStatusReceipt", - "OrderStatusRequest", - "OrderSubmitEnqueueRequest", - "OrderSubmitPlan", - "OrderSubmitPrepareRequest", - "OrderSubmitReceipt", - "OrderWorkflowEnqueueReceipt", - "OrderWorkflowIdempotencyReceipt", - "OrderWorkflowKind", - "OrderWorkflowPlan", - "OrderWorkflowRetryAdvice", - "SdkDvmInventoryBinWitness", - "SdkOrderStatusIssue", - "SdkOrderStatusIssueKind", - "SdkOrderStatusSource", - "TradeProtocolClient", -]; - -const FORBIDDEN_TRADE_LOW_LEVEL_REQUEST_EXPORTS: &[&str] = &[ - "TradeCancellationEnqueueRequest", - "TradeCancellationPrepareRequest", - "TradeDecisionEnqueueRequest", - "TradeDecisionPrepareRequest", - "TradeRevisionDecisionEnqueueRequest", - "TradeRevisionDecisionPrepareRequest", - "TradeRevisionProposalEnqueueRequest", - "TradeRevisionProposalPrepareRequest", - "TradeSubmitEnqueueRequest", - "TradeSubmitPrepareRequest", -]; - -const FORBIDDEN_ORDER_RUNTIME_METHODS: &[&str] = &[ - "accept_settlement", - "checkout", - "enqueue_fulfillment", - "enqueue_payment", - "enqueue_receipt_record", - "enqueue_settlement", - "escrow", - "fulfillment", - "invoice", - "payment_provider", - "prepare_fulfillment", - "prepare_payment", - "prepare_receipt_record", - "prepare_settlement", - "receipt_record", - "record_payment", - "refund", - "reject_settlement", - "settle_payment", - "wallet", -]; - -#[test] -fn sdk_sources_do_not_import_app_or_cli_concepts() { - for path in rust_source_files(Path::new(env!("CARGO_MANIFEST_DIR")).join("src").as_path()) { - let source = read_source(path.as_path()); - for concept in FORBIDDEN_SDK_SOURCE_CONCEPTS { - assert!( - !contains_forbidden_concept(&source, concept.pattern), - "{} contains forbidden SDK source concept `{}`: {}", - path.display(), - concept.pattern, - concept.reason - ); - } - } -} - -#[test] -fn sdk_sources_reject_production_dead_code_suppressions() { - let offenders = rust_source_files(Path::new(env!("CARGO_MANIFEST_DIR")).join("src").as_path()) - .into_iter() - .filter_map(|path| { - let source = read_source(path.as_path()); - source - .contains("allow(dead_code)") - .then(|| path.display().to_string()) - }) - .collect::<Vec<_>>(); - - assert!( - offenders.is_empty(), - "SDK sources contain production dead-code suppressions: {offenders:?}" - ); -} - -#[test] -fn sdk_manifest_does_not_depend_on_app_or_cli_crates() { - let manifest_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"); - let manifest = read_source(manifest_path.as_path()); - - for crate_name in ["radroots_studio_app", "radroots_cli"] { - assert!( - !manifest.contains(crate_name), - "SDK manifest contains forbidden downstream crate dependency `{crate_name}`" - ); - } -} - -#[test] -fn sdk_transport_nostr_features_do_not_retain_relay_named_aliases() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let manifest = read_source(manifest_dir.join("Cargo.toml").as_path()); - - for required in [ - "transport-nostr-client = [", - "transport-nostr-runtime = [", - "\"transport-nostr-runtime\",", - "\"transport-nostr-client\",", - ] { - assert!( - manifest.contains(required), - "Cargo.toml must retain canonical SDK transport feature witness `{required}`" - ); - } - for forbidden in ["relay-client", "relay-runtime"] { - assert!( - !manifest.contains(forbidden), - "Cargo.toml must not retain removed SDK transport feature `{forbidden}`" - ); - } - - let lib_source = read_source(manifest_dir.join("src/lib.rs").as_path()); - assert!( - lib_source.contains("feature = \"transport-nostr-client\""), - "src/lib.rs must gate adapters on transport-nostr-client" - ); - assert!( - !lib_source.contains("relay-client") && !lib_source.contains("relay-runtime"), - "src/lib.rs must not retain removed transport feature names" - ); - - let adapters_mod_source = read_source(manifest_dir.join("src/adapters/mod.rs").as_path()); - assert!( - adapters_mod_source.contains("pub mod nostr;"), - "src/adapters/mod.rs must expose the Nostr adapter module by transport kind" - ); - assert!( - !adapters_mod_source.contains("pub mod relay;"), - "src/adapters/mod.rs must not retain a relay-named public adapter module" - ); - assert!( - !manifest_dir.join("src/adapters/relay.rs").exists(), - "src/adapters/relay.rs must not remain as a relay-named public adapter module" - ); - - for relative_path in [ - "src/sync_runtime.rs", - "src/orders_runtime.rs", - "examples/runtime_local.rs", - "README", - ] { - let source = read_source(manifest_dir.join(relative_path).as_path()); - for forbidden in ["relay-client", "relay-runtime"] { - assert!( - !source.contains(forbidden), - "{relative_path} must not retain removed SDK transport feature `{forbidden}`" - ); - } - } -} - -#[test] -fn sdk_transport_public_api_rejects_removed_relay_aliases_and_neutral_target_constructor() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let lib_source = read_source(manifest_dir.join("src/lib.rs").as_path()); - let nostr_adapter = read_source(manifest_dir.join("src/adapters/nostr.rs").as_path()); - let transport_source = read_source(manifest_dir.join("src/transport.rs").as_path()); - let target_set_impl = source_between( - transport_source.as_str(), - "impl TargetSet {", - "\n}\n\nimpl serde::Serialize for TargetSet", - ); - - for required in [ - "RadrootsNostrClient", - "RadrootsNostrClientOptions", - "RadrootsNostrError", - "RadrootsNostrEventId", - "RadrootsNostrOutput", - "RadrootsTransportDeliveryReceipt", - "RadrootsTransportDeliveryTargetStatus", - "RadrootsTransportKind", - "RadrootsTransportOutcome", - "RadrootsTransportSatisfactionClass", - "RadrootsTransportTargetReceipt", - "pub fn nostr_relays", - "pub fn transport_targets", - ] { - assert!( - nostr_adapter.contains(required) - || transport_source.contains(required) - || lib_source.contains(required), - "SDK public transport API must retain explicit target-state witness `{required}`" - ); - } - - for forbidden in [ - "pub type RelayClient", - "pub type RelayClientOptions", - "pub type RelayError", - "pub type RelayEventId", - "pub type RelayOutput", - "RelayClient::", - "RelayOutput<", - "pub fn new<I, S>", - ] { - assert!( - !nostr_adapter.contains(forbidden) && !target_set_impl.contains(forbidden), - "SDK transport public API must not retain removed alias or constructor `{forbidden}`" - ); - } - - for forbidden in [ - "RadrootsTransportDeliveryReceipt as TransportDeliveryReceipt", - "RadrootsTransportDeliveryTargetStatus as TransportDeliveryTargetStatus", - "RadrootsTransportKind as TransportKind", - "RadrootsTransportOutcome as TransportOutcome", - "RadrootsTransportSatisfactionClass as TransportSatisfactionClass", - "RadrootsTransportTargetReceipt as TransportTargetReceipt", - ] { - assert!( - !transport_source.contains(forbidden) && !lib_source.contains(forbidden), - "SDK transport public API must not retain removed transport alias `{forbidden}`" - ); - } - - for relative_root in ["src", "tests", "examples"] { - let root = manifest_dir.join(relative_root); - if !root.exists() { - continue; - } - for path in rust_source_files(root.as_path()) { - if path.file_name().and_then(|file_name| file_name.to_str()) - == Some("source_boundary.rs") - { - continue; - } - let source = read_source(path.as_path()); - let relative_path = relative_manifest_path(manifest_dir, path.as_path()); - assert!( - !contains_sdk_target_set_new(source.as_str()), - "{relative_path} must use TargetSet::nostr_relays or TargetSet::transport_targets explicitly" - ); - for forbidden in [ - "RelayClient", - "RelayClientOptions", - "RelayError", - "RelayEventId", - "RelayOutput", - ] { - assert!( - !source.contains(forbidden), - "{relative_path} must not retain removed SDK Nostr adapter alias `{forbidden}`" - ); - } - } - } -} - -fn contains_sdk_target_set_new(source: &str) -> bool { - source.match_indices("TargetSet::new(").any(|(index, _)| { - source[..index] - .chars() - .next_back() - .is_none_or(|character| !is_rust_identifier_character(character)) - }) -} - -#[test] -fn sdk_radrootsd_execution_required_targets_stay_fingerprint_exact() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let sync_runtime = read_source(manifest_dir.join("src/sync_runtime.rs").as_path()); - - for required in [ - "let required_remaining_targets =\n radrootsd_required_remaining_targets(&plan.satisfaction_policy, &active_targets)?;", - "required_remaining_targets.as_deref()", - "fn radrootsd_required_remaining_targets", - "RadrootsTransportSatisfactionPolicy::RequiredTargets { class, targets }", - "target.endpoint_fingerprint == *required", - "target.status.counts_as_transport_satisfaction(*class)", - "target.status.is_ready_for_attempt()", - ] { - assert!( - sync_runtime.contains(required), - "SDK radrootsd publish must retain exact required-target witness `{required}`" - ); - } - - let required_targets_arm = source_between( - sync_runtime.as_str(), - "RadrootsTransportSatisfactionPolicy::RequiredTargets { .. } => {\n TransportPublishDeliveryPolicy::required_targets(", - " RadrootsTransportSatisfactionPolicy::Quorum { .. } => {", - ); - for forbidden in [ - "TransportPublishDeliveryPolicy::All", - "TransportPublishDeliveryPolicy::Any", - "TransportPublishDeliveryPolicy::Quorum", - ] { - assert!( - !required_targets_arm.contains(forbidden), - "SDK RequiredTargets radrootsd arm must not lower to count policy `{forbidden}`" - ); - } -} - -#[test] -fn sdk_readme_documents_current_public_product_surface() { - let readme_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("README"); - let readme = read_source(readme_path.as_path()); - - for concept in FORBIDDEN_SDK_README_CONCEPTS { - assert!( - !readme.contains(concept.pattern), - "README contains forbidden SDK public API concept `{}`: {}", - concept.pattern, - concept.reason - ); - } - - for concept in REQUIRED_SDK_README_CONCEPTS { - assert!( - readme.contains(concept), - "README must document current SDK public API concept `{concept}`" - ); - } -} - -#[test] -fn sdk_foundation_hardening_surfaces_reject_retired_names_and_ambiguous_docs() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let repo_root = manifest_dir - .parent() - .and_then(Path::parent) - .expect("SDK repo root"); - let mut findings = Vec::new(); - - for path in sdk_foundation_hardening_guard_files(repo_root) { - let source = read_source(path.as_path()); - let relative_path = relative_manifest_path(repo_root, path.as_path()); - - for concept in FORBIDDEN_FOUNDATION_HARDENING_RETIRED_CONCEPTS { - if foundation_hardening_retired_concept_allowed(relative_path.as_str(), concept.pattern) - { - continue; - } - if contains_forbidden_concept(source.as_str(), concept.pattern) { - findings.push(format!( - "{} contains retired Foundation Hardening concept `{}`: {}", - relative_path, concept.pattern, concept.reason - )); - } - } - - if is_doc_surface(path.as_path()) { - for concept in FORBIDDEN_FOUNDATION_HARDENING_DOC_CONCEPTS { - if source.contains(concept.pattern) { - findings.push(format!( - "{} contains ambiguous Foundation Hardening wording `{}`: {}", - relative_path, concept.pattern, concept.reason - )); - } - } - } - } - - assert!( - findings.is_empty(), - "SDK Foundation Hardening V1 source-boundary violations:\n{}", - findings.join("\n") - ); -} - -#[test] -fn sdk_sources_do_not_reintroduce_root_trade_alias_surface() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - - for path in sdk_root_alias_guard_files(manifest_dir) { - let source = read_source(path.as_path()); - let relative_path = relative_manifest_path(manifest_dir, path.as_path()); - let findings = root_trade_alias_findings(relative_path.as_str(), source.as_str()); - assert!( - findings.is_empty(), - "SDK root trade aliases are forbidden:\n{}", - findings.join("\n") - ); - } -} - -#[test] -fn radroots_client_does_not_expose_root_trade_alias_methods() { - let source = read_source( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/runtime.rs") - .as_path(), - ); - let client_impl = impl_block(source.as_str(), "impl RadrootsClient {"); - let findings = root_trade_alias_findings("src/runtime.rs RadrootsClient impl", client_impl); - - assert!( - findings.is_empty(), - "RadrootsClient must expose grouped product handles instead of root trade aliases:\n{}", - findings.join("\n") - ); -} - -#[test] -fn root_trade_alias_scanner_preserves_internal_status_identifiers() { - let allowed_source = r#" - let code = "trade_status_limit_invalid"; - let error = RadrootsSdkError::trade_status_limit_invalid(0, 1, 100); - async fn dvm_validation_receipt_ingest_commits_pending_trade_status() {} - "#; - assert!( - root_trade_alias_findings("allowed_fixture.rs", allowed_source).is_empty(), - "internal status identifiers must remain allowed when they are not root alias calls or definitions" - ); - - let forbidden_source = r#" - impl RadrootsClient { - pub async fn trade_status(&self) {} - } - async fn workflow(sdk: RadrootsClient) { - sdk.trade_resync (request).await; - RadrootsClient::trade_buyer(&sdk); - } - "#; - let findings = root_trade_alias_findings("forbidden_fixture.rs", forbidden_source); - - for alias in ["trade_status", "trade_resync", "trade_buyer"] { - assert!( - findings.iter().any(|finding| finding.contains(alias)), - "root trade alias scanner must reject `{alias}`" - ); - } -} - -#[test] -fn farm_runtime_stays_on_product_runtime_boundary() { - product_runtime_file_stays_on_boundary("src/farms_runtime.rs"); -} - -#[test] -fn order_runtime_stays_on_product_runtime_boundary() { - product_runtime_file_stays_on_boundary("src/orders_runtime.rs"); -} - -#[test] -fn migrated_runtime_tests_stay_on_product_runtime_boundary() { - for file in ["tests/farms_runtime.rs", "tests/orders_runtime.rs"] { - product_runtime_file_stays_on_boundary(file); - } -} - -#[test] -fn private_protocol_helper_modules_are_runtime_gated_by_lib() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let lib_source = read_source(manifest_dir.join("src/lib.rs").as_path()); - - for module in ["farm", "order"] { - let module_gate = format!("#[cfg(feature = \"runtime\")]\nmod {module};"); - assert!( - lib_source.contains(module_gate.as_str()), - "src/lib.rs must keep private protocol helper module `{module}` behind runtime" - ); - } - - for relative_path in ["src/farm.rs", "src/order.rs"] { - let source = read_source(manifest_dir.join(relative_path).as_path()); - assert!( - !source.contains("feature = \"runtime\""), - "{relative_path} must rely on the lib.rs module-level runtime gate" - ); - assert!( - !source.contains("feature = \"serde_json\""), - "{relative_path} must rely on the runtime feature's serde_json contract instead of duplicating it" - ); - } - - let order_source = read_source(manifest_dir.join("src/order.rs").as_path()); - for helper in [ - "build_order_request_draft", - "build_order_decision_draft", - "build_order_cancellation_draft", - ] { - let helper_gate = format!("#[cfg(feature = \"signer-adapters\")]\npub fn {helper}("); - assert!( - order_source.contains(helper_gate.as_str()), - "src/order.rs must keep `{helper}` available only for signer adapters" - ); - } -} - -#[test] -fn default_status_noise_test_uses_production_ingest_not_perf_sql_ballast() { - let orders_runtime_tests = read_source( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/orders_runtime.rs") - .as_path(), - ); - let default_status_noise_test = function_source( - orders_runtime_tests.as_str(), - "order_status_query_uses_indexed_order_id_under_background_event_noise", - ); - let manual_perf_test = function_source( - orders_runtime_tests.as_str(), - "manual_local_status_perf_gate_measures_100k_events", - ); - - assert!( - default_status_noise_test.contains("ingest_status_noise_events("), - "default status noise test must use production-equivalent event-store ingest" - ); - for forbidden in [ - "insert_perf_non_trade_events(", - "insert_perf_trade_background_events(", - "sqlx::query(", - ] { - assert!( - !default_status_noise_test.contains(forbidden), - "default status noise test must not use manual SQL ballast helper `{forbidden}`" - ); - } - for required in [ - "insert_perf_non_trade_events(", - "insert_perf_trade_background_events(", - ] { - assert!( - manual_perf_test.contains(required), - "manual performance gate must retain explicit SQL ballast helper `{required}`" - ); - } -} - -#[test] -fn agreement_order_runtime_excludes_post_agreement_surfaces() { - let lib_source = read_source( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/lib.rs") - .as_path(), - ); - let order_source = read_source( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/orders_runtime.rs") - .as_path(), - ); - - for forbidden in FORBIDDEN_ORDER_RUNTIME_PUBLIC_EXPORTS { - assert!( - !lib_source.contains(forbidden), - "src/lib.rs must not expose unsupported order runtime surface `{forbidden}`" - ); - } - - for forbidden in FORBIDDEN_ORDER_RUNTIME_METHODS { - assert!( - !order_source.contains(forbidden), - "src/orders_runtime.rs must not expose unsupported order runtime method or capability `{forbidden}`" - ); - } -} - -#[test] -fn order_runtime_public_exports_are_explicit() { - let source = read_source( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/lib.rs") - .as_path(), - ); - - assert!( - source.contains("mod orders_runtime;"), - "src/lib.rs must keep orders_runtime as an internal implementation module" - ); - assert!( - source.contains("pub use crate::orders_runtime::{"), - "src/lib.rs must explicitly re-export approved order runtime types" - ); - assert!( - !source.contains("pub mod orders_runtime;"), - "src/lib.rs must not expose the orders_runtime module path" - ); - assert!( - !source.contains("pub use crate::orders_runtime::*;"), - "src/lib.rs must not wildcard-export the order runtime" - ); - - let runtime_exports = export_block( - source.as_str(), - "#[cfg(feature = \"runtime\")]\npub use crate::orders_runtime::{", - ); - let signer_exports = export_block( - source.as_str(), - "#[cfg(all(feature = \"runtime\", feature = \"signer-adapters\"))]\npub use crate::orders_runtime::{", - ); - - for export in REQUIRED_TRADE_RUNTIME_EXPORTS { - assert!( - runtime_exports.contains(export), - "src/lib.rs must explicitly expose trade SDK runtime export `{export}`" - ); - } - - for export in REQUIRED_TRADE_SIGNER_EXPORTS { - assert!( - signer_exports.contains(export), - "src/lib.rs must expose trade SDK signer export `{export}` behind runtime plus signer-adapters" - ); - assert!( - !runtime_exports.contains(export), - "src/lib.rs runtime-only export block must not expose signer workflow export `{export}`" - ); - } - - for export in REQUIRED_TRADE_POLICY_EXPORTS { - assert!( - source.contains(export), - "src/lib.rs must explicitly expose trade policy export `{export}`" - ); - } - - for forbidden in FORBIDDEN_REMOVED_ORDER_PRODUCT_EXPORTS { - assert!( - !source.contains(forbidden), - "src/lib.rs must not expose removed order SDK product export `{forbidden}`" - ); - } - - for forbidden in FORBIDDEN_TRADE_LOW_LEVEL_REQUEST_EXPORTS { - assert!( - !source.contains(forbidden), - "src/lib.rs must not expose low-level trade mutation request export `{forbidden}`" - ); - } -} - -#[test] -fn trade_resync_and_validation_receipts_use_nostr_scoped_public_evidence_names() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let orders_source = read_source(manifest_dir.join("src/orders_runtime.rs").as_path()); - let lib_source = read_source(manifest_dir.join("src/lib.rs").as_path()); - - for required in [ - "pub nostr_relay_urls: Vec<String>,", - "pub nostr_evidence: TradeValidationReceiptNostrEvidenceReceipt,", - "pub nostr_relay_outcomes: Vec<TradeResyncNostrRelayOutcomeReceipt>,", - "pub nostr_relay_outcomes: Vec<TradeValidationReceiptNostrRelayOutcomeReceipt>,", - "pub nostr_relay_url: String,", - "TradeResyncNostrRelayOutcomeReceipt", - "TradeResyncNostrRelayOutcomeKind", - "TradeResyncNostrRelayTransportOutcomeKind", - "TradeValidationReceiptNostrEvidenceReceipt", - "TradeValidationReceiptNostrRelayOutcomeReceipt", - "TradeValidationReceiptNostrRelayOutcomeKind", - "TradeValidationReceiptNostrRelayTransportOutcomeKind", - ] { - assert!( - orders_source.contains(required) || lib_source.contains(required), - "SDK trade evidence source must retain Nostr-scoped public witness `{required}`" - ); - } - - for forbidden in [ - "TradeResyncRelayOutcomeReceipt", - "TradeResyncRelayOutcomeKind", - "TradeResyncRelayTransportOutcomeKind", - "TradeValidationReceiptRelayEvidenceReceipt", - "TradeValidationReceiptRelayOutcomeReceipt", - "TradeValidationReceiptRelayOutcomeKind", - "TradeValidationReceiptRelayTransportOutcomeKind", - "pub relay_targets:", - "pub relay_evidence:", - "pub relay_url:", - "pub relays:", - "relay_transport", - ] { - assert!( - !orders_source.contains(forbidden) && !lib_source.contains(forbidden), - "SDK trade evidence public source must not retain generic relay-shaped name `{forbidden}`" - ); - } -} - -#[test] -fn order_runtime_rejects_retired_status_source_names() { - let source = read_source( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/orders_runtime.rs") - .as_path(), - ); - - for retired in ["LocalEventStore", "local_event_store"] { - assert!( - !source.contains(retired), - "src/orders_runtime.rs must not expose retired trade status source `{retired}`" - ); - } -} - -#[test] -fn order_runtime_mutation_requests_require_evidence_mode() { - let source = read_source( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/orders_runtime.rs") - .as_path(), - ); - - assert!( - source.contains("pub enum TradeEvidenceMode"), - "src/orders_runtime.rs must define explicit trade mutation evidence modes" - ); - for variant in [ - "LocalOnly", - "ResyncBeforeMutation", - "RequireExplicitEvidence", - ] { - assert!( - source.contains(variant), - "TradeEvidenceMode must retain `{variant}`" - ); - } - for request in [ - "TradeAcceptRequest", - "TradeDeclineRequest", - "TradeCancelRequest", - ] { - let struct_block = struct_block(source.as_str(), request); - assert!( - struct_block.contains("pub evidence_mode: TradeEvidenceMode"), - "{request} must carry explicit evidence_mode" - ); - } - for constructor in [ - "impl TradeAcceptRequest", - "impl TradeDeclineRequest", - "impl TradeCancelRequest", - ] { - let impl_source = impl_block(source.as_str(), constructor); - assert!( - impl_source.contains("evidence_mode: TradeEvidenceMode"), - "{constructor}::new must require explicit evidence mode" - ); - } -} - -#[test] -fn retired_dvm_runtime_public_exports_are_absent() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let source = read_source(manifest_dir.join("src/lib.rs").as_path()); - - assert!( - !manifest_dir.join("src/dvm_runtime.rs").exists(), - "src/dvm_runtime.rs must not exist after V1 validation receipt consolidation" - ); - for forbidden in [ - "mod dvm_runtime;", - "pub mod dvm_runtime;", - "pub use crate::dvm_runtime", - "DVM_TRADE_TRANSITION_PROOF_REQUEST_CONTRACT_ID", - "DVM_TRADE_TRANSITION_PROOF_REQUEST_OPERATION_KIND", - "DvmProofMode", - "DvmTradeTransitionProof", - "DvmValidationReceipt", - "RadrootsTradeInventoryBinWitnessDto", - ] { - assert!( - !source.contains(forbidden), - "src/lib.rs must not expose retired DVM runtime surface `{forbidden}`" - ); - } -} - -#[test] -fn local_sdk_signer_uses_event_signer_capability_boundary() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let signer_source = read_source(manifest_dir.join("src/signer_provider.rs").as_path()); - let lib_source = read_source(manifest_dir.join("src/lib.rs").as_path()); - - for required in [ - "pub type RadrootsSdkLocalSignerCapability", - "dyn RadrootsEventSigner + Send + Sync", - "Arc<RadrootsSdkLocalSignerCapability>", - "pub fn from_event_signer", - "pub fn from_shared_event_signer", - ] { - assert!( - signer_source.contains(required), - "signer provider must expose the protected local signer capability boundary `{required}`" - ); - } - - assert!( - lib_source.contains("RadrootsSdkLocalSignerCapability"), - "SDK root exports the local signer capability type for downstream protected signer wiring" - ); - for forbidden in [ - "RadrootsSdkLocalKeySigner::new", - "pub fn new(keys", - "RadrootsLocalEventSigner", - ] { - assert!( - !signer_source.contains(forbidden), - "signer provider must not expose or couple to retired raw local signer constructor `{forbidden}`" - ); - } -} - -#[test] -fn private_store_schema_is_v1_encrypted_private_authority_only() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let source = read_source(manifest_dir.join("src/private_store.rs").as_path()); - - for required in [ - "private_metadata", - "wrapped_profile_key", - "wrapped_signing_secret", - "private_farm_location", - "trade_private_thread", - "buyer_contact_private", - "cursor_hmac_key", - "nip46_session_private", - "key_rotation_progress", - "RadrootsProtectedStoreEnvelope", - "RadrootsProtectedFileKeySource", - "credential_backend", - ] { - assert!( - source.contains(required), - "private store source must contain V1 encrypted private authority contract `{required}`" - ); - } - - assert!( - !source.contains("CREATE TABLE IF NOT EXISTS sdk_private_farm_location"), - "retired plaintext SDK private farm-location table must not be created" - ); - assert!( - source.matches("sdk_private_farm_location").count() == 1 - && source.contains("reject_pre_v1_private_store"), - "the retired private table name may appear only in the fail-closed pre-V1 rejection check" - ); - let migration_start = source - .find("const PRIVATE_STORE_MIGRATION_UP") - .expect("private store migration must exist"); - let migration_end = source[migration_start..] - .find("\"#;") - .map(|offset| migration_start + offset) - .expect("private store migration must close"); - let migration = &source[migration_start..migration_end]; - for forbidden_plaintext_column in [ - "label TEXT", - "latitude REAL", - "longitude REAL", - "locality_city TEXT", - "locality_region TEXT", - "locality_country TEXT", - "geohash5 TEXT", - "geonames_id", - ] { - assert!( - !migration.contains(forbidden_plaintext_column), - "private farm-location migration must not expose plaintext column `{forbidden_plaintext_column}`" - ); - } -} - -#[test] -fn identity_public_surface_is_an_explicit_feature_module() { - let lib_source = read_source( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/lib.rs") - .as_path(), - ); - let identity_source = read_source( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/identity.rs") - .as_path(), - ); - - assert!( - lib_source - .lines() - .any(|line| line.trim() == "pub mod identity;"), - "src/lib.rs must expose identity as an explicit feature-gated public module" - ); - assert!( - lib_source - .lines() - .all(|line| line.trim() != "mod identity;"), - "src/lib.rs must not hide identity exports in a private module" - ); - assert!( - !lib_source.contains("pub use crate::identity::{"), - "src/lib.rs must not flatten identity exports into root aliases" - ); - - for export in REQUIRED_IDENTITY_MODEL_EXPORTS { - assert!( - identity_source.contains(export), - "src/identity.rs must expose identity model export `{export}`" - ); - } - - for export in REQUIRED_IDENTITY_STORAGE_EXPORTS { - assert!( - identity_source.contains(export), - "src/identity.rs must expose identity storage export `{export}`" - ); - } -} - -#[test] -fn orders_client_surface_is_inventory_guarded() { - let source = read_source( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/orders_runtime.rs") - .as_path(), - ); - - assert!( - source.contains("impl<'sdk> TradesClient<'sdk> {"), - "src/orders_runtime.rs must own TradesClient runtime methods" - ); - - for method in REQUIRED_TRADES_CLIENT_METHODS { - assert!( - source.contains(method), - "TradesClient must expose inventory-guarded method `{method}`" - ); - } - - for method in FORBIDDEN_TRADES_CLIENT_PUBLIC_METHODS { - assert!( - !source.contains(method), - "TradesClient must not expose low-level trade mutation method `{method}`" - ); - } -} - -#[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_RESYNC_CLIENT_METHODS { - assert!( - source.contains(method), - "TradeResyncClient must expose product workflow method `{method}`" - ); - } - - for method in REQUIRED_TRADE_VALIDATION_RECEIPTS_CLIENT_METHODS { - assert!( - source.contains(method), - "TradeValidationReceiptsClient must expose product workflow method `{method}`" - ); - } -} - -#[test] -fn trade_product_facade_feature_gates_are_explicit() { - let orders_source = read_source( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/orders_runtime.rs") - .as_path(), - ); - let product_clients_source = read_source( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/product_clients.rs") - .as_path(), - ); - - assert!( - product_clients_source.contains( - "#[cfg(feature = \"signer-adapters\")]\n pub fn buyer(&self) -> TradeBuyerClient<'client> {" - ), - "TradesClient::buyer must be gated by signer-adapters inside the runtime TradesClient impl" - ); - assert!( - product_clients_source.contains( - "pub fn validation_receipts(&self) -> TradeValidationReceiptsClient<'client>" - ), - "TradesClient must expose validation receipts through the grouped trade product facade" - ); - assert!( - product_clients_source.contains( - "#[cfg(all(feature = \"runtime\", feature = \"signer-adapters\"))]\n#[derive(Clone, Copy)]\npub struct TradeBuyerClient<'client>" - ), - "TradeBuyerClient must be gated by runtime plus signer-adapters" - ); - assert!( - product_clients_source.contains( - "#[cfg(feature = \"runtime\")]\n#[derive(Clone, Copy)]\npub struct TradeSellerClient<'client>" - ), - "TradeSellerClient must remain runtime-visible for seller inbox reads" - ); - assert!( - orders_source.contains( - "#[cfg(all(feature = \"runtime\", feature = \"signer-adapters\"))]\nimpl<'sdk> TradeBuyerClient<'sdk> {" - ), - "TradeBuyerClient product mutation impl must require runtime plus signer-adapters" - ); - assert!( - orders_source - .contains("#[cfg(feature = \"runtime\")]\nimpl<'sdk> TradeSellerClient<'sdk> {"), - "TradeSellerClient impl must remain runtime-visible for seller inbox" - ); - - let seller_impl = impl_block( - orders_source.as_str(), - "impl<'sdk> TradeSellerClient<'sdk> {", - ); - assert!( - seller_impl.contains("pub async fn inbox("), - "TradeSellerClient must expose seller inbox in the runtime impl" - ); - for method in ["accept_trade", "decline_trade"] { - let gated_method = - format!("#[cfg(feature = \"signer-adapters\")]\n pub async fn {method}("); - assert!( - seller_impl.contains(gated_method.as_str()), - "TradeSellerClient::{method} must be gated by signer-adapters" - ); - } -} - -#[test] -fn trade_propose_request_stays_product_shaped() { - let source = read_source( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/orders_runtime.rs") - .as_path(), - ); - let request = struct_block(source.as_str(), "TradeProposeRequest"); - - assert!( - !request.contains("RadrootsOrderRequest"), - "TradeProposeRequest must not expose protocol-shaped order request input" - ); - for required_field in [ - "pub order_id: RadrootsOrderId", - "pub listing_addr: RadrootsListingAddress", - "pub seller_pubkey: RadrootsPublicKey", - "pub items: Vec<RadrootsOrderItem>", - "pub economics: RadrootsOrderEconomics", - "pub public_note: Option<String>", - ] { - assert!( - request.contains(required_field), - "TradeProposeRequest must expose product field `{required_field}`" - ); - } -} - -#[test] -fn retired_dvm_client_surface_is_absent() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let lib_source = read_source(manifest_dir.join("src/lib.rs").as_path()); - let clients_source = read_source(manifest_dir.join("src/product_clients.rs").as_path()); - - assert!( - !manifest_dir.join("src/dvm_runtime.rs").exists(), - "src/dvm_runtime.rs must not exist after V1 validation receipt consolidation" - ); - for forbidden in [ - "DvmClient", - "prepare_trade_transition_proof_request", - "enqueue_trade_transition_proof_request", - "ingest_validation_receipt", - ] { - assert!( - !lib_source.contains(forbidden) && !clients_source.contains(forbidden), - "SDK product client surface must not expose retired DVM client concept `{forbidden}`" - ); - } -} - -#[test] -fn product_clients_remain_thin_sdk_handles() { - let lib_source = read_source( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/lib.rs") - .as_path(), - ); - let clients_source = read_source( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/product_clients.rs") - .as_path(), - ); - - assert!( - lib_source.contains("mod product_clients;"), - "src/lib.rs must keep product_clients internal" - ); - assert!( - lib_source.contains("pub use crate::product_clients::{"), - "src/lib.rs must explicitly export product client handles" - ); - assert!( - !lib_source.contains("pub mod product_clients;"), - "src/lib.rs must not expose the product_clients module path" - ); - - for client in [ - "FarmsClient", - "ListingsClient", - "MarketClient", - "SyncClient", - "TradeResyncClient", - "TradeSellerClient", - "TradeValidationReceiptsClient", - "TradesClient", - ] { - assert!( - lib_source.contains(client), - "src/lib.rs must export product client handle `{client}`" - ); - assert!( - clients_source.contains(format!("pub struct {client}<'client>").as_str()), - "product_clients.rs must define thin handle `{client}`" - ); - } - - let runtime_product_exports = export_block( - lib_source.as_str(), - "#[cfg(feature = \"runtime\")]\npub use crate::product_clients::{", - ); - assert!( - !runtime_product_exports.contains("TradeBuyerClient"), - "src/lib.rs runtime-only product client export block must not expose TradeBuyerClient" - ); - assert!( - lib_source.contains( - "#[cfg(all(feature = \"runtime\", feature = \"signer-adapters\"))]\npub use crate::product_clients::TradeBuyerClient;" - ), - "src/lib.rs must export TradeBuyerClient only behind runtime plus signer-adapters" - ); - assert!( - clients_source.contains("pub struct TradeBuyerClient<'client>"), - "product_clients.rs must define thin signer-gated handle `TradeBuyerClient`" - ); - - for forbidden in FORBIDDEN_PRODUCT_CLIENT_HANDLES { - assert!( - !lib_source.contains(forbidden), - "src/lib.rs must not export removed product client handle `{forbidden}`" - ); - assert!( - !clients_source.contains(forbidden), - "product_clients.rs must not define removed product client handle `{forbidden}`" - ); - } - - for forbidden in FORBIDDEN_PRODUCT_CLIENT_METHODS { - assert!( - !clients_source.contains(forbidden), - "product_clients.rs must not expose removed product client method `{forbidden}`" - ); - } -} - -#[test] -fn removed_client_and_config_modules_are_absent() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - - for relative_path in ["src/client.rs", "src/config.rs"] { - let path = manifest_dir.join(relative_path); - assert!( - !path.exists(), - "{relative_path} must not exist after SDK runtime surface closure" - ); - } -} - -#[test] -fn removed_trade_client_root_export_is_absent() { - let source = read_source( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/lib.rs") - .as_path(), - ); - - assert!( - !source.contains("TradeClient"), - "src/lib.rs must not re-export the removed TradeClient facade" - ); -} - -#[test] -fn removed_client_config_modules_are_not_public() { - let source = read_source( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/lib.rs") - .as_path(), - ); - - for forbidden in [ - "pub mod client;", - "pub mod config;", - "pub use crate::client", - "pub use crate::config", - "RadrootsSdkClient", - "RadrootsSdkConfig", - "SdkTransportMode", - "ProfileClient", - "FarmClient", - "ListingClient", - "SdkPublishReceipt", - "SdkTransportReceipt", - ] { - assert!( - !source.contains(forbidden), - "src/lib.rs must not expose removed SDK client/config concept `{forbidden}`" - ); - } -} - -#[test] -fn sdk_public_api_does_not_export_protocol_workflow_bypass() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let source = read_source(manifest_dir.join("src/lib.rs").as_path()); - - for forbidden in ["pub mod protocol;", "pub use crate::protocol"] { - assert!( - !source.contains(forbidden), - "src/lib.rs must not expose protocol workflow bypass `{forbidden}`" - ); - } - - assert!( - !manifest_dir.join("src/protocol").exists(), - "src/protocol must not remain as a public protocol re-export surface" - ); +fn read_source(path: &Path) -> String { + fs::read_to_string(path).unwrap_or_else(|error| panic!("read {}: {error}", path.display())) } -#[test] -fn sdk_radrootsd_execution_surfaces_reject_removed_daemon_publish_identifiers() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - - for relative_path in [ - "Cargo.toml", - "src/lib.rs", - "src/adapters/radrootsd.rs", - "src/sync_runtime.rs", - "src/transport.rs", - ] { - let source = read_source(manifest_dir.join(relative_path).as_path()); - for forbidden in FORBIDDEN_DAEMON_PUBLISH_PROXY_IDENTIFIERS { - assert!( - !contains_forbidden_concept(source.as_str(), forbidden), - "{relative_path} must not reintroduce removed daemon publish identifier `{forbidden}`" - ); - } - } - - let transport_source = read_source(manifest_dir.join("src/transport.rs").as_path()); - assert!( - !transport_source.contains(concat!("RadrootsTransportKind::", "Pro", "xy")), - "src/transport.rs must not model radrootsd execution as a transport kind" - ); - - let sync_runtime_source = read_source(manifest_dir.join("src/sync_runtime.rs").as_path()); - assert!( - !sync_runtime_source.contains(concat!("RadrootsTransportKind::", "Pro", "xy")), - "src/sync_runtime.rs must not identify radrootsd execution through a transport kind" - ); - assert!( - sync_runtime_source.contains("radrootsd execution explicit targets are Nostr-only"), - "src/sync_runtime.rs must reject non-Nostr radrootsd explicit targets before behavior is lost" - ); - assert!( - sync_runtime_source.contains("active_delivery_plan_id(claimed"), - "src/sync_runtime.rs must derive radrootsd publish behavior from the claimed active delivery plan" - ); - for required in [ - "let mut completed_target_ids = std::collections::BTreeSet::new();", - "let mut matched_outcomes = Vec::new();", - "matched multiple ready delivery targets", - "matched delivery target", - "more than once", - ] { - assert!( - sync_runtime_source.contains(required), - "src/sync_runtime.rs must retain radrootsd completion uniqueness witness `{required}`" - ); - } - assert!( - !sync_runtime_source.contains("TransportPublishReticulumBehavior::RejectDeliveryAttempts"), - "src/sync_runtime.rs must not rewrite Reticulum radrootsd outbox targets to reject attempts" - ); - let sync_runtime_unit_source = read_source( - manifest_dir - .join("tests/unit/sync_runtime_tests.rs") - .as_path(), - ); - for required in [ - "claimed_uningested_radrootsd_event", - "assert_no_transport_publish_request", - "assert!(!stored_before.event_store_ingested)", - "assert!(!stored.event_store_ingested)", - "with_timeout(Duration::from_millis(50))", - "radrootsd_delivery_policy_rejects_non_accepted_satisfaction_before_daemon_publish", - "radrootsd_outbox_target_conversion_preserves_nostr_scope_and_label", - "radrootsd_completion_matches_duplicate_endpoint_targets_by_scope", - "push_radrootsd_event_receipt_preserves_daemon_target_metadata", - "radrootsd_completion_rejects_duplicate_daemon_outcome_before_local_mutation", - ] { - assert!( - sync_runtime_unit_source.contains(required), - "tests/unit/sync_runtime_tests.rs must retain radrootsd local-validation ordering proof `{required}`" - ); - } - - let adapter_source = read_source(manifest_dir.join("src/adapters/radrootsd.rs").as_path()); - assert!( - !adapter_source.contains("impl RadrootsRelayPublishAdapter for RadrootsdPublishAdapter"), - "src/adapters/radrootsd.rs must not implement relay publish traits for RadrootsdPublishAdapter" - ); - assert!( - !adapter_source.contains("radrootsd_relay_receipt_from_response"), - "src/adapters/radrootsd.rs must not convert typed transport publish jobs into relay receipts" - ); - assert!( - !adapter_source.contains("TransportPublishReticulumBehavior::RejectDeliveryAttempts"), - "src/adapters/radrootsd.rs must not rewrite Reticulum relay targets to reject attempts" - ); - - for required in [ - "TransportPublishOutcomeKind::DeferredUntilImplemented", - "mark_delivery_target_deferred_until_implemented", - "TransportPublishOutcomeKind::DeferredUntilImplemented", - "mark_delivery_target_deferred_until_implemented", - "PushOutboxEventState::DeferredUntilImplemented", - "PushOutboxEventState::DeferredUntilImplemented", - "PushOutboxTargetOutcomeKind::DeferredUntilImplemented", - "PushOutboxTargetOutcomeKind::DeferredUntilImplemented", - "reject_non_accepted_radrootsd_satisfaction", - "class != RadrootsTransportSatisfactionClass::Accepted", - "target.target_scope.as_ref()", - "outcome.target_scope.as_deref()", - "target_scope: outcome.target_scope", - "target_label: outcome.target_label", - ] { - assert!( - sync_runtime_source.contains(required), - "src/sync_runtime.rs must preserve radrootsd deferred outcome witness `{required}`" - ); - } - - let receipt_source = source_between( - sync_runtime_source.as_str(), - "fn push_radrootsd_event_receipt", - "fn push_radrootsd_target_receipt", - ); - assert!( - receipt_source.contains("push_receipt_event_id("), - "push_radrootsd_event_receipt must convert daemon event ids through the typed receipt helper" - ); - for forbidden in [".expect(", ".unwrap(", "panic!("] { - assert!( - !receipt_source.contains(forbidden), - "push_radrootsd_event_receipt must not use production panic path `{forbidden}`" - ); - } - - let radrootsd_target_receipt_source = source_between( - sync_runtime_source.as_str(), - "fn push_radrootsd_target_receipt", - "fn push_radrootsd_target_outcome_kind", - ); - for forbidden in ["target_scope: None", "target_label: None"] { - assert!( - !radrootsd_target_receipt_source.contains(forbidden), - "push_radrootsd_target_receipt must not hard-code daemon metadata field `{forbidden}`" - ); - } +fn active_source_files() -> Vec<PathBuf> { + let src = manifest_dir().join("src"); + fs::read_dir(src) + .expect("src entries") + .map(|entry| entry.expect("src entry").path()) + .filter(|path| path.extension().is_some_and(|extension| extension == "rs")) + .collect() } #[test] -fn sdk_direct_nostr_push_receipts_preserve_outbox_target_metadata() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let sync_runtime_source = read_source(manifest_dir.join("src/sync_runtime.rs").as_path()); - - for required in [ - "RadrootsOutboxPublishReceipt", - "RadrootsOutboxPublishTargetReceipt", - "push_event_final_state(&publish)", - ".target_receipts", - "target_scope: target.target_scope", - "target_label: target.target_label", - ] { - assert!( - sync_runtime_source.contains(required), - "src/sync_runtime.rs must preserve direct Nostr target-aware receipt witness `{required}`" - ); - } +fn retired_order_runtime_roots_are_not_active() { + let manifest = manifest_dir(); + let lib = read_source(&manifest.join("src/lib.rs")); - let direct_event_receipt_source = source_between( - sync_runtime_source.as_str(), - "fn push_event_receipt", - "fn push_receipt_event_id", - ); - assert!( - !direct_event_receipt_source.contains(".relays"), - "push_event_receipt must not derive SDK direct publish targets from URL-level relay receipts" - ); - - let direct_target_receipt_source = source_between( - sync_runtime_source.as_str(), - "fn push_target_receipt", - "#[cfg(all(test", - ); for forbidden in [ - "target_scope: None", - "target_label: None", - "RadrootsRelayPublishRelayReceipt", + "mod orders_runtime;", + "mod market_runtime;", + "mod trade_storage;", + "mod order;", + "pub use crate::orders_runtime", + "pub use crate::market_runtime", + "pub use crate::trade_storage", ] { assert!( - !direct_target_receipt_source.contains(forbidden), - "push_target_receipt must not hard-code or relay-derive direct Nostr metadata token `{forbidden}`" + !lib.contains(forbidden), + "src/lib.rs must not activate retired runtime root `{forbidden}`" ); } - let sync_runtime_test_source = - read_source(manifest_dir.join("tests/sync_runtime.rs").as_path()); - for required in [ - "push_outbox_with_transport_preserves_scoped_duplicate_target_metadata", - "push_outbox_adapter_transport_failure_preserves_scoped_target_metadata", - "target.target_scope.as_deref() == Some(\"farm.a\")", - "target.target_scope.as_deref() == Some(\"farm.b\")", + for retired in [ + "src/orders_runtime.rs", + "src/market_runtime.rs", + "src/trade_storage.rs", + "src/order.rs", + "tests/orders_runtime.rs", + "tests/market_runtime.rs", + "tests/trade_public_api.rs", + "tests/trade_product_publish_runtime.rs", ] { assert!( - sync_runtime_test_source.contains(required), - "tests/sync_runtime.rs must retain SDK direct Nostr metadata coverage `{required}`" + !manifest.join(retired).exists(), + "retired SDK order runtime file must be removed: {retired}" ); } } #[test] -fn sdk_public_outcome_label_contracts_are_explicit() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let sync_runtime_source = read_source(manifest_dir.join("src/sync_runtime.rs").as_path()); - let orders_runtime_source = read_source(manifest_dir.join("src/orders_runtime.rs").as_path()); - - for (source, enum_name, required_labels) in [ - ( - sync_runtime_source.as_str(), - "PushOutboxTargetOutcomeKind", - &[ - r#"Self::Accepted => "accepted""#, - r#"Self::DuplicateAccepted => "duplicate_accepted""#, - r#"Self::Blocked => "blocked""#, - r#"Self::RateLimited => "rate_limited""#, - r#"Self::Invalid => "invalid""#, - r#"Self::PowRequired => "pow_required""#, - r#"Self::Restricted => "restricted""#, - r#"Self::AuthRequired => "auth_required""#, - r#"Self::Muted => "muted""#, - r#"Self::Unsupported => "unsupported""#, - r#"Self::PaymentRequired => "payment_required""#, - r#"Self::Error => "error""#, - r#"Self::Timeout => "timeout""#, - r#"Self::ConnectionFailed => "connection_failed""#, - r#"Self::TargetUriRejected => "target_uri_rejected""#, - r#"Self::SkippedAlreadyAccepted => "skipped_already_accepted""#, - r#"Self::DeferredUntilImplemented => "deferred_until_implemented""#, - r#"Self::Unknown => "unknown""#, - ][..], - ), - ( - sync_runtime_source.as_str(), - "PushOutboxTransportOutcomeKind", - &[ - r#"Self::Accepted => "accepted""#, - r#"Self::DuplicateAccepted => "duplicate_accepted""#, - r#"Self::Delivered => "delivered""#, - r#"Self::Forwarded => "forwarded""#, - r#"Self::StoredByGateway => "stored_by_gateway""#, - r#"Self::Seen => "seen""#, - r#"Self::DeferredUntilImplemented => "deferred_until_implemented""#, - r#"Self::Rejected => "rejected""#, - r#"Self::RouteUnavailable => "route_unavailable""#, - r#"Self::PayloadTooLarge => "payload_too_large""#, - r#"Self::PolicyDenied => "policy_denied""#, - r#"Self::Timeout => "timeout""#, - r#"Self::ConnectionFailed => "connection_failed""#, - r#"Self::TransportUnavailable => "transport_unavailable""#, - ][..], - ), - ( - orders_runtime_source.as_str(), - "TradeResyncNostrRelayOutcomeKind", - &[ - r#"Self::Eose => "eose""#, - r#"Self::Closed => "closed""#, - r#"Self::Notice => "notice""#, - ][..], - ), - ( - orders_runtime_source.as_str(), - "TradeResyncNostrRelayTransportOutcomeKind", - &[ - r#"Self::Accepted => "accepted""#, - r#"Self::DuplicateAccepted => "duplicate_accepted""#, - r#"Self::Blocked => "blocked""#, - r#"Self::RateLimited => "rate_limited""#, - r#"Self::Invalid => "invalid""#, - r#"Self::PowRequired => "pow_required""#, - r#"Self::Restricted => "restricted""#, - r#"Self::AuthRequired => "auth_required""#, - r#"Self::Muted => "muted""#, - r#"Self::Unsupported => "unsupported""#, - r#"Self::PaymentRequired => "payment_required""#, - r#"Self::Error => "error""#, - r#"Self::Timeout => "timeout""#, - r#"Self::ConnectionFailed => "connection_failed""#, - r#"Self::RelayUrlRejected => "relay_url_rejected""#, - r#"Self::SkippedAlreadyAccepted => "skipped_already_accepted""#, - r#"Self::Unknown => "unknown""#, - ][..], - ), - ( - orders_runtime_source.as_str(), - "TradeValidationReceiptNostrRelayOutcomeKind", - &[ - r#"Self::Eose => "eose""#, - r#"Self::Closed => "closed""#, - r#"Self::Notice => "notice""#, - ][..], - ), - ( - orders_runtime_source.as_str(), - "TradeValidationReceiptNostrRelayTransportOutcomeKind", - &[ - r#"Self::Accepted => "accepted""#, - r#"Self::DuplicateAccepted => "duplicate_accepted""#, - r#"Self::Blocked => "blocked""#, - r#"Self::RateLimited => "rate_limited""#, - r#"Self::Invalid => "invalid""#, - r#"Self::PowRequired => "pow_required""#, - r#"Self::Restricted => "restricted""#, - r#"Self::AuthRequired => "auth_required""#, - r#"Self::Muted => "muted""#, - r#"Self::Unsupported => "unsupported""#, - r#"Self::PaymentRequired => "payment_required""#, - r#"Self::Error => "error""#, - r#"Self::Timeout => "timeout""#, - r#"Self::ConnectionFailed => "connection_failed""#, - r#"Self::RelayUrlRejected => "relay_url_rejected""#, - r#"Self::SkippedAlreadyAccepted => "skipped_already_accepted""#, - r#"Self::Unknown => "unknown""#, - ][..], - ), - ] { - let marker = format!("impl {enum_name} {{"); - let block = impl_block(source, marker.as_str()); - assert!( - block.contains("pub fn as_str(self) -> &'static str"), - "{enum_name} must expose an SDK-owned public label helper" - ); - for required in required_labels { +fn active_sources_do_not_import_retired_trade_modules() { + for path in active_source_files() { + let source = read_source(&path); + for forbidden in ["radroots_trade::order", "radroots_trade::projection"] { assert!( - block.contains(required), - "{enum_name} label helper must explicitly cover `{required}`" - ); - } - for forbidden in ["serde_json::to_value", "panic!(", "_ =>"] { - assert!( - !block.contains(forbidden), - "{enum_name} label helper must not contain forbidden conversion token `{forbidden}`" + !source.contains(forbidden), + "{} must not import retired trade module `{forbidden}`", + path.display() ); } } } #[test] -fn sdk_transport_policy_sources_reject_configured_profile_and_radrootsd_relay_bridge() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - for relative_path in [ - "src", - "tests", - "examples", - "packages/event-bindings/src", - "packages/event-codec-js/src", - ] { - let root = manifest_dir.join(relative_path); - if !root.exists() { - continue; - } - for path in rust_source_files(root.as_path()) { - if path.file_name().and_then(|file_name| file_name.to_str()) - == Some("source_boundary.rs") - { - continue; - } - let source = read_source(path.as_path()); - for forbidden in [ - "UseConfiguredProfile", - "use_configured_profile", - "configured_profile()", - "UseTransportProfile", - "use_transport_profile", - "impl RadrootsRelayPublishAdapter for RadrootsdPublishAdapter", - "radrootsd_relay_receipt_from_response", - ] { - assert!( - !source.contains(forbidden), - "{} must not reintroduce removed transport policy or relay bridge surface `{forbidden}`", - path.display() - ); - } - } - } -} - -#[test] -fn sdk_transport_sources_reject_digest_only_and_top_level_relay_adapter_dispatch() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - for relative_path in ["src", "tests", "examples"] { - let root = manifest_dir.join(relative_path); - if !root.exists() { - continue; - } - for path in rust_source_files(root.as_path()) { - if path.file_name().and_then(|file_name| file_name.to_str()) - == Some("source_boundary.rs") - { - continue; - } - let source = read_source(path.as_path()); - let relative_path = relative_manifest_path(manifest_dir, path.as_path()); - for forbidden in ["payload_digest", "DigestOnly", "push_outbox_with_adapter"] { - assert!( - !source.contains(forbidden), - "{relative_path} must not retain digest-only or adapter-owned transport dispatch `{forbidden}`" - ); - } - } - } - - let sync_runtime = read_source(manifest_dir.join("src/sync_runtime.rs").as_path()); - for required in [ - "RadrootsNostrTransport::new(adapter)", - "self.push_outbox_with_transport(&transport, request).await", - "pub async fn push_outbox_with_transport<T>", - "T: radroots_transport::RadrootsTransport + ?Sized", - "publish_claimed_outbox_event_with_transport(", - ] { - assert!( - sync_runtime.contains(required), - "src/sync_runtime.rs must retain generic transport dispatch witness `{required}`" - ); - } - assert!( - !sync_runtime.contains("RadrootsRelayPublishAdapter"), - "src/sync_runtime.rs must not expose relay adapter dispatch as the top-level SDK push path" - ); -} - -#[test] -fn sdk_sync_status_sources_reject_retired_relay_shaped_generic_fields() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let sync_runtime = read_source(manifest_dir.join("src/sync_runtime.rs").as_path()); - let status_summary = source_between( - sync_runtime.as_str(), - "pub struct SyncTransportStatusSummary", - "\n}\n\n#[cfg(feature = \"runtime\")]\nimpl SyncTransportStatusSummary", - ); - - for required in [ - "pub configured_transport_target_count: usize,", - "pub configured_transport_targets: Vec<SyncTransportTargetSummary>,", - "pub transport_statuses: Vec<SyncTransportStatusSummary>,", - ".configured_transport_targets()?", - ".transport_statuses()", - "pub target_scope: Option<String>,", - "pub target_label: Option<String>,", - "target.scope.as_ref()", - "target.label.as_ref()", - "pub transport: String,", - "pub configured: bool,", - "pub implementation: String,", - "pub usable_for_delivery: bool,", - "pub capabilities: SyncTransportOperationCapabilitiesSummary,", - "pub struct SyncTransportOperationCapabilitiesSummary", - "pub deliver: bool,", - "pub fetch: bool,", - "status.capabilities.deliver", - "status.capabilities.fetch", - "pub message: String,", - ] { - assert!( - sync_runtime.contains(required), - "SDK sync status must retain transport-neutral field witness `{required}`" - ); - } - - for required in [ - "pub transport: String,", - "pub configured: bool,", - "pub implementation: String,", - "pub usable_for_delivery: bool,", - "pub capabilities: SyncTransportOperationCapabilitiesSummary,", - "pub message: String,", - ] { - assert!( - status_summary.contains(required), - "SDK sync status summary must retain canonical status field witness `{required}`" - ); - } - - let operation_capabilities_summary = source_between( - sync_runtime.as_str(), - "pub struct SyncTransportOperationCapabilitiesSummary", - "\n}\n\n#[cfg(feature = \"runtime\")]\nimpl SyncTransportStatusSummary", - ); - for required in ["pub deliver: bool,", "pub fetch: bool,"] { - assert!( - operation_capabilities_summary.contains(required), - "SDK sync transport capabilities must retain operation field witness `{required}`" - ); - } - - for forbidden in [ - concat!("configured_nostr", "_relay", "_count"), - concat!("configured_nostr", "_relays"), - concat!("target", "_relays"), - concat!("connected", "_relays"), - concat!("acknowledged", "_relays"), - concat!("failed", "_relays"), - concat!("relay", "_count"), - ] { - assert!( - !sync_runtime.contains(forbidden), - "SDK sync status source must not expose retired relay-shaped generic field `{forbidden}`" - ); - } - - for forbidden in [ - concat!("pub transport", "_kind: String,"), - concat!("pub implementation", "_state: String,"), - "pub readiness: String,", - concat!("pub publish", "_usable: bool,"), - concat!("pub fetch", "_usable: bool,"), - concat!("pub redacted", "_message: Option<String>,"), - ] { - assert!( - !status_summary.contains(forbidden), - "SDK sync status summary must not expose retired status field `{forbidden}`" - ); - } -} - -#[test] -fn sdk_transport_sources_expose_full_satisfaction_policy_without_legacy_aliases() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let transport_source = read_source(manifest_dir.join("src/transport.rs").as_path()); - let workflow_runtime = read_source(manifest_dir.join("src/workflow_runtime.rs").as_path()); - - for required in [ - "pub enum SatisfactionPolicy", - "AnyAccepted", - "AllAccepted", - "QuorumAccepted", - "AnyDelivered", - "AllDelivered", - "QuorumDelivered", - "RequiredAcceptedTargets", - "RequiredDeliveredTargets", - "pub fn quorum_accepted(", - "pub fn quorum_delivered(", - "pub fn required_accepted_targets", - "pub fn required_delivered_targets", - "RadrootsTransportSatisfactionPolicy::required_targets", - "RadrootsTransportSatisfactionClass::Accepted", - "RadrootsTransportSatisfactionClass::Delivered", - ] { - assert!( - transport_source.contains(required), - "src/transport.rs must retain full SDK satisfaction policy witness `{required}`" - ); - } - - for forbidden in [ - "AtLeastOneTarget", - "AllTargets", - "pub fn at_least", - concat!("at_least", "_one_target"), - concat!("all", "_targets"), - ] { - assert!( - !transport_source.contains(forbidden) && !workflow_runtime.contains(forbidden), - "SDK transport/workflow sources must not retain legacy satisfaction alias `{forbidden}`" - ); - } -} - -#[test] -fn sdk_workflow_runtime_records_local_import_observations() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let workflow_runtime = read_source(manifest_dir.join("src/workflow_runtime.rs").as_path()); - - for required in [ - "SDK_LOCAL_EVENT_ENDPOINT_URI", - "RadrootsTransportKind::Local", - "RadrootsTransportObservationType::LocalImport", - ".with_observation(local_import_observation)", - ] { - assert!( - workflow_runtime.contains(required), - "SDK workflow runtime must retain local import observation witness `{required}`" - ); - } -} - -#[test] -fn sdk_transport_sources_keep_reticulum_push_boundary() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - - for relative_path in [ - "src/transport.rs", - "src/sync_runtime.rs", - "src/error.rs", - "src/adapters/radrootsd.rs", - ] { - let source = read_source(manifest_dir.join(relative_path).as_path()); - if let Some(line) = removed_reticulum_endpoint_lines(source.as_str()) - .into_iter() - .next() - { - panic!("{relative_path}:{line} contains removed Reticulum endpoint"); - } - } - - let transport_source = read_source(manifest_dir.join("src/transport.rs").as_path()); - assert!( - transport_source.contains("RADROOTS_RETICULUM_ENDPOINT_URI"), - "src/transport.rs must consume the shared Reticulum endpoint constant" - ); - assert!( - !transport_source.contains("const RETICULUM_PREVIEW_ENDPOINT_URI"), - "src/transport.rs must not duplicate the Reticulum endpoint constant" - ); - assert!( - !transport_source.contains("reticulum:local"), - "src/transport.rs must not duplicate the Reticulum endpoint literal" - ); - - let sync_runtime = read_source(manifest_dir.join("src/sync_runtime.rs").as_path()); - for required in [ - "TransportProfile::Reticulum { .. }", - "reticulum_push_receipt", - "reticulum_event_receipt", - "push_reported_event", - "RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE", - "ReticulumTryNowRequest", - "try_reticulum_now", - "\"sync.try_reticulum_now\"", - "RadrootsSdkError::ReticulumTransportUnavailable", - "pub target_scope: Option<String>,", - "pub target_label: Option<String>,", - "pub transport_outcome_kind: Option<PushOutboxTransportOutcomeKind>,", - "PushOutboxTransportOutcomeKind::TransportUnavailable", - ] { - assert!( - sync_runtime.contains(required), - "src/sync_runtime.rs must retain Reticulum push boundary `{required}`" - ); - } - assert!( - !sync_runtime.contains("push_outbox_has_no_reticulum_work"), - "src/sync_runtime.rs must not revive the Reticulum ready-work error probe" - ); - assert!( - !sync_runtime.contains("RadrootsSdkError::reticulum_transport_unavailable(\n"), - "src/sync_runtime.rs must not return Reticulum deferred until implemented errors from push_outbox" - ); - - let error_source = read_source(manifest_dir.join("src/error.rs").as_path()); - for required in [ - "reticulum_transport_unavailable", - "reticulum_transport_deferred", - "Reticulum endpoint", - ] { - assert!( - error_source.contains(required), - "src/error.rs must retain Reticulum error witness `{required}`" - ); - } -} - -#[test] -fn sdk_feature_matrix_keeps_reticulum_runtime_owned_without_alias() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let manifest_source = read_source(manifest_dir.join("Cargo.toml").as_path()); - let features_source = source_between(manifest_source.as_str(), "[features]", "[dependencies]"); - let runtime_source = source_between(features_source, "runtime = [", "local-signer = ["); - let nostr_runtime_source = source_between( - features_source, - "transport-nostr-runtime = [", - "local-runtime = [", - ); - - for required in [ - "\"dep:radroots_transport_reticulum\"", - "\"dep:radroots_transport\"", - "\"dep:radroots_outbox\"", - ] { - assert!( - runtime_source.contains(required), - "SDK runtime feature must retain Reticulum matrix witness `{required}`" - ); - } - for required in [ - "\"runtime\"", - "\"dep:radroots_nostr\"", - "\"radroots_nostr/client\"", - "\"radroots_transport_nostr/client\"", - ] { - assert!( - nostr_runtime_source.contains(required), - "SDK Nostr runtime feature must retain real delivery matrix witness `{required}`" - ); - } - - for forbidden in [ - concat!("transport-reticulum-", "pre", "view"), - "radroots_transport_reticulum/client", - "reticulum-runtime", - "dep:rns", - "dep:rnsd", - "dep:reticulum", - "dep:pyo3", - "package = \"reticulum\"", - "package = \"pyo3\"", - ] { - assert!( - !manifest_source.contains(forbidden), - "SDK feature matrix must not introduce Reticulum alias or real runtime dependency `{forbidden}`" - ); - } - - let xtask_check_source = read_source( - manifest_dir - .join("../../tools/xtask/src/check.rs") - .as_path(), - ); - for required in [ - "check_sdk_workspace_reticulum_dependency_boundaries", - "check_manifest_reticulum_dependency_boundaries", - "check_cargo_lock_reticulum_package_names", - "check_cargo_metadata_reticulum_package_names", - "package = \"reticulum\"", - "package = \"pyo3\"", - "dto_bindgen_backend_python", - ] { - assert!( - xtask_check_source.contains(required), - "SDK xtask Reticulum release gate must retain package-graph bypass witness `{required}`" - ); - } - - let sync_runtime_source = read_source(manifest_dir.join("src/sync_runtime.rs").as_path()); - for required in [ - "#[cfg(not(feature = \"radrootsd-execution\"))]", - "self.sdk.radrootsd_execution_profile().is_some()", - "required_feature: \"radrootsd-execution\"", - ] { - assert!( - sync_runtime_source.contains(required), - "SDK runtime-only push_outbox must retain radrootsd execution feature gate witness `{required}`" - ); - } -} - -fn product_runtime_file_stays_on_boundary(relative_path: &str) { - let source = read_source( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join(relative_path) - .as_path(), - ); - - for forbidden in [ - "RadrootsSdkClient", - "SdkTransportMode", - "SdkPublishReceipt", - "radrootsd", - "publish_with_identity", - "publish_parts_via_relay", - "publish_listing_via_radrootsd", - "publish_order_request_via_radrootsd", - "publish_farm_via_radrootsd", - ] { - assert!( - !source.contains(forbidden), - "{relative_path} must not use removed SDK client or transport concept `{forbidden}`" - ); - } -} - -fn sdk_root_alias_guard_files(manifest_dir: &Path) -> Vec<PathBuf> { - let mut paths = vec![manifest_dir.join("README")]; - - for relative_root in ["src", "examples", "tests"] { - let root = manifest_dir.join(relative_root); - if root.exists() { - paths.extend(rust_source_files(root.as_path())); - } - } - - paths.retain(|path| { - path.file_name().and_then(|file_name| file_name.to_str()) != Some("source_boundary.rs") - }); - paths.sort(); - paths -} - -fn sdk_foundation_hardening_guard_files(repo_root: &Path) -> Vec<PathBuf> { - let mut paths = Vec::new(); - - for path in [ - repo_root.join("Cargo.toml"), - repo_root.join("package.json"), - repo_root.join("README"), - repo_root.join("README.md"), - ] { - if path.exists() { - paths.push(path); - } - } - - for relative_root in ["crates", "packages"] { - let root = repo_root.join(relative_root); - if root.exists() { - collect_sdk_foundation_hardening_guard_files(root.as_path(), &mut paths); - } - } - - paths.sort(); - paths -} - -fn collect_sdk_foundation_hardening_guard_files(root: &Path, paths: &mut Vec<PathBuf>) { - for entry in fs::read_dir(root) - .unwrap_or_else(|error| panic!("failed to read {}: {error}", root.display())) - { - let path = entry.expect("SDK guard entry").path(); - if path.file_name().and_then(|file_name| file_name.to_str()) == Some("source_boundary.rs") { - continue; - } - - if path.is_dir() { - if matches!( - path.file_name().and_then(|file_name| file_name.to_str()), - Some("target") | Some("node_modules") | Some("dist") - ) { - continue; - } - collect_sdk_foundation_hardening_guard_files(path.as_path(), paths); - continue; - } - - let is_guarded_extension = matches!( - path.extension().and_then(|extension| extension.to_str()), - Some("rs") | Some("ts") | Some("json") | Some("toml") | Some("md") - ); - if is_guarded_extension - || matches!( - path.file_name().and_then(|file_name| file_name.to_str()), - Some("README") | Some("README.md") - ) - { - paths.push(path); - } - } -} - -fn foundation_hardening_retired_concept_allowed(relative_path: &str, pattern: &str) -> bool { - pattern == "RadrootsNostrEvent" && sdk_nostr_protocol_context(relative_path) -} - -fn sdk_nostr_protocol_context(relative_path: &str) -> bool { - matches!( - relative_path, - "crates/sdk/src/adapters/nostr.rs" - | "crates/sdk/src/adapters/signing.rs" - | "crates/sdk/src/signer_provider.rs" - | "crates/sdk/examples/sdk_v1_myc_nip46_signer_setup.rs" - | "crates/sdk/tests/unit/signer_provider_tests.rs" - ) -} - -fn read_source(path: &Path) -> String { - fs::read_to_string(path) - .unwrap_or_else(|error| panic!("failed to read source {}: {error}", path.display())) -} - -fn contains_forbidden_concept(source: &str, pattern: &str) -> bool { - if !pattern.chars().all(is_rust_identifier_character) { - return source.contains(pattern); - } - - source.match_indices(pattern).any(|(index, _)| { - let before = source[..index].chars().next_back(); - let after = source[index + pattern.len()..].chars().next(); - before.is_none_or(|character| !is_rust_identifier_character(character)) - && after.is_none_or(|character| !is_rust_identifier_character(character)) - }) -} - -fn is_doc_surface(path: &Path) -> bool { - matches!( - path.file_name().and_then(|file_name| file_name.to_str()), - Some("README") | Some("README.md") - ) || matches!( - path.extension().and_then(|extension| extension.to_str()), - Some("md") - ) -} - -fn removed_reticulum_endpoint_lines(source: &str) -> Vec<usize> { - let removed_endpoint = ["reticulum:", "pre", "view"].concat(); - source - .match_indices(removed_endpoint.as_str()) - .filter_map(|(index, _)| { - let after = source[index + removed_endpoint.len()..].chars().next(); - (after != Some('-')).then(|| line_number(source, index)) - }) - .collect() -} - -fn root_trade_alias_findings(relative_path: &str, source: &str) -> Vec<String> { - let mut findings = Vec::new(); - - for alias in FORBIDDEN_SDK_ROOT_TRADE_ALIAS_NAMES { - for (index, _) in source.match_indices(alias) { - let before = source[..index].chars().next_back(); - let after_index = index + alias.len(); - let after = source[after_index..].chars().next(); - - if before.is_some_and(is_rust_identifier_character) - || after.is_some_and(is_rust_identifier_character) - { - continue; - } - - if source[after_index..] - .chars() - .find(|character| !character.is_whitespace()) - != Some('(') - { - continue; - } - - let prefix = source[..index].trim_end(); - let reason = if prefix.ends_with('.') { - "root client method call" - } else if prefix.ends_with("::") { - "root client associated function call" - } else if prefix_ends_with_keyword(prefix, "fn") { - "function or method definition" - } else { - continue; - }; - - findings.push(format!( - "{relative_path}:{} uses `{alias}` as a {reason}", - line_number(source, index) - )); - } - } - - findings -} - -fn prefix_ends_with_keyword(source: &str, keyword: &str) -> bool { - source.ends_with(keyword) - && source[..source.len() - keyword.len()] - .chars() - .next_back() - .is_none_or(|character| !is_rust_identifier_character(character)) -} - -fn line_number(source: &str, index: usize) -> usize { - source[..index] - .bytes() - .filter(|byte| *byte == b'\n') - .count() - + 1 -} - -fn relative_manifest_path(manifest_dir: &Path, path: &Path) -> String { - path.strip_prefix(manifest_dir) - .unwrap_or(path) - .display() - .to_string() -} - -fn function_source<'source>(source: &'source str, function_name: &str) -> &'source str { - let signature = format!("async fn {function_name}"); - let start = source - .find(signature.as_str()) - .unwrap_or_else(|| panic!("failed to find test function `{function_name}`")); - let source_after_start = &source[start..]; - let end = source_after_start - .find("\n#[tokio::test]") - .unwrap_or(source_after_start.len()); - &source_after_start[..end] -} - -fn source_between<'source>( - source: &'source str, - start_marker: &str, - end_marker: &str, -) -> &'source str { - let start = source - .find(start_marker) - .unwrap_or_else(|| panic!("failed to find source marker `{start_marker}`")); - let source_after_start = &source[start..]; - let end = source_after_start - .find(end_marker) - .unwrap_or_else(|| panic!("failed to find source marker `{end_marker}`")); - &source_after_start[..end] -} - -fn export_block<'source>(source: &'source str, marker: &str) -> &'source str { - let start = source - .find(marker) - .unwrap_or_else(|| panic!("failed to find export block marker `{marker}`")); - let source_after_start = &source[start..]; - let end = source_after_start - .find("\n};") - .unwrap_or_else(|| panic!("failed to find end of export block `{marker}`")); - &source_after_start[..end] -} - -fn impl_block<'source>(source: &'source str, marker: &str) -> &'source str { - let start = source - .find(marker) - .unwrap_or_else(|| panic!("failed to find impl block marker `{marker}`")); - let source_after_start = &source[start..]; - let end = source_after_start - .find("\n}\n\n#[cfg(") - .unwrap_or(source_after_start.len()); - &source_after_start[..end] -} - -fn struct_block<'source>(source: &'source str, marker: &str) -> &'source str { - let signature = format!("pub struct {marker}"); - let start = source - .find(signature.as_str()) - .unwrap_or_else(|| panic!("failed to find struct block marker `{marker}`")); - let source_after_start = &source[start..]; - let end = source_after_start - .find("\n}\n\n#[cfg(") - .unwrap_or(source_after_start.len()); - &source_after_start[..end] -} - -fn is_rust_identifier_character(character: char) -> bool { - character == '_' || character.is_ascii_alphanumeric() -} - -fn rust_source_files(root: &Path) -> Vec<PathBuf> { - let mut paths = Vec::new(); - collect_rust_source_files(root, &mut paths); - paths.sort(); - paths -} - -fn collect_rust_source_files(root: &Path, paths: &mut Vec<PathBuf>) { - let entries = fs::read_dir(root).unwrap_or_else(|error| { - panic!( - "failed to read source directory {}: {error}", - root.display() - ) - }); - - for entry in entries { - let entry = entry.unwrap_or_else(|error| { - panic!( - "failed to inspect source directory {}: {error}", - root.display() - ) - }); - let path = entry.path(); - - if path.is_dir() { - collect_rust_source_files(path.as_path(), paths); - continue; - } - - if path.extension().and_then(|extension| extension.to_str()) == Some("rs") { - paths.push(path); +fn active_sources_do_not_describe_compatibility_paths() { + for path in active_source_files() { + let source = read_source(&path).to_lowercase(); + for forbidden in [ + "compatibility", + "legacy", + "shim", + "dual-read", + "dual-write", + "fallback adapter", + ] { + assert!( + !source.contains(forbidden), + "{} must not describe `{forbidden}` behavior", + path.display() + ); } } } diff --git a/crates/sdk/tests/trade_product_publish_runtime.rs b/crates/sdk/tests/trade_product_publish_runtime.rs @@ -1,297 +0,0 @@ -#![cfg(all( - feature = "runtime", - feature = "signer-adapters", - feature = "local-signer", - feature = "radrootsd-execution" -))] - -use radroots_authority::{RadrootsActorContext, RadrootsLocalEventSigner}; -use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, -}; -use radroots_event::{ - RadrootsEventPtr, - contract::RadrootsActorRole, - ids::RadrootsListingAddress, - kinds::KIND_LISTING, - order::{ - RadrootsOrderEconomicItem, RadrootsOrderEconomicLine, RadrootsOrderEconomics, - RadrootsOrderItem, RadrootsOrderPricingBasis, RadrootsOrderRequest, - }, -}; -use radroots_nostr::prelude::{RadrootsNostrKeys, RadrootsNostrSecretKey}; -use radroots_sdk::{ - NostrRelayUrlPolicy, PublishMode, PushOutboxTargetOutcomeKind, RadrootsClient, - RadrootsSdkLocalKeySigner, RadrootsSdkSignerProvider, RadrootsSdkTimestamp, - RadrootsdExecutionProfile, SatisfactionPolicy, TargetPolicy, TargetSet, TradeMutationOutcome, - TradeProposeRequest, -}; -use std::{ - io::{Read, Write}, - net::{TcpListener, TcpStream}, - thread::JoinHandle, -}; - -const BUYER_SECRET_KEY_HEX: &str = - "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; -const BUYER_PUBLIC_KEY_HEX: &str = - "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; -const SELLER_PUBLIC_KEY_HEX: &str = - "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; -const RELAY: &str = "wss://relay.radroots.test"; - -struct RecordedTransportPublishRequest { - body: String, -} - -fn spawn_trade_transport_publish_server() -> (String, JoinHandle<RecordedTransportPublishRequest>) { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind transport publish server"); - let endpoint = format!("http://{}/rpc", listener.local_addr().expect("addr")); - let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept"); - let body = read_transport_publish_request_body(&mut stream); - write_transport_publish_accept_response(&mut stream, body.as_str()); - RecordedTransportPublishRequest { body } - }); - (endpoint, handle) -} - -fn read_transport_publish_request_body(stream: &mut TcpStream) -> String { - let mut request = Vec::new(); - let mut buffer = [0u8; 1024]; - loop { - let read = stream.read(&mut buffer).expect("read request"); - if read == 0 { - break; - } - request.extend_from_slice(&buffer[..read]); - if request.windows(4).any(|window| window == b"\r\n\r\n") { - let headers_end = request - .windows(4) - .position(|window| window == b"\r\n\r\n") - .expect("headers end") - + 4; - let header_text = String::from_utf8_lossy(&request[..headers_end]); - let content_length = header_text - .lines() - .find_map(|line| { - let (name, value) = line.split_once(':')?; - name.eq_ignore_ascii_case("content-length") - .then(|| value.trim().parse::<usize>().expect("content length")) - }) - .unwrap_or(0); - while request.len() < headers_end + content_length { - let read = stream.read(&mut buffer).expect("read body"); - if read == 0 { - break; - } - request.extend_from_slice(&buffer[..read]); - } - break; - } - } - let request_text = String::from_utf8_lossy(&request); - let (_, body) = request_text.split_once("\r\n\r\n").expect("request body"); - body.to_owned() -} - -fn write_transport_publish_accept_response(stream: &mut TcpStream, body: &str) { - let body_json: serde_json::Value = serde_json::from_str(body).expect("body json"); - let raw_event_json = body_json["params"]["raw_event_json"] - .as_str() - .expect("raw event json"); - let event: serde_json::Value = serde_json::from_str(raw_event_json).expect("event json"); - let response_body = serde_json::json!({ - "jsonrpc": "2.0", - "id": body_json["id"], - "result": { - "deduplicated": false, - "job": { - "job_id": "trade-product-publish-job", - "status": "delivery_satisfied", - "terminal": true, - "delivery_satisfied": true, - "event_id": event["id"], - "pubkey": event["pubkey"], - "event_kind": event["kind"], - "target_policy": body_json["params"]["target_policy"], - "delivery_policy": body_json["params"]["delivery_policy"], - "target_count": 1, - "acknowledged_count": 1, - "retryable_count": 0, - "terminal_count": 0, - "requested_at_ms": 1700000000000i64, - "completed_at_ms": 1700000000100i64, - "targets": [{ - "transport_kind": "nostr", - "endpoint_uri": RELAY, - "source": "request", - "attempted": true, - "outcome_kind": "accepted", - "message": "accepted" - }] - } - } - }) - .to_string(); - let response = format!( - "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", - response_body.len(), - response_body - ); - stream - .write_all(response.as_bytes()) - .expect("write response"); -} - -fn buyer_actor() -> RadrootsActorContext { - RadrootsActorContext::test(BUYER_PUBLIC_KEY_HEX, [RadrootsActorRole::Buyer]).expect("actor") -} - -fn listing_address() -> RadrootsListingAddress { - RadrootsListingAddress::parse(format!( - "{KIND_LISTING}:{SELLER_PUBLIC_KEY_HEX}:AAAAAAAAAAAAAAAAAAAAAg" - )) - .expect("listing address") -} - -fn listing_event_ptr() -> RadrootsEventPtr { - RadrootsEventPtr { - id: "6ccf12d1e56c21065d239bc3d46c0000cd000095d20000d9000073cd00009600".to_owned(), - relays: Some(RELAY.to_owned()), - } -} - -fn decimal(raw: &str) -> RadrootsCoreDecimal { - raw.parse().expect("decimal") -} - -fn usd(raw: &str) -> RadrootsCoreMoney { - RadrootsCoreMoney::new(decimal(raw), RadrootsCoreCurrency::USD) -} - -fn economics() -> RadrootsOrderEconomics { - RadrootsOrderEconomics { - quote_id: "quote-1".parse().expect("quote id"), - quote_version: 1, - pricing_basis: RadrootsOrderPricingBasis::ListingEvent, - currency: RadrootsCoreCurrency::USD, - items: vec![RadrootsOrderEconomicItem { - bin_id: "bin-1".parse().expect("bin id"), - bin_count: 2, - quantity_amount: decimal("1"), - quantity_unit: RadrootsCoreUnit::Each, - unit_price_amount: decimal("5"), - unit_price_currency: RadrootsCoreCurrency::USD, - line_subtotal: usd("10"), - }], - discounts: Vec::<RadrootsOrderEconomicLine>::new(), - adjustments: Vec::<RadrootsOrderEconomicLine>::new(), - subtotal: usd("10"), - discount_total: usd("0"), - adjustment_total: usd("0"), - total: usd("10"), - } -} - -fn order_request(raw_order_id: &str) -> RadrootsOrderRequest { - RadrootsOrderRequest { - order_id: raw_order_id.parse().expect("order id"), - listing_addr: listing_address(), - buyer_pubkey: BUYER_PUBLIC_KEY_HEX.parse().expect("buyer pubkey"), - seller_pubkey: SELLER_PUBLIC_KEY_HEX.parse().expect("seller pubkey"), - items: vec![RadrootsOrderItem { - bin_id: "bin-1".parse().expect("bin id"), - bin_count: 2, - }], - economics: economics(), - } -} - -fn trade_propose_request( - raw_order_id: &str, - publish_mode: PublishMode, - satisfaction_policy: SatisfactionPolicy, -) -> TradeProposeRequest { - let order = order_request(raw_order_id); - TradeProposeRequest::new( - buyer_actor(), - listing_event_ptr(), - order, - explicit_trade_relays(), - publish_mode, - satisfaction_policy, - ) -} - -fn explicit_trade_relays() -> TargetPolicy { - TargetPolicy::explicit( - TargetSet::nostr_relays([RELAY], NostrRelayUrlPolicy::Public).expect("target relays"), - ) -} - -#[tokio::test] -async fn trade_product_propose_enqueue_and_publish_uses_ack_policy() { - let (endpoint, handle) = spawn_trade_transport_publish_server(); - let tempdir = tempfile::tempdir().expect("tempdir"); - let secret_key = RadrootsNostrSecretKey::from_hex(BUYER_SECRET_KEY_HEX).expect("secret key"); - let signer_keys = RadrootsNostrKeys::new(secret_key); - let sdk = RadrootsClient::builder() - .directory_storage(tempdir.path().join("sdk")) - .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)) - .signer_provider(RadrootsSdkSignerProvider::LocalKey( - RadrootsSdkLocalKeySigner::from_event_signer( - RadrootsLocalEventSigner::new(signer_keys).expect("local event signer"), - ) - .expect("local signer"), - )) - .radrootsd_execution_profile(RadrootsdExecutionProfile::new(endpoint)) - .build() - .await - .expect("sdk"); - - let outcome = sdk - .trades() - .buyer() - .propose_trade( - trade_propose_request( - "01890f0e-6c00-7000-8000-000000000223", - PublishMode::EnqueueAndPublish, - SatisfactionPolicy::AnyAccepted, - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000223") - .expect("idempotency"), - ) - .await - .expect("publish proposal"); - let (receipt, publish) = match outcome { - TradeMutationOutcome::Published { receipt, publish } => (receipt, publish), - TradeMutationOutcome::DryRun { .. } => panic!("expected published outcome"), - TradeMutationOutcome::Enqueued { .. } => panic!("expected published outcome"), - }; - - assert_eq!( - receipt.order_id.as_str(), - "01890f0e-6c00-7000-8000-000000000223" - ); - assert_eq!(publish.attempted_events, 1); - assert_eq!(publish.published_events, 1); - assert_eq!(publish.events.len(), 1); - assert_eq!(publish.events[0].outbox_event_id, receipt.outbox_event_id); - assert_eq!(publish.events[0].quorum, 1); - assert!(publish.events[0].quorum_met); - assert_eq!( - publish.events[0].targets[0].outcome_kind, - PushOutboxTargetOutcomeKind::Accepted - ); - - let recorded = handle.join().expect("transport publish request"); - let body: serde_json::Value = serde_json::from_str(recorded.body.as_str()).expect("body"); - assert_eq!(body["method"], "transport.publish.event"); - assert_eq!(body["params"]["delivery_policy"]["mode"], "any"); - assert_eq!(body["params"]["target_policy"]["kind"], "explicit_targets"); - assert_eq!( - body["params"]["target_policy"]["targets"][0]["endpoint_uri"], - RELAY - ); -} diff --git a/crates/sdk/tests/trade_public_api.rs b/crates/sdk/tests/trade_public_api.rs @@ -1,64 +0,0 @@ -#![cfg(feature = "runtime")] - -use radroots_authority::RadrootsActorContext; -use radroots_event::contract::RadrootsActorRole; -use radroots_sdk::{ - RadrootsClient, TradeResyncRequest, TradeSellerInboxRequest, TradeStatusKind, - TradeStatusRequest, TradeValidationReceiptListRequest, -}; - -const SELLER_PUBLIC_KEY_HEX: &str = - "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; - -#[tokio::test] -async fn grouped_trade_surface_is_the_public_product_entrypoint() { - let sdk = RadrootsClient::builder().build().await.expect("sdk"); - let trades = sdk.trades(); - - let _seller = trades.seller(); - let _resync = trades.resync(); - let validation_receipts = trades.validation_receipts(); - - let status = trades - .status(TradeStatusRequest::parse("trade-public-api-order").expect("status request")) - .await - .expect("status"); - - assert_eq!(status.status, TradeStatusKind::Missing); - - let resync_error = trades - .resync() - .resync(TradeResyncRequest::new(status.locator)) - .await - .expect_err("resync requires configured relays"); - - #[cfg(feature = "transport-nostr-runtime")] - assert_eq!(resync_error.code(), "empty_transport_targets"); - #[cfg(not(feature = "transport-nostr-runtime"))] - assert_eq!(resync_error.code(), "product_sync_unsupported"); - - let validation_receipt_error = validation_receipts - .list( - TradeValidationReceiptListRequest::parse("trade-public-api-order") - .expect("validation receipt list request"), - ) - .await - .expect_err("validation receipt list requires configured relays"); - - #[cfg(feature = "transport-nostr-runtime")] - assert_eq!(validation_receipt_error.code(), "empty_transport_targets"); - #[cfg(not(feature = "transport-nostr-runtime"))] - assert_eq!(validation_receipt_error.code(), "product_sync_unsupported"); - - let seller_actor = - RadrootsActorContext::test(SELLER_PUBLIC_KEY_HEX, [RadrootsActorRole::Seller]) - .expect("seller actor"); - let inbox = trades - .seller() - .inbox(TradeSellerInboxRequest::new(seller_actor)) - .await - .expect("seller inbox"); - - assert_eq!(inbox.seller_pubkey.as_str(), SELLER_PUBLIC_KEY_HEX); - assert!(inbox.statuses.is_empty()); -} diff --git a/crates/sdk/tests/unit/error_tests.rs b/crates/sdk/tests/unit/error_tests.rs @@ -295,8 +295,15 @@ fn sdk_error_contract_methods_cover_representative_classes_and_details() { existing_digest_prefix: "existing".to_owned(), new_digest_prefix: "new".to_owned(), }, - RadrootsSdkError::trade_status_limit_invalid(0, 1, 100), - RadrootsSdkError::invalid_trade_id("bad order", "bad id"), + RadrootsSdkError::TradeStatusLimitInvalid { + limit: 0, + min: 1, + max: 100, + }, + RadrootsSdkError::InvalidTradeId { + value: "bad order".to_owned(), + message: "bad id".to_owned(), + }, RadrootsSdkError::PrivacyPreflight { operation: "trade.cancel".to_owned(), status: PrivacyPreflightStatus::ExplicitConfirmationRequired, diff --git a/crates/sdk/tests/unit/farms_runtime_tests.rs b/crates/sdk/tests/unit/farms_runtime_tests.rs @@ -670,7 +670,10 @@ async fn farm_configured_enqueue_reports_prepare_and_signer_errors() { plan, TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) .expect("transport targets"), - None, + Some( + SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000237") + .expect("idempotency"), + ), ) .await, Err(RadrootsSdkError::SignerUnavailable { .. }) diff --git a/crates/sdk/tests/unit/listings_runtime_tests.rs b/crates/sdk/tests/unit/listings_runtime_tests.rs @@ -369,7 +369,10 @@ async fn listing_configured_enqueue_reports_missing_signer_after_prepare() { plan, TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) .expect("transport targets"), - None, + Some( + SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000233") + .expect("idempotency"), + ), ) .await, Err(RadrootsSdkError::SignerUnavailable { .. }) diff --git a/crates/sdk/tests/unit/market_runtime_tests.rs b/crates/sdk/tests/unit/market_runtime_tests.rs @@ -1,112 +0,0 @@ -use super::*; - -const SELLER_PUBLIC_KEY_HEX: &str = - "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; - -fn projection_row() -> RadrootsListingProjectionRow { - RadrootsListingProjectionRow { - listing_addr: RadrootsListingAddress::parse(format!( - "30402:{SELLER_PUBLIC_KEY_HEX}:AAAAAAAAAAAAAAAAAAAAAg" - )) - .expect("listing address"), - listing_event_id: "a".repeat(64), - seller_pubkey: SELLER_PUBLIC_KEY_HEX.to_owned(), - title: "Blueberries".to_owned(), - description: "Fresh field berries".to_owned(), - product_type: "fruit".to_owned(), - price_amount: "6".to_owned(), - price_currency: "USD".to_owned(), - inventory_available: "12".to_owned(), - delivery_method: "pickup".to_owned(), - locality_primary: "Fernwood".to_owned(), - locality_city: Some("Victoria".to_owned()), - locality_region: Some("BC".to_owned()), - locality_country: Some("CA".to_owned()), - geohash5: "c2b2q".to_owned(), - updated_at_ms: 1_700_000_000_000, - } -} - -fn projection_error_message(error: RadrootsSdkError) -> String { - match error { - RadrootsSdkError::Projection { message } => message, - other => panic!("expected projection error, got {other:?}"), - } -} - -#[test] -fn market_search_request_builders_preserve_refresh_contract() { - let request = MarketSearchRequest::new("berries") - .with_limit(7) - .with_projection_refresh(SyncProjectionRefreshRequest::new().with_limit(3)); - - assert_eq!(request.query, "berries"); - assert_eq!(request.limit, 7); - assert_eq!(request.projection_refresh.limit, 3); -} - -#[test] -fn listing_projection_row_conversion_validates_stored_identity_columns() { - let row = projection_row(); - let search_row = - MarketListingSearchRow::try_from_projection_row(row.clone()).expect("search row"); - assert_eq!(search_row.listing_addr, row.listing_addr); - assert_eq!(search_row.listing_event_id.as_str(), row.listing_event_id); - assert_eq!(search_row.seller_pubkey.as_str(), row.seller_pubkey); - assert_eq!(search_row.title, "Blueberries"); - - let mut invalid_event_id = row.clone(); - invalid_event_id.listing_event_id = "not-an-event-id".to_owned(); - assert!( - projection_error_message( - MarketListingSearchRow::try_from_projection_row(invalid_event_id).unwrap_err() - ) - .contains("projection event id") - ); - - let mut invalid_seller = row; - invalid_seller.seller_pubkey = "not-a-pubkey".to_owned(); - assert!( - projection_error_message( - MarketListingSearchRow::try_from_projection_row(invalid_seller).unwrap_err() - ) - .contains("projection seller pubkey") - ); -} - -#[test] -fn market_search_receipt_serializes_absent_optional_locality_fields() { - let mut row = projection_row(); - row.locality_city = None; - row.locality_region = None; - row.locality_country = None; - let search_row = MarketListingSearchRow::try_from_projection_row(row).expect("search row"); - let receipt = MarketSearchReceipt { - source: MarketSearchSource::LocalProjectionFts, - refresh: SyncProjectionRefreshReceipt::default(), - listings: vec![search_row], - }; - - let value = serde_json::to_value(receipt).expect("receipt json"); - - assert_eq!(value["source"], "local_projection_fts"); - assert!(value["listings"][0]["locality_city"].is_null()); - assert!(value["listings"][0]["locality_region"].is_null()); - assert!(value["listings"][0]["locality_country"].is_null()); -} - -#[tokio::test] -async fn market_search_reports_projection_refresh_errors_before_querying_rows() { - let sdk = crate::RadrootsClient::builder() - .clock(crate::RadrootsSdkClock::BeforeUnixEpoch) - .build() - .await - .expect("sdk"); - - assert!(matches!( - sdk.market() - .search(MarketSearchRequest::new("berries")) - .await, - Err(RadrootsSdkError::ClockBeforeUnixEpoch) - )); -} diff --git a/crates/sdk/tests/unit/orders_runtime_tests.rs b/crates/sdk/tests/unit/orders_runtime_tests.rs @@ -1,199 +0,0 @@ -use super::*; - -#[test] -#[cfg(feature = "transport-nostr-runtime")] -fn validation_receipt_limit_rejects_out_of_range_values() { - assert!(validate_validation_receipt_limit(1).is_ok()); - assert!(validate_validation_receipt_limit(TRADE_STATUS_MAX_LIMIT).is_ok()); - - let zero = validate_validation_receipt_limit(0).expect_err("zero limit"); - assert_eq!( - zero.to_string(), - format!("sdk order status limit invalid: limit=0, min=1, max={TRADE_STATUS_MAX_LIMIT}") - ); - - let over_max = - validate_validation_receipt_limit(TRADE_STATUS_MAX_LIMIT + 1).expect_err("over max"); - assert_eq!( - over_max.to_string(), - format!( - "sdk order status limit invalid: limit={}, min=1, max={TRADE_STATUS_MAX_LIMIT}", - TRADE_STATUS_MAX_LIMIT + 1 - ) - ); -} - -#[test] -fn relay_outcome_labels_cover_current_variants() { - for (kind, label) in [ - (TradeResyncNostrRelayOutcomeKind::Eose, "eose"), - (TradeResyncNostrRelayOutcomeKind::Closed, "closed"), - (TradeResyncNostrRelayOutcomeKind::Notice, "notice"), - ] { - assert_eq!(kind.as_str(), label); - } - - for (kind, label) in [ - (TradeValidationReceiptNostrRelayOutcomeKind::Eose, "eose"), - ( - TradeValidationReceiptNostrRelayOutcomeKind::Closed, - "closed", - ), - ( - TradeValidationReceiptNostrRelayOutcomeKind::Notice, - "notice", - ), - ] { - assert_eq!(kind.as_str(), label); - } - - for (kind, label) in [ - ( - TradeResyncNostrRelayTransportOutcomeKind::Accepted, - "accepted", - ), - ( - TradeResyncNostrRelayTransportOutcomeKind::DuplicateAccepted, - "duplicate_accepted", - ), - ( - TradeResyncNostrRelayTransportOutcomeKind::Blocked, - "blocked", - ), - ( - TradeResyncNostrRelayTransportOutcomeKind::RateLimited, - "rate_limited", - ), - ( - TradeResyncNostrRelayTransportOutcomeKind::Invalid, - "invalid", - ), - ( - TradeResyncNostrRelayTransportOutcomeKind::PowRequired, - "pow_required", - ), - ( - TradeResyncNostrRelayTransportOutcomeKind::Restricted, - "restricted", - ), - ( - TradeResyncNostrRelayTransportOutcomeKind::AuthRequired, - "auth_required", - ), - (TradeResyncNostrRelayTransportOutcomeKind::Muted, "muted"), - ( - TradeResyncNostrRelayTransportOutcomeKind::Unsupported, - "unsupported", - ), - ( - TradeResyncNostrRelayTransportOutcomeKind::PaymentRequired, - "payment_required", - ), - (TradeResyncNostrRelayTransportOutcomeKind::Error, "error"), - ( - TradeResyncNostrRelayTransportOutcomeKind::Timeout, - "timeout", - ), - ( - TradeResyncNostrRelayTransportOutcomeKind::ConnectionFailed, - "connection_failed", - ), - ( - TradeResyncNostrRelayTransportOutcomeKind::RelayUrlRejected, - "relay_url_rejected", - ), - ( - TradeResyncNostrRelayTransportOutcomeKind::SkippedAlreadyAccepted, - "skipped_already_accepted", - ), - ( - TradeResyncNostrRelayTransportOutcomeKind::Unknown, - "unknown", - ), - ] { - assert_eq!(kind.as_str(), label); - } - - for (kind, label) in [ - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::Accepted, - "accepted", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::DuplicateAccepted, - "duplicate_accepted", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::Blocked, - "blocked", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::RateLimited, - "rate_limited", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::Invalid, - "invalid", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::PowRequired, - "pow_required", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::Restricted, - "restricted", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::AuthRequired, - "auth_required", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::Muted, - "muted", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::Unsupported, - "unsupported", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::PaymentRequired, - "payment_required", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::Error, - "error", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::Timeout, - "timeout", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::ConnectionFailed, - "connection_failed", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::RelayUrlRejected, - "relay_url_rejected", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::SkippedAlreadyAccepted, - "skipped_already_accepted", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::Unknown, - "unknown", - ), - ] { - assert_eq!(kind.as_str(), label); - } -} - -#[test] -fn camel_to_snake_converts_debug_case_labels() { - assert_eq!(camel_to_snake("AwaitValidation"), "await_validation"); - assert_eq!( - camel_to_snake("InspectEvidenceIssues"), - "inspect_evidence_issues" - ); - assert_eq!(camel_to_snake("Terminal"), "terminal"); -} diff --git a/crates/sdk/tests/unit/runtime_tests.rs b/crates/sdk/tests/unit/runtime_tests.rs @@ -299,7 +299,7 @@ fn transport_profile_defaults_are_explicit() { #[tokio::test] async fn open_storage_and_storage_kind_cover_memory_directory_and_file_failures() { - let memory = open_storage(&RadrootsSdkStorageConfig::Memory) + let memory = open_storage(&RadrootsSdkStorageConfig::Memory, 0) .await .expect("memory storage"); assert!(memory.paths.is_none()); @@ -320,7 +320,7 @@ async fn open_storage_and_storage_kind_cover_memory_directory_and_file_failures( let tempdir = tempfile::tempdir().expect("tempdir"); let directory = tempdir.path().join("sdk"); - let directory_storage = open_storage(&RadrootsSdkStorageConfig::Directory(directory)) + let directory_storage = open_storage(&RadrootsSdkStorageConfig::Directory(directory), 0) .await .expect("directory storage"); let directory_paths = directory_storage.paths.expect("directory paths"); @@ -344,24 +344,148 @@ async fn open_storage_and_storage_kind_cover_memory_directory_and_file_failures( let file_path = tempdir.path().join("not-directory"); fs::write(&file_path, b"file").expect("file"); - assert!(!io_message(open_directory_storage(&file_path).await).is_empty()); + assert!(!io_message(open_directory_storage(&file_path, 0).await).is_empty()); let event_store_directory = tempdir.path().join("event-store-directory"); fs::create_dir(&event_store_directory).expect("event store dir"); fs::create_dir(event_store_directory.join(RUNTIME_SQLITE_FILE)) .expect("event store file slot dir"); - assert_event_store_error(open_directory_storage(&event_store_directory).await); + assert_event_store_error(open_directory_storage(&event_store_directory, 0).await); let studio_directory = tempdir.path().join("studio-directory"); fs::create_dir(&studio_directory).expect("studio dir"); fs::create_dir(studio_directory.join(STUDIO_SQLITE_FILE)).expect("studio file slot dir"); - assert_studio_store_error(open_directory_storage(&studio_directory).await); + assert_studio_store_error(open_directory_storage(&studio_directory, 0).await); let private_store_directory = tempdir.path().join("private-store-directory"); fs::create_dir(&private_store_directory).expect("private store dir"); fs::create_dir(private_store_directory.join(PRIVATE_SQLITE_FILE)) .expect("private store file slot dir"); - assert_private_store_error(open_directory_storage(&private_store_directory).await); + assert_private_store_error(open_directory_storage(&private_store_directory, 0).await); +} + +#[tokio::test] +async fn runtime_schema_refuses_newer_profiles_before_migration() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let profile = tempdir.path().join("sdk"); + fs::create_dir(&profile).expect("profile"); + let runtime_path = profile.join(RUNTIME_SQLITE_FILE); + let pool = open_runtime_file_pool(&runtime_path) + .await + .expect("runtime pool"); + sqlx::query("PRAGMA user_version = 99") + .execute(&pool) + .await + .expect("user version"); + pool.close().await; + + let unsupported = assert_unsupported_profile_error(open_directory_storage(&profile, 10).await); + + assert_eq!(unsupported, runtime_path); +} + +#[tokio::test] +async fn runtime_startup_recovery_updates_journal_reservations_projections_and_outbox() { + let storage = open_storage(&RadrootsSdkStorageConfig::Memory, 0) + .await + .expect("memory storage"); + let pool = storage.event_store.pool(); + sqlx::query( + "INSERT INTO sdk_runtime_operation_journal(contract_version, operation_kind, actor_pubkey, idempotency_key, command_payload_hash, frozen_draft_json, expected_transport_id, state, created_at_ms, updated_at_ms) VALUES ('1', 'trade.proposal.v1', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', '019b0000-0000-7000-8000-000000000001', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', '{}', 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', 'signature_pending', 1, 1)", + ) + .execute(pool) + .await + .expect("operation journal"); + sqlx::query( + "INSERT INTO sdk_seller_inventory_reservation(reservation_id, farm_id, candidate_id, authority_id, inventory_epoch, assertion_commitment, state, lease_until_ms, created_at_ms, updated_at_ms) VALUES ('reservation-1', 'farm-1', 'candidate-1', 'seller-1', 1, 'commitment-1', 'prepared', 5, 1, 1)", + ) + .execute(pool) + .await + .expect("reservation"); + sqlx::query( + "INSERT INTO sdk_seller_inventory_reservation_line(reservation_id, farm_id, candidate_id, line_id, bin_id, quantity_mantissa, quantity_scale, unit_code) VALUES ('reservation-1', 'farm-1', 'candidate-1', 'line-1', 'bin-1', '1', 0, 'lb')", + ) + .execute(pool) + .await + .expect("reservation line"); + sqlx::query( + "INSERT INTO sdk_seller_inventory_reservation(reservation_id, farm_id, candidate_id, authority_id, inventory_epoch, assertion_commitment, state, lease_until_ms, created_at_ms, updated_at_ms) VALUES ('reservation-2', 'farm-1', 'candidate-1', 'seller-1', 1, 'commitment-2', 'prepared', 50, 1, 1)", + ) + .execute(pool) + .await + .expect("second reservation"); + assert!( + sqlx::query( + "INSERT INTO sdk_seller_inventory_reservation_line(reservation_id, farm_id, candidate_id, line_id, bin_id, quantity_mantissa, quantity_scale, unit_code) VALUES ('reservation-2', 'farm-1', 'candidate-1', 'line-1', 'bin-1', '1', 0, 'lb')", + ) + .execute(pool) + .await + .is_err() + ); + sqlx::query( + "INSERT INTO sdk_runtime_trade_projection_checkpoint(projection_name, reducer_contract_id, reducer_version, last_ingest_seq, source_digest, projection_digest, completeness_state, updated_at_ms) VALUES ('trade_projection', 'radroots.trade.reducer.v1', 1, 7, 'source', 'projection', 'rebuilding', 1)", + ) + .execute(pool) + .await + .expect("projection checkpoint"); + let operation_id = sqlx::query( + "INSERT INTO outbox_operations(operation_kind, expected_pubkey, semantic_scope, idempotency_key, operation_idempotency_digest, status, created_at_ms, updated_at_ms) VALUES ('listing.publish.v1', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'generic_event', '019b0000-0000-7000-8000-000000000002', 'digest', 'queued', 1, 1)", + ) + .execute(pool) + .await + .expect("outbox operation") + .last_insert_rowid(); + sqlx::query( + "INSERT INTO outbox_event(operation_id, event_id, expected_pubkey, draft_json, state, attempt_count, claim_token, claim_owner, claim_expires_at_ms, next_attempt_after_ms, event_store_ingested, event_store_inserted, created_at_ms, updated_at_ms) VALUES (?, 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', '{}', 'publishing', 1, 'claim-1', 'worker-1', 5, 1, 0, 0, 1, 1)", + ) + .bind(operation_id) + .execute(pool) + .await + .expect("outbox event"); + + recover_sdk_runtime_state(pool, 10).await.expect("recover"); + + let operation_state: String = sqlx::query_scalar( + "SELECT state FROM sdk_runtime_operation_journal WHERE operation_kind = 'trade.proposal.v1'", + ) + .fetch_one(pool) + .await + .expect("operation state"); + let reservation_state: String = sqlx::query_scalar( + "SELECT state FROM sdk_seller_inventory_reservation WHERE reservation_id = 'reservation-1'", + ) + .fetch_one(pool) + .await + .expect("reservation state"); + let projection_state: String = sqlx::query_scalar( + "SELECT completeness_state FROM sdk_runtime_trade_projection_checkpoint WHERE projection_name = 'trade_projection'", + ) + .fetch_one(pool) + .await + .expect("projection state"); + let outbox_state: String = + sqlx::query_scalar("SELECT state FROM outbox_event WHERE outbox_event_id = 1") + .fetch_one(pool) + .await + .expect("outbox state"); + let outbox_claim: Option<String> = + sqlx::query_scalar("SELECT claim_token FROM outbox_event WHERE outbox_event_id = 1") + .fetch_one(pool) + .await + .expect("outbox claim"); + let receipts: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM sdk_runtime_recovery_receipt WHERE recovery_code IN ('signer_timeout','reservation_expiry','projection_stale','relay_failure')", + ) + .fetch_one(pool) + .await + .expect("recovery receipts"); + + assert_eq!(operation_state, "failed_recoverable"); + assert_eq!(reservation_state, "expired"); + assert_eq!(projection_state, "stale"); + assert_eq!(outbox_state, "publish_retryable"); + assert!(outbox_claim.is_none()); + assert_eq!(receipts, 4); } #[tokio::test] @@ -603,53 +727,6 @@ async fn storage_status_inspection_is_read_only_and_never_creates_missing_profil } #[tokio::test] -async fn quarantine_reset_moves_pre_v1_artifacts_before_materializing_v1_stores() { - let tempdir = tempfile::tempdir().expect("tempdir"); - let profile = tempdir.path().join("profile"); - let quarantine = tempdir.path().join("quarantine"); - fs::create_dir(&profile).expect("profile"); - fs::write(profile.join("event_store.sqlite"), b"old event store").expect("old event store"); - fs::write(profile.join("event_store.sqlite-wal"), b"old event wal").expect("old event wal"); - fs::write(profile.join("outbox.sqlite"), b"old outbox").expect("old outbox"); - - let unsupported = assert_unsupported_profile_error( - RadrootsClient::builder() - .directory_storage(&profile) - .build() - .await, - ); - assert_eq!(unsupported, profile.join("event_store.sqlite")); - - let receipt = RadrootsClient::quarantine_reset_storage(QuarantineResetRequest::new( - &profile, - &quarantine, - )) - .await - .expect("quarantine reset"); - - assert_eq!(receipt.profile, profile); - assert_eq!(receipt.quarantine, quarantine); - assert_eq!(receipt.quarantined_paths.len(), 3); - assert!(receipt.quarantine.join("event_store.sqlite").exists()); - assert!(receipt.quarantine.join("event_store.sqlite-wal").exists()); - assert!(receipt.quarantine.join("outbox.sqlite").exists()); - assert!(!receipt.profile.join("event_store.sqlite").exists()); - assert!(!receipt.profile.join("outbox.sqlite").exists()); - assert!(receipt.reset_paths.runtime_path.exists()); - assert!(receipt.reset_paths.private_path.exists()); - assert!(receipt.reset_paths.studio_path.exists()); - - let status = - RadrootsClient::inspect_storage_status(&receipt.profile, StorageStatusRequest::new()) - .await - .expect("read-only status after reset"); - assert_eq!(status.event_store.total_events, 0); - assert_eq!(status.outbox.total_events, 0); - assert_eq!(status.private_store.farm_private_locations, 0); - assert_eq!(status.studio_store.studio_state_records, 0); -} - -#[tokio::test] async fn storage_status_integrity_and_backup_map_closed_pool_errors() { let event_store_closed = RadrootsClient::builder().build().await.expect("sdk"); event_store_closed._event_store.pool().close().await; @@ -1364,7 +1441,7 @@ fn restore_staging_helpers_cover_new_and_existing_destination_installs() { #[cfg(unix)] #[tokio::test] async fn sqlite_backup_errors_cover_invalid_paths_and_execute_failures() { - let storage = open_storage(&RadrootsSdkStorageConfig::Memory) + let storage = open_storage(&RadrootsSdkStorageConfig::Memory, 0) .await .expect("memory storage"); @@ -1409,7 +1486,7 @@ async fn sqlite_backup_errors_cover_invalid_paths_and_execute_failures() { .await, ); - let studio_closed_storage = open_storage(&RadrootsSdkStorageConfig::Memory) + let studio_closed_storage = open_storage(&RadrootsSdkStorageConfig::Memory, 0) .await .expect("studio closed storage"); studio_closed_storage.studio_store.pool().close().await; diff --git a/crates/sdk/tests/unit/sync_runtime_tests.rs b/crates/sdk/tests/unit/sync_runtime_tests.rs @@ -1413,14 +1413,14 @@ async fn radrootsd_completion_updates_outbox_for_success_retryable_and_terminal_ ( "radrootsd-complete-deferred", PushOutboxEventState::DeferredUntilImplemented, - PushOutboxEventState::DeferredUntilImplemented, + PushOutboxEventState::FailedTerminal, RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented, TransportPublishOutcomeKind::DeferredUntilImplemented, ), ( "radrootsd-complete-deferred-until-implemented", PushOutboxEventState::DeferredUntilImplemented, - PushOutboxEventState::DeferredUntilImplemented, + PushOutboxEventState::FailedTerminal, RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented, TransportPublishOutcomeKind::DeferredUntilImplemented, ), diff --git a/crates/sdk/tests/unit/workflow_runtime_tests.rs b/crates/sdk/tests/unit/workflow_runtime_tests.rs @@ -157,10 +157,25 @@ fn workflow_digest_and_event_helpers_cover_error_and_input_paths() { "wss://relay.example.com" ); assert!(input.event_store_inserted); + let frozen = frozen_draft_json(&draft).expect("frozen draft json"); + assert!(frozen.contains("\"expected_event_id\"")); + let receipt = SdkWorkflowEnqueueReceipt { + signed_event_id: RadrootsEventId::parse(draft.expected_event_id_str()).expect("event id"), + local_event_seq: 1, + outbox_operation_id: 2, + outbox_event_id: 3, + state: radroots_outbox::RadrootsOutboxEnqueueStatus::Inserted, + idempotency_digest_prefix: "abcdef123456".to_owned(), + }; + let receipt_json = workflow_receipt_result_json(&receipt); + let decoded = + workflow_receipt_from_result_json(receipt_json.to_string().as_str()).expect("receipt"); + assert_eq!(decoded.outbox_event_id, receipt.outbox_event_id); + assert_eq!(outbox_enqueue_status_str(decoded.state), "inserted"); } #[tokio::test] -async fn default_operation_idempotency_ignores_target_policy() { +async fn workflow_idempotency_replays_original_receipt_and_conflicts_on_new_command_hash() { let sdk = crate::RadrootsClient::builder() .fixed_clock(crate::RadrootsSdkTimestamp::from_unix_seconds( 1_700_000_012, @@ -189,7 +204,7 @@ async fn default_operation_idempotency_ignores_target_policy() { operation_kind: "workflow.test.v1", actor: &actor, frozen_draft: &draft, - target_policy: first_target_policy, + target_policy: first_target_policy.clone(), satisfaction_policy: SatisfactionPolicy::AllAccepted, idempotency_key: Some(workflow_idempotency_key(0x240)), }, @@ -197,7 +212,21 @@ async fn default_operation_idempotency_ignores_target_policy() { ) .await .expect("first enqueue"); - let second = enqueue_signed_workflow( + let replay = enqueue_signed_workflow( + &sdk, + SdkWorkflowEnqueueRequest { + operation_kind: "workflow.test.v1", + actor: &actor, + frozen_draft: &draft, + target_policy: first_target_policy.clone(), + satisfaction_policy: SatisfactionPolicy::AllAccepted, + idempotency_key: Some(workflow_idempotency_key(0x240)), + }, + &signer, + ) + .await + .expect("replay enqueue"); + let conflict = enqueue_signed_workflow( &sdk, SdkWorkflowEnqueueRequest { operation_kind: "workflow.test.v1", @@ -210,32 +239,32 @@ async fn default_operation_idempotency_ignores_target_policy() { &signer, ) .await - .expect("second enqueue"); + .expect_err("conflicting command hash"); assert_eq!( first.state, radroots_outbox::RadrootsOutboxEnqueueStatus::Inserted ); assert_eq!( - second.state, + replay.state, radroots_outbox::RadrootsOutboxEnqueueStatus::Inserted ); - assert_eq!(first.outbox_operation_id, second.outbox_operation_id); - assert_eq!(first.outbox_event_id, second.outbox_event_id); + assert_eq!(first.outbox_operation_id, replay.outbox_operation_id); + assert_eq!(first.outbox_event_id, replay.outbox_event_id); assert_eq!( first.idempotency_digest_prefix, - second.idempotency_digest_prefix + replay.idempotency_digest_prefix ); + assert!(matches!( + conflict, + RadrootsSdkError::IdempotencyConflict { .. } + )); let plans = sdk ._outbox .delivery_plans(first.outbox_event_id) .await .expect("delivery plans"); - assert_eq!(plans.len(), 2); - assert_ne!( - plans[0].delivery_plan_idempotency_digest, - plans[1].delivery_plan_idempotency_digest - ); + assert_eq!(plans.len(), 1); let targets = sdk ._outbox .delivery_targets(first.outbox_event_id) @@ -247,11 +276,17 @@ async fn default_operation_idempotency_ignores_target_policy() { .collect::<std::collections::BTreeSet<_>>(); assert_eq!( target_uris, - std::collections::BTreeSet::from([ - "wss://relay-a.example.com", - "wss://relay-b.example.com", - ]) + std::collections::BTreeSet::from(["wss://relay-a.example.com"]) ); + let recovery_count: i64 = sqlx::query( + "SELECT COUNT(*) FROM sdk_runtime_recovery_receipt WHERE recovery_code = 'idempotency_conflict'", + ) + .fetch_one(sdk._event_store.pool()) + .await + .expect("recovery count") + .try_get(0) + .expect("recovery count value"); + assert_eq!(recovery_count, 1); } #[tokio::test] diff --git a/crates/trade_bindings/src/dto.rs b/crates/trade_bindings/src/dto.rs @@ -3,10 +3,7 @@ use dto_bindgen_core::{ RustTypeId, SourceSpan, TargetFieldNames, TargetOverride, TypeDef, TypeRef, VariantDef, VariantShape, WireFieldNames, }; -use radroots_trade::{ - listing::model::RadrootsTradeListingTotal, order::RadrootsOrderWorkflowProjection, - workflow::RadrootsTradeWorkflowState, -}; +use radroots_trade::listing::model::RadrootsTradeListingTotal; pub fn dto_roots() -> Vec<RootDescriptor> { let mut roots = radroots_trade::dto::dto_roots() @@ -25,17 +22,9 @@ pub fn dto_roots() -> Vec<RootDescriptor> { RootDescriptor::new::<RadrootsTradeListingSort>(), RootDescriptor::new::<RadrootsTradeListingSortField>(), RootDescriptor::new::<RadrootsTradeMarketplaceListingSummary>(), - RootDescriptor::new::<RadrootsTradeMarketplaceOrderSummary>(), RootDescriptor::new::<RadrootsTradeModerationFlag>(), RootDescriptor::new::<RadrootsTradeModerationSeverity>(), RootDescriptor::new::<RadrootsTradeModerationStatus>(), - RootDescriptor::new::<RadrootsTradeOrderBackofficeOverlay>(), - RootDescriptor::new::<RadrootsTradeOrderBackofficeQuery>(), - RootDescriptor::new::<RadrootsTradeOrderBackofficeView>(), - RootDescriptor::new::<RadrootsTradeOrderFacets>(), - RootDescriptor::new::<RadrootsTradeOrderQuery>(), - RootDescriptor::new::<RadrootsTradeOrderSort>(), - RootDescriptor::new::<RadrootsTradeOrderSortField>(), RootDescriptor::new::<RadrootsTradeReviewPriority>(), RootDescriptor::new::<RadrootsTradeReviewQueueEntry>(), RootDescriptor::new::<RadrootsTradeReviewStatus>(), @@ -81,7 +70,6 @@ imported_ts_type!( imported_ts_type!(RadrootsListingProductImport, "RadrootsListingProduct"); imported_ts_type!(RadrootsPlotRefImport, "RadrootsPlotRef"); imported_ts_type!(RadrootsResourceAreaRefImport, "RadrootsResourceAreaRef"); -imported_ts_type!(RadrootsOrderEventTypeImport, "RadrootsOrderEventType"); #[derive(dto_bindgen::Dto)] pub struct RadrootsTradeFacetCount { @@ -262,19 +250,6 @@ pub struct RadrootsTradeMarketplaceListingSummary { } #[derive(dto_bindgen::Dto)] -pub struct RadrootsTradeMarketplaceOrderSummary { - pub order_id: String, - pub listing_addr: String, - pub buyer_pubkey: String, - pub seller_pubkey: String, - pub status: RadrootsTradeWorkflowState, - pub last_message_type: RadrootsOrderEventTypeImport, - pub item_count: u32, - pub total_bin_count: u32, - pub last_reason: Option<String>, -} - -#[derive(dto_bindgen::Dto)] pub struct RadrootsTradeModerationFlag { pub code: String, pub severity: RadrootsTradeModerationSeverity, @@ -304,69 +279,6 @@ pub enum RadrootsTradeModerationStatus { } #[derive(dto_bindgen::Dto)] -pub struct RadrootsTradeOrderBackofficeOverlay { - pub order_id: String, - pub review_queue: Option<RadrootsTradeReviewQueueEntry>, - pub moderation_flags: Vec<RadrootsTradeModerationFlag>, -} - -#[derive(dto_bindgen::Dto)] -pub struct RadrootsTradeOrderBackofficeQuery { - pub order: RadrootsTradeOrderQuery, - pub requires_review: Option<bool>, - pub has_open_moderation_flags: Option<bool>, -} - -#[derive(dto_bindgen::Dto)] -pub struct RadrootsTradeOrderBackofficeView { - pub order: RadrootsOrderWorkflowProjection, - pub marketplace: RadrootsTradeMarketplaceOrderSummary, - pub overlay: Option<RadrootsTradeOrderBackofficeOverlay>, - pub requires_review: bool, - pub open_moderation_flag_count: u32, -} - -#[derive(dto_bindgen::Dto)] -pub struct RadrootsTradeOrderFacets { - pub buyer_pubkeys: Vec<RadrootsTradeFacetCount>, - pub seller_pubkeys: Vec<RadrootsTradeFacetCount>, - pub listing_addrs: Vec<RadrootsTradeFacetCount>, - pub statuses: Vec<RadrootsTradeFacetCount>, -} - -#[derive(dto_bindgen::Dto)] -pub struct RadrootsTradeOrderQuery { - pub listing_addr: Option<String>, - pub buyer_pubkey: Option<String>, - pub seller_pubkey: Option<String>, - pub status: Option<RadrootsTradeWorkflowState>, -} - -#[derive(dto_bindgen::Dto)] -pub struct RadrootsTradeOrderSort { - pub field: RadrootsTradeOrderSortField, - pub direction: RadrootsTradeSortDirection, -} - -#[derive(dto_bindgen::Dto)] -pub enum RadrootsTradeOrderSortField { - #[serde(rename = "order_id")] - OrderId, - #[serde(rename = "listing_addr")] - ListingAddr, - #[serde(rename = "buyer_pubkey")] - BuyerPubkey, - #[serde(rename = "seller_pubkey")] - SellerPubkey, - #[serde(rename = "status")] - Status, - #[serde(rename = "last_message_type")] - LastMessageType, - #[serde(rename = "total_bin_count")] - TotalBinCount, -} - -#[derive(dto_bindgen::Dto)] pub enum RadrootsTradeReviewPriority { #[serde(rename = "low")] Low, diff --git a/crates/trade_bindings/src/lib.rs b/crates/trade_bindings/src/lib.rs @@ -39,11 +39,12 @@ pub const TRADE_TYPE_INVENTORY: &[TradeTypeInventoryEntry] = &[ event_import("RadrootsListingProduct"), event_import("RadrootsListingPublicLocation"), event_import("RadrootsListingStatus"), - event_import("RadrootsOrderEconomics"), - event_import("RadrootsOrderEventType"), - event_import("RadrootsOrderInventoryCommitment"), - source_support("RadrootsOrderIssue"), local_shape("RadrootsTradeFacetCount"), + source_root("RadrootsTradeAgreementStateV1"), + source_root("RadrootsTradeAttestationStateV1"), + source_root("RadrootsTradeConflictStateV1"), + source_root("RadrootsTradeEvidenceStateV1"), + source_root("RadrootsTradeFulfillmentStateV1"), source_root("RadrootsTradeListing"), local_shape("RadrootsTradeListingBackofficeOverlay"), local_shape("RadrootsTradeListingBackofficeQuery"), @@ -58,23 +59,17 @@ pub const TRADE_TYPE_INVENTORY: &[TradeTypeInventoryEntry] = &[ source_root("RadrootsTradeListingSubtotal"), source_root("RadrootsTradeListingTotal"), local_shape("RadrootsTradeMarketplaceListingSummary"), - local_shape("RadrootsTradeMarketplaceOrderSummary"), local_shape("RadrootsTradeModerationFlag"), local_shape("RadrootsTradeModerationSeverity"), local_shape("RadrootsTradeModerationStatus"), - local_shape("RadrootsTradeOrderBackofficeOverlay"), - local_shape("RadrootsTradeOrderBackofficeQuery"), - local_shape("RadrootsTradeOrderBackofficeView"), - local_shape("RadrootsTradeOrderFacets"), - local_shape("RadrootsTradeOrderQuery"), - local_shape("RadrootsTradeOrderSort"), - local_shape("RadrootsTradeOrderSortField"), - source_root("RadrootsOrderWorkflowProjection"), + source_root("RadrootsTradeNegotiationStateV1"), + source_root("RadrootsTradePaymentStateV1"), + source_root("RadrootsTradePrivateTermsStateV1"), + source_root("RadrootsTradeProjectionV1"), local_shape("RadrootsTradeReviewPriority"), local_shape("RadrootsTradeReviewQueueEntry"), local_shape("RadrootsTradeReviewStatus"), local_shape("RadrootsTradeSortDirection"), - source_root("RadrootsTradeWorkflowState"), ]; pub const TRADE_LARGE_INTEGER_POLICIES: &[TradeLargeIntegerPolicyEntry] = &[ @@ -92,12 +87,6 @@ pub const TRADE_LARGE_INTEGER_POLICIES: &[TradeLargeIntegerPolicyEntry] = &[ "RadrootsTradeMarketplaceListingSummary", "terminal_order_count", ), - json_number_safe_count("RadrootsTradeMarketplaceOrderSummary", "item_count"), - json_number_safe_count("RadrootsTradeMarketplaceOrderSummary", "total_bin_count"), - json_number_safe_count( - "RadrootsTradeOrderBackofficeView", - "open_moderation_flag_count", - ), ]; const fn source_root(export_name: &'static str) -> TradeTypeInventoryEntry { @@ -107,13 +96,6 @@ const fn source_root(export_name: &'static str) -> TradeTypeInventoryEntry { } } -const fn source_support(export_name: &'static str) -> TradeTypeInventoryEntry { - TradeTypeInventoryEntry { - export_name, - disposition: TradeTypeDisposition::SourceTradeSupport, - } -} - const fn event_import(export_name: &'static str) -> TradeTypeInventoryEntry { TradeTypeInventoryEntry { export_name, @@ -174,11 +156,12 @@ mod tests { "RadrootsListingProduct", "RadrootsListingPublicLocation", "RadrootsListingStatus", - "RadrootsOrderEconomics", - "RadrootsOrderEventType", - "RadrootsOrderInventoryCommitment", - "RadrootsOrderIssue", "RadrootsTradeFacetCount", + "RadrootsTradeAgreementStateV1", + "RadrootsTradeAttestationStateV1", + "RadrootsTradeConflictStateV1", + "RadrootsTradeEvidenceStateV1", + "RadrootsTradeFulfillmentStateV1", "RadrootsTradeListing", "RadrootsTradeListingBackofficeOverlay", "RadrootsTradeListingBackofficeQuery", @@ -193,23 +176,17 @@ mod tests { "RadrootsTradeListingSubtotal", "RadrootsTradeListingTotal", "RadrootsTradeMarketplaceListingSummary", - "RadrootsTradeMarketplaceOrderSummary", "RadrootsTradeModerationFlag", "RadrootsTradeModerationSeverity", "RadrootsTradeModerationStatus", - "RadrootsTradeOrderBackofficeOverlay", - "RadrootsTradeOrderBackofficeQuery", - "RadrootsTradeOrderBackofficeView", - "RadrootsTradeOrderFacets", - "RadrootsTradeOrderQuery", - "RadrootsTradeOrderSort", - "RadrootsTradeOrderSortField", - "RadrootsOrderWorkflowProjection", + "RadrootsTradeNegotiationStateV1", + "RadrootsTradePaymentStateV1", + "RadrootsTradePrivateTermsStateV1", + "RadrootsTradeProjectionV1", "RadrootsTradeReviewPriority", "RadrootsTradeReviewQueueEntry", "RadrootsTradeReviewStatus", - "RadrootsTradeSortDirection", - "RadrootsTradeWorkflowState" + "RadrootsTradeSortDirection" ] ); } @@ -225,9 +202,6 @@ mod tests { "RadrootsListingProduct", "RadrootsListingPublicLocation", "RadrootsListingStatus", - "RadrootsOrderEconomics", - "RadrootsOrderEventType", - "RadrootsOrderInventoryCommitment", ] { assert_eq!( disposition(export_name), @@ -247,20 +221,28 @@ mod tests { assert_eq!( source_roots, [ + "RadrootsTradeAgreementStateV1", + "RadrootsTradeAttestationStateV1", + "RadrootsTradeConflictStateV1", + "RadrootsTradeEvidenceStateV1", + "RadrootsTradeFulfillmentStateV1", "RadrootsTradeListing", "RadrootsTradeListingSubtotal", "RadrootsTradeListingTotal", - "RadrootsOrderWorkflowProjection", - "RadrootsTradeWorkflowState" + "RadrootsTradeNegotiationStateV1", + "RadrootsTradePaymentStateV1", + "RadrootsTradePrivateTermsStateV1", + "RadrootsTradeProjectionV1" ] ); } #[test] fn trade_source_support_types_are_marked_for_source_registry() { - assert_eq!( - disposition("RadrootsOrderIssue"), - TradeTypeDisposition::SourceTradeSupport + assert!( + TRADE_TYPE_INVENTORY + .iter() + .all(|entry| entry.disposition != TradeTypeDisposition::SourceTradeSupport) ); } @@ -314,21 +296,6 @@ mod tests { "terminal_order_count", super::TradeLargeIntegerPolicy::JsonNumberSafeCount ), - ( - "RadrootsTradeMarketplaceOrderSummary", - "item_count", - super::TradeLargeIntegerPolicy::JsonNumberSafeCount - ), - ( - "RadrootsTradeMarketplaceOrderSummary", - "total_bin_count", - super::TradeLargeIntegerPolicy::JsonNumberSafeCount - ), - ( - "RadrootsTradeOrderBackofficeView", - "open_moderation_flag_count", - super::TradeLargeIntegerPolicy::JsonNumberSafeCount - ), ] ); } diff --git a/packages/trade-bindings/src/generated/types.ts b/packages/trade-bindings/src/generated/types.ts @@ -1,14 +1,26 @@ // @generated by cargo xtask generate ts // Do not edit by hand. import type { RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreUnit } from "@radroots/core-bindings"; -import type { RadrootsFarmRef, RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, RadrootsListingDeliveryMethod, RadrootsListingImage, RadrootsListingProduct, RadrootsListingPublicLocation, RadrootsOrderEconomics, RadrootsOrderEventType, RadrootsOrderInventoryCommitment, RadrootsPlotRef, RadrootsResourceAreaRef } from "@radroots/event-bindings"; +import type { RadrootsFarmRef, RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, RadrootsListingDeliveryMethod, RadrootsListingImage, RadrootsListingProduct, RadrootsListingPublicLocation, RadrootsPlotRef, RadrootsResourceAreaRef } from "@radroots/event-bindings"; -export type RadrootsOrderIssue = "missing_request" | { multiple_requests: { event_ids: Array<string>, }, } | { request_payload_invalid: { event_id: string, }, } | { request_order_id_mismatch: { event_id: string, }, } | { request_author_mismatch: { event_id: string, }, } | { request_listing_address_invalid: { event_id: string, }, } | { request_seller_listing_mismatch: { event_id: string, }, } | { decision_payload_invalid: { event_id: string, }, } | { decision_order_id_mismatch: { event_id: string, }, } | { decision_author_mismatch: { event_id: string, }, } | { decision_counterparty_mismatch: { event_id: string, }, } | { decision_buyer_mismatch: { event_id: string, }, } | { decision_seller_mismatch: { event_id: string, }, } | { decision_listing_address_invalid: { event_id: string, }, } | { decision_listing_mismatch: { event_id: string, }, } | { decision_root_mismatch: { event_id: string, }, } | { decision_previous_mismatch: { event_id: string, }, } | { decision_missing_inventory_commitments: { event_id: string, }, } | { decision_inventory_commitment_mismatch: { event_id: string, }, } | { decision_missing_reason: { event_id: string, }, } | { conflicting_decisions: { event_ids: Array<string>, }, } | { cancellation_without_cancellable_order: { event_id: string, }, } | { cancellation_payload_invalid: { event_id: string, }, } | { cancellation_order_id_mismatch: { event_id: string, }, } | { cancellation_author_mismatch: { event_id: string, }, } | { cancellation_counterparty_mismatch: { event_id: string, }, } | { cancellation_buyer_mismatch: { event_id: string, }, } | { cancellation_seller_mismatch: { event_id: string, }, } | { cancellation_listing_address_invalid: { event_id: string, }, } | { cancellation_listing_mismatch: { event_id: string, }, } | { cancellation_root_mismatch: { event_id: string, }, } | { cancellation_previous_mismatch: { event_id: string, }, } | { forked_lifecycle: { event_ids: Array<string>, }, } | { validation_receipt_without_pending_agreement: { event_id: string, }, } | { validation_receipt_order_id_mismatch: { event_id: string, }, } | { validation_receipt_type_mismatch: { event_id: string, }, } | { validation_receipt_root_mismatch: { event_id: string, }, } | { validation_receipt_target_mismatch: { event_id: string, }, } | { validation_receipt_listing_mismatch: { event_id: string, }, } | { conflicting_validation_receipts: { event_ids: Array<string>, }, } | { deterministic_validation_failure: { event_id: string, reason: string, }, } | { stale_listing_event: { expected_event_id: string, current_event_id: string, }, }; +export type RadrootsTradeAgreementClaimV1 = { claim_mutation_id: string, proposal_mutation_id: string, candidate_id: string, candidate_author_pubkey: string, accepted_by_pubkey: string, reservation_commitment: string, }; -export type RadrootsOrderWorkflowProjection = { order_id: string, status: RadrootsTradeWorkflowState, request_event_id?: string | null, decision_event_id?: string | null, cancellation_event_id?: string | null, validation_receipt_event_id?: string | null, lifecycle_terminal: boolean, economics?: RadrootsOrderEconomics | null, agreement_event_id?: string | null, pending_inventory_reservations: Array<RadrootsOrderInventoryCommitment>, committed_inventory_reservations: Array<RadrootsOrderInventoryCommitment>, listing_addr?: string | null, buyer_pubkey?: string | null, seller_pubkey?: string | null, last_event_id?: string | null, issues: Array<RadrootsOrderIssue>, }; +export type RadrootsTradeAgreementStateV1 = "none" | "agreed" | "contested" | "cancelled"; + +export type RadrootsTradeAttestationRecordV1 = { event_id: string, claim_mutation_id: string, result: RadrootsTradeAttestationResultV1, }; + +export type RadrootsTradeAttestationResultV1 = "Valid" | "Invalid"; + +export type RadrootsTradeAttestationStateV1 = "none" | "present_valid" | "present_invalid" | "conflicting"; + +export type RadrootsTradeConflictStateV1 = "none" | "concurrent_candidates" | "double_acceptance" | "decision_conflict" | "cancellation_conflict" | "invalid_causal_chain" | "inventory_authority_conflict"; + +export type RadrootsTradeEvidenceStateV1 = "complete" | "missing" | "query_partial" | "unsupported_version"; export type RadrootsTradeFacetCount = { key: string, count: number, }; +export type RadrootsTradeFulfillmentStateV1 = "not_started"; + export type RadrootsTradeListing = { listing_id: string, listing_addr: string, seller_pubkey: string, title: string, description: string, product_type: string, primary_bin_id: string, bin_quantity: RadrootsCoreQuantity, unit: RadrootsCoreUnit, unit_price: RadrootsCoreMoney, inventory_available: RadrootsCoreDecimal, availability: RadrootsListingAvailability, location: RadrootsListingPublicLocation, delivery_method: RadrootsListingDeliveryMethod, listing: RadrootsListing, }; export type RadrootsTradeListingBackofficeOverlay = { listing_addr: string, review_queue?: RadrootsTradeReviewQueueEntry | null, moderation_flags: Array<RadrootsTradeModerationFlag>, }; @@ -37,27 +49,21 @@ export type RadrootsTradeListingTotal = { price_amount: RadrootsCoreMoney, price export type RadrootsTradeMarketplaceListingSummary = { listing_addr: string, seller_pubkey: string, farm_pubkey: string, farm_id: string, product_key: string, product_title: string, product_category: string, product_summary?: string | null, listing_status: RadrootsTradeListingMarketStatus, location_primary?: string | null, inventory_available?: RadrootsCoreDecimal | null, primary_bin_id: string, primary_bin_label?: string | null, primary_bin_total: RadrootsTradeListingTotal, order_count: number, open_order_count: number, terminal_order_count: number, }; -export type RadrootsTradeMarketplaceOrderSummary = { order_id: string, listing_addr: string, buyer_pubkey: string, seller_pubkey: string, status: RadrootsTradeWorkflowState, last_message_type: RadrootsOrderEventType, item_count: number, total_bin_count: number, last_reason?: string | null, }; - export type RadrootsTradeModerationFlag = { code: string, severity: RadrootsTradeModerationSeverity, status: RadrootsTradeModerationStatus, source?: string | null, reason?: string | null, }; export type RadrootsTradeModerationSeverity = "notice" | "warning" | "block"; export type RadrootsTradeModerationStatus = "open" | "snoozed" | "resolved"; -export type RadrootsTradeOrderBackofficeOverlay = { order_id: string, review_queue?: RadrootsTradeReviewQueueEntry | null, moderation_flags: Array<RadrootsTradeModerationFlag>, }; - -export type RadrootsTradeOrderBackofficeQuery = { order: RadrootsTradeOrderQuery, requires_review?: boolean | null, has_open_moderation_flags?: boolean | null, }; +export type RadrootsTradeNegotiationStateV1 = "none" | "open" | "closed_declined" | "closed_expired"; -export type RadrootsTradeOrderBackofficeView = { order: RadrootsOrderWorkflowProjection, marketplace: RadrootsTradeMarketplaceOrderSummary, overlay?: RadrootsTradeOrderBackofficeOverlay | null, requires_review: boolean, open_moderation_flag_count: number, }; +export type RadrootsTradePaymentStateV1 = "not_tracked"; -export type RadrootsTradeOrderFacets = { buyer_pubkeys: Array<RadrootsTradeFacetCount>, seller_pubkeys: Array<RadrootsTradeFacetCount>, listing_addrs: Array<RadrootsTradeFacetCount>, statuses: Array<RadrootsTradeFacetCount>, }; +export type RadrootsTradePrivateTermsStateV1 = "not_required" | "available_verified" | "missing" | "undecryptable" | "commitment_mismatch"; -export type RadrootsTradeOrderQuery = { listing_addr?: string | null, buyer_pubkey?: string | null, seller_pubkey?: string | null, status?: RadrootsTradeWorkflowState | null, }; +export type RadrootsTradeProjectionV1 = { reducer_contract_id: string, reducer_version: number, trade_id: string, root_mutation_id?: string | null, buyer_pubkey?: string | null, seller_pubkey?: string | null, farm_id?: string | null, negotiation_state: RadrootsTradeNegotiationStateV1, agreement_state: RadrootsTradeAgreementStateV1, evidence_state: RadrootsTradeEvidenceStateV1, conflict_state: RadrootsTradeConflictStateV1, private_terms_state: RadrootsTradePrivateTermsStateV1, attestation_state: RadrootsTradeAttestationStateV1, fulfillment_state: RadrootsTradeFulfillmentStateV1, payment_state: RadrootsTradePaymentStateV1, candidate_heads: Array<string>, agreement_claims: Array<RadrootsTradeAgreementClaimV1>, active_agreement_claim_ids: Array<string>, contested_claim_ids: Array<string>, cancelled_claim_ids: Array<string>, declined_candidate_ids: Array<string>, missing_parent_ids: Array<string>, missing_proposal_ids: Array<string>, unsupported_mutation_ids: Array<string>, issues: Array<RadrootsTradeReducerIssueV1>, attestations: Array<RadrootsTradeAttestationRecordV1>, projection_digest: string, }; -export type RadrootsTradeOrderSort = { field: RadrootsTradeOrderSortField, direction: RadrootsTradeSortDirection, }; - -export type RadrootsTradeOrderSortField = "order_id" | "listing_addr" | "buyer_pubkey" | "seller_pubkey" | "status" | "last_message_type" | "total_bin_count"; +export type RadrootsTradeReducerIssueV1 = "MissingRootProposal" | "MultipleRootProposals" | "MissingMutationId" | { TradeIdentityMismatch: { mutation_id: string, }, } | { UnsupportedSchema: { mutation_id: string, schema_version: number, }, } | { InvalidMutation: { mutation_id?: string | null, reason: string, }, } | { MissingParent: { mutation_id: string, parent_mutation_id: string, }, } | { MissingProposal: { decision_mutation_id: string, proposal_mutation_id: string, }, } | { CandidateIdMismatch: { decision_mutation_id: string, proposal_mutation_id: string, }, } | { DecisionAuthorMismatch: { decision_mutation_id: string, }, } | { DecisionParentMissing: { decision_mutation_id: string, proposal_mutation_id: string, }, } | { MissingSellerReservation: { decision_mutation_id: string, }, } | { ReservationCandidateMismatch: { decision_mutation_id: string, }, } | { ReservationAuthorityMismatch: { decision_mutation_id: string, }, } | { ReservationLineMismatch: { decision_mutation_id: string, }, } | { DecisionConflict: { proposal_mutation_id: string, }, } | { DoubleAcceptance: { proposal_mutation_id: string, }, } | { CancellationConflict: { cancellation_mutation_id: string, }, } | { InvalidCausalChain: { mutation_id: string, }, } | { PrivateTermsUnavailable: { candidate_id: string, }, } | { ProjectionDigestUnavailable: { reason: string, }, }; export type RadrootsTradeReviewPriority = "low" | "normal" | "high" | "critical"; @@ -66,5 +72,3 @@ export type RadrootsTradeReviewQueueEntry = { queue: string, priority: RadrootsT export type RadrootsTradeReviewStatus = "queued" | "in_progress" | "blocked" | "resolved"; export type RadrootsTradeSortDirection = "asc" | "desc"; - -export type RadrootsTradeWorkflowState = "missing" | "requested" | "agreed_pending_validation" | "committed" | "declined" | "cancelled" | "validation_expired" | "invalid"; diff --git a/tools/xtask/src/dto_roots.rs b/tools/xtask/src/dto_roots.rs @@ -145,16 +145,6 @@ const TRADE_EXTERNAL_OVERRIDES: &[DtoExternalOverride] = &[ event_override("RadrootsEventPtr"), event_override("RadrootsPlotRef"), event_override("RadrootsResourceAreaRef"), - event_override("RadrootsOrderEconomicActor"), - event_override("RadrootsOrderEconomicEffect"), - event_override("RadrootsOrderEconomicItem"), - event_override("RadrootsOrderEconomicLine"), - event_override("RadrootsOrderEconomicLineKind"), - event_override("RadrootsOrderEconomics"), - event_override("RadrootsOrderEventType"), - event_override("RadrootsOrderInventoryCommitment"), - event_override("RadrootsOrderItem"), - event_override("RadrootsOrderPricingBasis"), ]; const TRADE_REQUIRED_EXTERNAL_PACKAGE_IMPORTS: &[&str] = @@ -682,9 +672,15 @@ mod tests { "RadrootsEventIndexCheckpoint", ]; const TRADE_TYPE_INVENTORY: &[&str] = &[ - "RadrootsOrderIssue", - "RadrootsOrderWorkflowProjection", + "RadrootsTradeAgreementClaimV1", + "RadrootsTradeAgreementStateV1", + "RadrootsTradeAttestationRecordV1", + "RadrootsTradeAttestationResultV1", + "RadrootsTradeAttestationStateV1", + "RadrootsTradeConflictStateV1", + "RadrootsTradeEvidenceStateV1", "RadrootsTradeFacetCount", + "RadrootsTradeFulfillmentStateV1", "RadrootsTradeListing", "RadrootsTradeListingBackofficeOverlay", "RadrootsTradeListingBackofficeQuery", @@ -699,22 +695,18 @@ mod tests { "RadrootsTradeListingSubtotal", "RadrootsTradeListingTotal", "RadrootsTradeMarketplaceListingSummary", - "RadrootsTradeMarketplaceOrderSummary", "RadrootsTradeModerationFlag", "RadrootsTradeModerationSeverity", "RadrootsTradeModerationStatus", - "RadrootsTradeOrderBackofficeOverlay", - "RadrootsTradeOrderBackofficeQuery", - "RadrootsTradeOrderBackofficeView", - "RadrootsTradeOrderFacets", - "RadrootsTradeOrderQuery", - "RadrootsTradeOrderSort", - "RadrootsTradeOrderSortField", + "RadrootsTradeNegotiationStateV1", + "RadrootsTradePaymentStateV1", + "RadrootsTradePrivateTermsStateV1", + "RadrootsTradeProjectionV1", + "RadrootsTradeReducerIssueV1", "RadrootsTradeReviewPriority", "RadrootsTradeReviewQueueEntry", "RadrootsTradeReviewStatus", "RadrootsTradeSortDirection", - "RadrootsTradeWorkflowState", ]; #[test] @@ -993,17 +985,17 @@ mod tests { assert!( imports .get("@radroots/event-bindings") - .is_some_and(|names| names.contains("RadrootsOrderEventType")) + .is_some_and(|names| names.contains("RadrootsFarmRef")) ); assert!( imports .get("@radroots/event-bindings") - .is_some_and(|names| names.contains("RadrootsOrderEconomics")) + .is_some_and(|names| names.contains("RadrootsListing")) ); assert!( imports .get("@radroots/event-bindings") - .is_some_and(|names| names.contains("RadrootsOrderInventoryCommitment")) + .is_some_and(|names| names.contains("RadrootsListingBin")) ); for duplicate in [ @@ -1015,27 +1007,19 @@ mod tests { "export type RadrootsCommercialMessageType = ", "export type RadrootsOrderStatus = ", "export type RadrootsTradeOrderWorkflowMessage = ", + "export type RadrootsOrderWorkflowProjection = ", + "export type RadrootsTradeMarketplaceOrderSummary = ", + "export type RadrootsTradeOrderQuery = ", ] { assert!(!TRADE_BINDINGS_TYPES_TS.contains(duplicate)); } - let marketplace_order_summary = type_declaration( - TRADE_BINDINGS_TYPES_TS, - "RadrootsTradeMarketplaceOrderSummary", - ); - assert!(marketplace_order_summary.contains("status: RadrootsTradeWorkflowState")); - assert!(marketplace_order_summary.contains("last_message_type: RadrootsOrderEventType")); - assert!(!marketplace_order_summary.contains("has_requested_discounts")); - - let order_query = type_declaration(TRADE_BINDINGS_TYPES_TS, "RadrootsTradeOrderQuery"); - assert!(order_query.contains("status?: RadrootsTradeWorkflowState | null")); - - let workflow_projection = - type_declaration(TRADE_BINDINGS_TYPES_TS, "RadrootsOrderWorkflowProjection"); - assert!(workflow_projection.contains("status: RadrootsTradeWorkflowState")); - assert!(workflow_projection.contains("request_event_id?: string | null")); - assert!(workflow_projection.contains("last_event_id?: string | null")); - assert!(workflow_projection.contains("listing_addr?: string | null")); + let projection = type_declaration(TRADE_BINDINGS_TYPES_TS, "RadrootsTradeProjectionV1"); + assert!(projection.contains("agreement_state: RadrootsTradeAgreementStateV1")); + assert!(projection.contains("negotiation_state: RadrootsTradeNegotiationStateV1")); + assert!(projection.contains("agreement_claims: Array<RadrootsTradeAgreementClaimV1>")); + assert!(projection.contains("issues: Array<RadrootsTradeReducerIssueV1>")); + assert!(projection.contains("projection_digest: string")); for stale_counter in [ "root_event_id", "last_message_type", @@ -1051,7 +1035,7 @@ mod tests { "discount_accept_count", "discount_decline_count", ] { - assert!(!workflow_projection.contains(stale_counter)); + assert!(!projection.contains(stale_counter)); } }