lib

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

commit 111f143c5007830710b8f895ae62ab778c22c79e
parent cebd246ea7b45b615200b47fb72a23a3821dfe96
Author: triesap <tyson@radroots.org>
Date:   Wed, 15 Jul 2026 11:52:34 +0000

runtime: consolidate sdk storage transactions

Move SDK local runtime effects onto the V1 runtime/private/studio SQLite layout and require explicit UUIDv7 idempotency for mutations. Route event-store ingest, operation journal state, and outbox enqueue through one runtime transaction; add read-only inspection, pre-V1 fail-closed detection, and explicit quarantine reset. Remove retired DVM, revision, partial-local-mutation, and disabled legacy test surfaces while updating downstream tests, README, examples, and generated bindings.

Diffstat:
Mcrates/event_bindings/src/model.rs | 43-------------------------------------------
Mcrates/sdk/README | 36+++++++++++++++---------------------
Mcrates/sdk/examples/runtime_local.rs | 2+-
Mcrates/sdk/examples/sdk_v1_listing_prepare.rs | 1-
Mcrates/sdk/examples/sdk_v1_local_enqueue_and_mock_sync.rs | 4+++-
Mcrates/sdk/examples/sdk_v1_myc_nip46_signer_setup.rs | 2+-
Dcrates/sdk/src/dvm_runtime.rs | 742-------------------------------------------------------------------------------
Mcrates/sdk/src/error.rs | 127+++++++++++++++++++++++--------------------------------------------------------
Mcrates/sdk/src/farms_runtime.rs | 4++--
Mcrates/sdk/src/idempotency.rs | 42++++++++++++++++++------------------------
Mcrates/sdk/src/lib.rs | 59+++++++++++++++++++++++++----------------------------------
Mcrates/sdk/src/listings_runtime.rs | 39+++++++++++++++++----------------------
Mcrates/sdk/src/order.rs | 88++++++++++++-------------------------------------------------------------------
Mcrates/sdk/src/orders_runtime.rs | 3552++++++++++++++-----------------------------------------------------------------
Mcrates/sdk/src/private_store.rs | 25++-----------------------
Mcrates/sdk/src/product_clients.rs | 13-------------
Mcrates/sdk/src/runtime.rs | 829++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/sdk/src/signer_provider.rs | 7++-----
Acrates/sdk/src/studio_store.rs | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/src/sync_runtime.rs | 10++--------
Mcrates/sdk/src/transport.rs | 36+++++++++++++++++-------------------
Mcrates/sdk/src/workflow_runtime.rs | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Dcrates/sdk/tests/dvm_runtime.rs | 894-------------------------------------------------------------------------------
Mcrates/sdk/tests/farms_runtime.rs | 62+++++++++++++++++++++++++++++++++++++-------------------------
Mcrates/sdk/tests/listings_runtime.rs | 182++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mcrates/sdk/tests/market_runtime.rs | 4+++-
Mcrates/sdk/tests/orders_runtime.rs | 3616+++++--------------------------------------------------------------------------
Mcrates/sdk/tests/runtime_foundation.rs | 160++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mcrates/sdk/tests/source_boundary.rs | 139+++++++++++++++++++++++++------------------------------------------------------
Mcrates/sdk/tests/sync_runtime.rs | 185++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mcrates/sdk/tests/trade_product_publish_runtime.rs | 15+++++++--------
Mcrates/sdk/tests/unit/actor_json_tests.rs | 5+----
Dcrates/sdk/tests/unit/dvm_runtime_tests.rs | 390-------------------------------------------------------------------------------
Mcrates/sdk/tests/unit/error_tests.rs | 80+++++++------------------------------------------------------------------------
Mcrates/sdk/tests/unit/farms_runtime_tests.rs | 38++++++++++++++++++++------------------
Mcrates/sdk/tests/unit/idempotency_tests.rs | 24+++++++++++++-----------
Mcrates/sdk/tests/unit/listings_runtime_tests.rs | 46++++++++++++++++++++++++----------------------
Mcrates/sdk/tests/unit/orders_runtime_tests.rs | 4335+------------------------------------------------------------------------------
Mcrates/sdk/tests/unit/private_store_tests.rs | 20+++++---------------
Mcrates/sdk/tests/unit/runtime_tests.rs | 349++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mcrates/sdk/tests/unit/signer_provider_tests.rs | 4++--
Mcrates/sdk/tests/unit/sync_runtime_tests.rs | 2+-
Mcrates/sdk/tests/unit/transport_tests.rs | 5+----
Mcrates/sdk/tests/unit/workflow_runtime_tests.rs | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
44 files changed, 2786 insertions(+), 13825 deletions(-)

diff --git a/crates/event_bindings/src/model.rs b/crates/event_bindings/src/model.rs @@ -113,57 +113,14 @@ const EVENT_KIND_EXPORTS: &[(&str, u32)] = &[ ("KIND_APP_DATA", kinds::KIND_APP_DATA), ("KIND_LISTING", kinds::KIND_LISTING), ("KIND_APPLICATION_HANDLER", kinds::KIND_APPLICATION_HANDLER), - ( - "KIND_TRADE_LISTING_VALIDATION_REQUEST", - kinds::KIND_TRADE_LISTING_VALIDATION_REQUEST, - ), - ( - "KIND_TRADE_LISTING_VALIDATION_RESULT", - kinds::KIND_TRADE_LISTING_VALIDATION_RESULT, - ), - ( - "KIND_TRADE_TRANSITION_PROOF_REQUEST", - kinds::KIND_TRADE_TRANSITION_PROOF_REQUEST, - ), - ( - "KIND_TRADE_TRANSITION_PROOF_RESULT", - kinds::KIND_TRADE_TRANSITION_PROOF_RESULT, - ), ("KIND_ORDER_REQUEST", kinds::KIND_ORDER_REQUEST), ("KIND_ORDER_DECISION", kinds::KIND_ORDER_DECISION), - ( - "KIND_ORDER_REVISION_PROPOSAL", - kinds::KIND_ORDER_REVISION_PROPOSAL, - ), - ( - "KIND_ORDER_REVISION_DECISION", - kinds::KIND_ORDER_REVISION_DECISION, - ), ("KIND_ORDER_CANCELLATION", kinds::KIND_ORDER_CANCELLATION), - ("KIND_TRADE_QUESTION", kinds::KIND_TRADE_QUESTION), - ("KIND_TRADE_ANSWER", kinds::KIND_TRADE_ANSWER), - ( - "KIND_TRADE_DISCOUNT_REQUEST", - kinds::KIND_TRADE_DISCOUNT_REQUEST, - ), - ( - "KIND_TRADE_DISCOUNT_OFFER", - kinds::KIND_TRADE_DISCOUNT_OFFER, - ), - ( - "KIND_TRADE_DISCOUNT_ACCEPT", - kinds::KIND_TRADE_DISCOUNT_ACCEPT, - ), ( "KIND_TRADE_FORBIDDEN_3431", kinds::KIND_TRADE_FORBIDDEN_3431, ), ( - "KIND_TRADE_FULFILLMENT_UPDATE", - kinds::KIND_TRADE_FULFILLMENT_UPDATE, - ), - ("KIND_TRADE_RECEIPT", kinds::KIND_TRADE_RECEIPT), - ( "KIND_TRADE_VALIDATION_RECEIPT", kinds::KIND_TRADE_VALIDATION_RECEIPT, ), diff --git a/crates/sdk/README b/crates/sdk/README @@ -7,9 +7,9 @@ The SDK v1 product runtime is centered on `RadrootsClient::builder()`, `RadrootsClient::builder()` defaults to memory storage, the system clock, the `LocalOnly` transport profile, and no production network publishing. Directory storage is opt-in and creates -`event_store.sqlite` and `outbox.sqlite` in the selected directory. Configured Nostr relay URLs live -inside `TransportProfile::Nostr` or the Nostr side of `TransportProfile::Hybrid`, and product -enqueue requests choose the active profile through `TargetPolicy::default_profile()`. +`runtime.sqlite`, `private.sqlite`, and `studio.sqlite` in the selected directory. Configured Nostr +relay URLs live inside `TransportProfile::Nostr` or the Nostr side of `TransportProfile::Hybrid`, +and product enqueue requests choose the active profile through `TargetPolicy::default_profile()`. When `signer-adapters` is enabled, `RadrootsClient::builder()` accepts a configured `RadrootsSdkSignerProvider`. The production signing modes are `local_key` and `myc_nip46`. Product @@ -23,13 +23,9 @@ that prepared plan, ingests it into the local event store, queues signed outbox typed `ListingEnqueueReceipt`. `sdk.listings().enqueue_publish(...)` is the convenience path that prepares once and delegates to `enqueue_prepared_publish(...)`. Farm and trade write methods follow the same configured signer pattern. The enqueue path uses typed transport target and idempotency -inputs; omitted idempotency keys are derived deterministically. `SatisfactionPolicy::NoWait` records -first-class no-wait delivery plans, so enqueue-only product workflows can complete without accepted -or delivered transport targets and without fabricated target delivery receipts. - -Explicit signer injection remains available under `*_with_explicit_signer` method names for -controlled adapter-level tests and advanced integration checks. Those methods are not the primary -product API. +inputs; write requests require explicit UUIDv7 idempotency keys. `SatisfactionPolicy::NoWait` +records first-class no-wait delivery plans, so enqueue-only product workflows can complete without +accepted or delivered transport targets and without fabricated target delivery receipts. Event `created_at` and local observation time are separate contracts. The event timestamp remains the authored event-envelope timestamp, while event-store and outbox mutation timestamps use the SDK @@ -47,19 +43,17 @@ transport-level substrate checks. `radrootsd-proxy` adds daemon-resolved publish `publish.event`. `sdk.trades()` exposes the local trade runtime surface. With `runtime`, callers can ingest local trade -evidence, ingest DVM validation receipts through `sdk.dvm()`, read `sdk.trades().status(...)`, resync -through `sdk.trades().resync()`, inspect validation receipts through -`sdk.trades().validation_receipts()`, and read seller inbox projections through -`sdk.trades().seller()`. +evidence, read `sdk.trades().status(...)`, resync through `sdk.trades().resync()`, inspect +validation receipts through `sdk.trades().validation_receipts()`, and read seller inbox projections +through `sdk.trades().seller()`. `sdk.trades().status(...)` accepts `TradeStatusRequest` and reads local projections; it returns typed source, event count, limit state, event IDs, ambiguity candidates, eligibility, next-action, and reducer issue data. Status is not a network fetch. Configured-signer trade writes compile when both `runtime` and `signer-adapters` are enabled. Buyer -write workflows use `sdk.trades().buyer()` for propose, cancel, and revision-decision actions. -Seller write workflows use `sdk.trades().seller()` for accept, decline, and revision-proposal -actions. Runtime-only builds intentionally expose no buyer write handle and no trade mutation request -DTOs. DVM proof requests and validation receipt ingestion use `sdk.dvm()`. +write workflows use `sdk.trades().buyer()` for propose and cancel actions. Seller write workflows +use `sdk.trades().seller()` for accept and decline actions. Runtime-only builds intentionally expose +no buyer write handle and no trade mutation request DTOs. The `local-runtime` feature is the curated feature bundle for local product runtime consumers. It enables `std`, `serde`, `serde_json`, `runtime`, `signer-adapters`, `transport-nostr-runtime`, and @@ -77,9 +71,9 @@ Relay URL policy is explicit. Public relay URLs must use `wss://`. Local develop URLs are accepted only under `NostrRelayUrlPolicy::Localhost` and only for `localhost`, `127.0.0.1`, or `[::1]`. Non-local insecure `ws://` targets, including private LAN addresses, are rejected. -Low-level event-contract, wire-codec, reducer, and DVM contract ownership lives in the shared -Radroots libraries. The SDK exposes product APIs and selected adapter boundaries; it does not expose -a public workflow-bypass namespace for product callers. +Low-level event-contract, wire-codec, reducer, and validation receipt contract ownership lives in +the shared Radroots libraries. The SDK exposes product APIs and selected adapter boundaries; it does +not expose a public workflow-bypass namespace for product callers. ## Examples diff --git a/crates/sdk/examples/runtime_local.rs b/crates/sdk/examples/runtime_local.rs @@ -30,7 +30,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { let listing = sample_listing(seller.as_str()); let prepare_request = ListingPreparePublishRequest::new(actor.clone(), listing); let target_policy = TargetPolicy::try_nostr_relays([RELAY], NostrRelayUrlPolicy::Public)?; - let idempotency_key = SdkIdempotencyKey::new("example-1")?; + let idempotency_key = SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000202")?; let prepared = sdk.listings().prepare_publish(prepare_request)?; let enqueue = sdk diff --git a/crates/sdk/examples/sdk_v1_listing_prepare.rs b/crates/sdk/examples/sdk_v1_listing_prepare.rs @@ -23,7 +23,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { let plan = sdk.listings().prepare_publish(request)?; println!("public listing: {}", plan.public_listing_addr.as_str()); - println!("draft listing: {}", plan.draft_listing_addr.as_str()); println!("expected event: {}", plan.expected_event_id.as_str()); 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 @@ -43,7 +43,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { &actor, prepared, target_policy, - Some(SdkIdempotencyKey::new("sdk-v1-local-example")?), + Some(SdkIdempotencyKey::new( + "01890f0e-6c00-7000-8000-000000000201", + )?), ) .await?; let adapter = RadrootsMockRelayPublishAdapter::new(); diff --git a/crates/sdk/examples/sdk_v1_myc_nip46_signer_setup.rs b/crates/sdk/examples/sdk_v1_myc_nip46_signer_setup.rs @@ -42,7 +42,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { Arc::new(ExampleNip46Transport), )?; let sdk = RadrootsClient::builder() - .signer_provider(RadrootsSdkSignerProvider::MycNip46(signer)) + .signer_provider(RadrootsSdkSignerProvider::MycNip46(Box::new(signer))) .build() .await?; let status = sdk.signer_status().expect("configured signer status"); diff --git a/crates/sdk/src/dvm_runtime.rs b/crates/sdk/src/dvm_runtime.rs @@ -1,742 +0,0 @@ -#[cfg(feature = "signer-adapters")] -use crate::workflow_runtime::enqueue_configured_signed_workflow; -#[cfg(feature = "runtime")] -use crate::{ - DvmClient, NostrRelayUrlPolicy, RadrootsSdkError, RadrootsSdkTimestamp, SatisfactionPolicy, - SdkIdempotencyKey, SdkMutationState, SyncProjectionRefreshReceipt, - SyncProjectionRefreshRequest, TargetPolicy, - runtime::sdk_now_ms, - sync_runtime::refresh_product_projections_for_sdk, - workflow_runtime::{SdkWorkflowEnqueueRequest, enqueue_signed_workflow}, -}; -#[cfg(feature = "runtime")] -use radroots_authority::{RadrootsActorContext, RadrootsEventSigner, authorize_actor_for_draft}; -use radroots_event::wire::RadrootsNip01EventWireParts; -#[cfg(feature = "runtime")] -use radroots_event::{ - draft::{RadrootsEventDraft, RadrootsSignedEvent}, - ids::{RadrootsEventId, RadrootsListingAddress, RadrootsOrderId, RadrootsPublicKey}, - kinds::KIND_TRADE_TRANSITION_PROOF_REQUEST, -}; -use radroots_event_codec::wire::canonicalize_tags; -#[cfg(feature = "runtime")] -use radroots_event_store::RadrootsEventIngest; -#[cfg(feature = "runtime")] -use radroots_trade::dvm::RadrootsTradeInventoryBinWitnessDto; -#[cfg(feature = "runtime")] -use radroots_trade::validation_receipt::{ - RadrootsTradeCommitmentConfidence, RadrootsTradeValidationAuthority, - RadrootsValidationReceiptExpectedBinding, RadrootsValidationReceiptProofSystem, - RadrootsValidationReceiptResult, RadrootsValidationReceiptType, - verify_validation_receipt_event, -}; - -#[cfg(feature = "runtime")] -const RADROOTS_TRADE_TRANSITION_WITNESS_VERSION: u32 = 1; -#[cfg(feature = "runtime")] -const RADROOTS_TRADE_TRANSITION_PROTOCOL_VERSION: &str = "radroots.trade.v1"; -#[cfg(feature = "runtime")] -const RADROOTS_TRADE_TRANSITION_REDUCER_PROGRAM_HASH: &str = - "0x3d8f7f463904d71f2d0d14b1551450756697e51c7b658e10c6d5c20a7bc61f08"; -#[cfg(feature = "runtime")] -const RADROOTS_TRADE_TRANSITION_PROOF_TARGET: &str = "trade.order_acceptance.v1"; - -#[cfg(feature = "runtime")] -pub const DVM_TRADE_TRANSITION_PROOF_REQUEST_CONTRACT_ID: &str = - "radroots.trade.transition_proof.request.v1"; -#[cfg(feature = "runtime")] -pub const DVM_TRADE_TRANSITION_PROOF_REQUEST_OPERATION_KIND: &str = - "dvm.trade_transition_proof.request.v1"; - -#[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum DvmProofMode { - #[default] - None, - Core, - Compressed, - Groth16, - Plonk, -} - -#[cfg(feature = "runtime")] -impl DvmProofMode { - pub const fn as_str(self) -> &'static str { - match self { - Self::None => "none", - Self::Core => "core", - Self::Compressed => "compressed", - Self::Groth16 => "groth16", - Self::Plonk => "plonk", - } - } - - pub const fn proof_system(self) -> RadrootsValidationReceiptProofSystem { - match self { - Self::None => RadrootsValidationReceiptProofSystem::None, - Self::Core => RadrootsValidationReceiptProofSystem::Sp1Core, - Self::Compressed => RadrootsValidationReceiptProofSystem::Sp1Compressed, - Self::Groth16 => RadrootsValidationReceiptProofSystem::Sp1Groth16, - Self::Plonk => RadrootsValidationReceiptProofSystem::Sp1Plonk, - } - } - - const fn requires_sp1_identity(self) -> bool { - !matches!(self, Self::None) - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, serde::Serialize)] -#[non_exhaustive] -pub struct DvmTradeTransitionProofPrepareRequest { - #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] - pub actor: RadrootsActorContext, - pub worker_pubkey: RadrootsPublicKey, - pub listing_addr: RadrootsListingAddress, - pub listing_event_id: RadrootsEventId, - pub request_event_id: RadrootsEventId, - pub decision_event_id: RadrootsEventId, - pub inventory_bins: Vec<RadrootsTradeInventoryBinWitnessDto>, - pub inventory_sequence: u128, - pub previous_state_root: Option<String>, - pub proof_mode: DvmProofMode, - pub sp1_program_hash: Option<String>, - pub sp1_verifying_key_hash: Option<String>, - pub created_at: Option<RadrootsSdkTimestamp>, -} - -#[cfg(feature = "runtime")] -impl DvmTradeTransitionProofPrepareRequest { - pub fn new( - actor: RadrootsActorContext, - worker_pubkey: RadrootsPublicKey, - listing_addr: RadrootsListingAddress, - listing_event_id: RadrootsEventId, - request_event_id: RadrootsEventId, - decision_event_id: RadrootsEventId, - inventory_bins: Vec<RadrootsTradeInventoryBinWitnessDto>, - ) -> Self { - Self { - actor, - worker_pubkey, - listing_addr, - listing_event_id, - request_event_id, - decision_event_id, - inventory_bins, - inventory_sequence: 0, - previous_state_root: None, - proof_mode: DvmProofMode::None, - sp1_program_hash: None, - sp1_verifying_key_hash: None, - created_at: None, - } - } - - pub fn with_inventory_sequence(mut self, inventory_sequence: u128) -> Self { - self.inventory_sequence = inventory_sequence; - self - } - - pub fn with_previous_state_root(mut self, previous_state_root: impl Into<String>) -> Self { - self.previous_state_root = Some(previous_state_root.into()); - self - } - - pub fn with_proof_mode(mut self, proof_mode: DvmProofMode) -> Self { - self.proof_mode = proof_mode; - self - } - - pub fn with_sp1_identity( - mut self, - program_hash: impl Into<String>, - verifying_key_hash: impl Into<String>, - ) -> Self { - self.sp1_program_hash = Some(program_hash.into()); - self.sp1_verifying_key_hash = Some(verifying_key_hash.into()); - 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 DvmTradeTransitionProofEnqueueRequest { - #[serde(flatten)] - pub prepare: DvmTradeTransitionProofPrepareRequest, - pub target_policy: TargetPolicy, - pub idempotency_key: Option<SdkIdempotencyKey>, -} - -#[cfg(feature = "runtime")] -impl DvmTradeTransitionProofEnqueueRequest { - pub fn new( - actor: RadrootsActorContext, - worker_pubkey: RadrootsPublicKey, - listing_addr: RadrootsListingAddress, - listing_event_id: RadrootsEventId, - request_event_id: RadrootsEventId, - decision_event_id: RadrootsEventId, - inventory_bins: Vec<RadrootsTradeInventoryBinWitnessDto>, - target_policy: TargetPolicy, - ) -> Self { - Self::from_prepare( - DvmTradeTransitionProofPrepareRequest::new( - actor, - worker_pubkey, - listing_addr, - listing_event_id, - request_event_id, - decision_event_id, - inventory_bins, - ), - target_policy, - ) - } - - pub fn from_prepare( - prepare: DvmTradeTransitionProofPrepareRequest, - target_policy: TargetPolicy, - ) -> Self { - Self { - prepare, - target_policy, - idempotency_key: None, - } - } - - pub fn try_with_nostr_targets<I, S>( - mut self, - target_policy: I, - policy: NostrRelayUrlPolicy, - ) -> Result<Self, RadrootsSdkError> - where - I: IntoIterator<Item = S>, - S: AsRef<str>, - { - self.target_policy = TargetPolicy::try_nostr_relays(target_policy, policy)?; - Ok(self) - } - - pub fn with_idempotency_key(mut self, idempotency_key: SdkIdempotencyKey) -> Self { - self.idempotency_key = Some(idempotency_key); - 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_inventory_sequence(mut self, inventory_sequence: u128) -> Self { - self.prepare.inventory_sequence = inventory_sequence; - self - } - - pub fn with_previous_state_root(mut self, previous_state_root: impl Into<String>) -> Self { - self.prepare.previous_state_root = Some(previous_state_root.into()); - self - } - - pub fn with_proof_mode(mut self, proof_mode: DvmProofMode) -> Self { - self.prepare.proof_mode = proof_mode; - self - } - - pub fn with_sp1_identity( - mut self, - program_hash: impl Into<String>, - verifying_key_hash: impl Into<String>, - ) -> Self { - self.prepare.sp1_program_hash = Some(program_hash.into()); - self.prepare.sp1_verifying_key_hash = Some(verifying_key_hash.into()); - self - } - - pub fn with_created_at(mut self, created_at: RadrootsSdkTimestamp) -> Self { - self.prepare.created_at = Some(created_at); - self - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct DvmTradeTransitionProofRequestPayload { - pub witness_version: u32, - pub proof_target: String, - pub listing_event_id: String, - pub request_event_id: String, - pub decision_event_id: String, - pub inventory_bins: Vec<RadrootsTradeInventoryBinWitnessDto>, - pub inventory_sequence: u128, - pub previous_state_root: Option<String>, - pub proof_mode: DvmProofMode, - pub reducer_program_hash: String, - pub radroots_protocol_version: String, - pub sp1_program_hash: Option<String>, - pub sp1_verifying_key_hash: Option<String>, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct DvmTradeTransitionProofPlan { - pub worker_pubkey: RadrootsPublicKey, - pub listing_addr: RadrootsListingAddress, - pub listing_event_id: RadrootsEventId, - pub request_event_id: RadrootsEventId, - pub decision_event_id: RadrootsEventId, - pub proof_mode: DvmProofMode, - pub expected_receipt_proof_system: RadrootsValidationReceiptProofSystem, - pub expected_event_id: RadrootsEventId, - pub frozen_draft: RadrootsEventDraft, - pub payload: DvmTradeTransitionProofRequestPayload, - pub created_at: RadrootsSdkTimestamp, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct DvmTradeTransitionProofReceipt { - pub worker_pubkey: RadrootsPublicKey, - pub listing_addr: RadrootsListingAddress, - pub listing_event_id: RadrootsEventId, - pub request_event_id: RadrootsEventId, - pub decision_event_id: RadrootsEventId, - pub expected_event_id: RadrootsEventId, - pub signed_event_id: RadrootsEventId, - pub proof_mode: DvmProofMode, - pub expected_receipt_proof_system: RadrootsValidationReceiptProofSystem, - 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 DvmValidationReceiptIngestRequest { - pub event: RadrootsSignedEvent, - pub observed_at: Option<RadrootsSdkTimestamp>, - pub expected_order_id: Option<RadrootsOrderId>, - pub expected_listing_event_id: Option<RadrootsEventId>, - pub expected_root_event_id: Option<RadrootsEventId>, - pub expected_target_event_id: Option<RadrootsEventId>, - pub projection_refresh: SyncProjectionRefreshRequest, -} - -#[cfg(feature = "runtime")] -impl DvmValidationReceiptIngestRequest { - pub fn new(event: RadrootsSignedEvent) -> Self { - Self { - event, - observed_at: None, - expected_order_id: None, - expected_listing_event_id: None, - expected_root_event_id: None, - expected_target_event_id: None, - projection_refresh: SyncProjectionRefreshRequest::new(), - } - } - - pub fn with_observed_at(mut self, observed_at: RadrootsSdkTimestamp) -> Self { - self.observed_at = Some(observed_at); - self - } - - pub fn with_expected_order_id(mut self, order_id: RadrootsOrderId) -> Self { - self.expected_order_id = Some(order_id); - self - } - - pub fn with_expected_listing_event_id(mut self, listing_event_id: RadrootsEventId) -> Self { - self.expected_listing_event_id = Some(listing_event_id); - self - } - - pub fn with_expected_root_event_id(mut self, root_event_id: RadrootsEventId) -> Self { - self.expected_root_event_id = Some(root_event_id); - self - } - - pub fn with_expected_target_event_id(mut self, target_event_id: RadrootsEventId) -> Self { - self.expected_target_event_id = Some(target_event_id); - 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 DvmValidationReceiptIngestReceipt { - pub receipt_event_id: RadrootsEventId, - pub order_id: RadrootsOrderId, - pub listing_event_id: RadrootsEventId, - pub root_event_id: RadrootsEventId, - pub target_event_id: RadrootsEventId, - pub receipt_type: RadrootsValidationReceiptType, - pub result: RadrootsValidationReceiptResult, - pub proof_system: RadrootsValidationReceiptProofSystem, - pub validation_authority: Option<RadrootsTradeValidationAuthority>, - pub commitment_confidence: RadrootsTradeCommitmentConfidence, - pub local_event_seq: i64, - pub inserted: bool, - pub refresh: SyncProjectionRefreshReceipt, -} - -#[cfg(feature = "runtime")] -impl<'sdk> DvmClient<'sdk> { - pub fn prepare_trade_transition_proof_request( - &self, - request: DvmTradeTransitionProofPrepareRequest, - ) -> Result<DvmTradeTransitionProofPlan, RadrootsSdkError> { - let created_at = match request.created_at { - Some(created_at) => created_at, - None => self.sdk.now()?, - }; - dvm_trade_transition_proof_plan(request, created_at) - } - - #[cfg(feature = "signer-adapters")] - pub async fn enqueue_trade_transition_proof_request( - &self, - request: DvmTradeTransitionProofEnqueueRequest, - ) -> Result<DvmTradeTransitionProofReceipt, RadrootsSdkError> { - let actor = request.prepare.actor.clone(); - let target_policy = request.target_policy.clone(); - let idempotency_key = request.idempotency_key.clone(); - let plan = self.prepare_trade_transition_proof_request(request.prepare)?; - let enqueue = enqueue_configured_signed_workflow( - self.sdk, - SdkWorkflowEnqueueRequest { - operation_kind: DVM_TRADE_TRANSITION_PROOF_REQUEST_OPERATION_KIND, - actor: &actor, - frozen_draft: &plan.frozen_draft, - target_policy, - satisfaction_policy: SatisfactionPolicy::AllAccepted, - idempotency_key, - }, - ) - .await?; - Ok(dvm_trade_transition_proof_receipt(plan, enqueue)) - } - - pub async fn enqueue_trade_transition_proof_request_with_explicit_signer( - &self, - request: DvmTradeTransitionProofEnqueueRequest, - signer: &dyn RadrootsEventSigner, - ) -> Result<DvmTradeTransitionProofReceipt, RadrootsSdkError> { - let actor = request.prepare.actor.clone(); - let target_policy = request.target_policy.clone(); - let idempotency_key = request.idempotency_key.clone(); - let plan = self.prepare_trade_transition_proof_request(request.prepare)?; - let enqueue = enqueue_signed_workflow( - self.sdk, - SdkWorkflowEnqueueRequest { - operation_kind: DVM_TRADE_TRANSITION_PROOF_REQUEST_OPERATION_KIND, - actor: &actor, - frozen_draft: &plan.frozen_draft, - target_policy, - satisfaction_policy: SatisfactionPolicy::AllAccepted, - idempotency_key, - }, - signer, - ) - .await?; - Ok(dvm_trade_transition_proof_receipt(plan, enqueue)) - } - - pub async fn ingest_validation_receipt( - &self, - request: DvmValidationReceiptIngestRequest, - ) -> Result<DvmValidationReceiptIngestReceipt, RadrootsSdkError> { - let verified = verify_validation_receipt_event( - request.event.envelope(), - RadrootsValidationReceiptExpectedBinding { - order_id: request - .expected_order_id - .as_ref() - .map(RadrootsOrderId::as_str), - listing_event_id: request - .expected_listing_event_id - .as_ref() - .map(RadrootsEventId::as_str), - root_event_id: request - .expected_root_event_id - .as_ref() - .map(RadrootsEventId::as_str), - target_event_id: request - .expected_target_event_id - .as_ref() - .map(RadrootsEventId::as_str), - ..RadrootsValidationReceiptExpectedBinding::default() - }, - ) - .map_err(validation_receipt_sdk_error)?; - let receipt_event_id = parse_event_id(request.event.id_str(), "receipt event id")?; - let order_id = - RadrootsOrderId::parse(verified.tags.order_id.as_str()).map_err(|error| { - RadrootsSdkError::InvalidRequest { - message: format!("validation receipt order id is invalid: {error}"), - } - })?; - let listing_event_id = RadrootsEventId::parse(verified.tags.listing_event_id.as_str()) - .expect("verified validation receipt listing event id is valid"); - let root_event_id = RadrootsEventId::parse(verified.tags.root_event_id.as_str()) - .expect("verified validation receipt root event id is valid"); - let target_event_id = RadrootsEventId::parse(verified.tags.target_event_id.as_str()) - .expect("verified validation receipt target event id is valid"); - let observed_at_ms = match request.observed_at { - Some(observed_at) => sdk_timestamp_ms(observed_at)?, - None => sdk_now_ms(self.sdk)?, - }; - let ingest = self - .sdk - ._event_store - .ingest_event(RadrootsEventIngest::new(request.event, observed_at_ms)) - .await?; - let refresh = - refresh_product_projections_for_sdk(self.sdk, request.projection_refresh).await?; - Ok(DvmValidationReceiptIngestReceipt { - receipt_event_id, - order_id, - listing_event_id, - root_event_id, - target_event_id, - receipt_type: verified.receipt.receipt_type, - result: verified.receipt.result, - proof_system: verified.receipt.proof.system, - validation_authority: None, - commitment_confidence: validation_receipt_ingest_confidence(verified.receipt.result), - local_event_seq: ingest.seq, - inserted: ingest.inserted, - refresh, - }) - } -} - -#[cfg(feature = "runtime")] -fn validation_receipt_ingest_confidence( - result: RadrootsValidationReceiptResult, -) -> RadrootsTradeCommitmentConfidence { - match result { - RadrootsValidationReceiptResult::Valid => RadrootsTradeCommitmentConfidence::LocalOnly, - RadrootsValidationReceiptResult::Invalid => RadrootsTradeCommitmentConfidence::Invalid, - } -} - -#[cfg(feature = "runtime")] -fn dvm_trade_transition_proof_plan( - request: DvmTradeTransitionProofPrepareRequest, - created_at: RadrootsSdkTimestamp, -) -> Result<DvmTradeTransitionProofPlan, RadrootsSdkError> { - validate_inventory_bins(&request.inventory_bins)?; - validate_sp1_identity(&request)?; - let payload = DvmTradeTransitionProofRequestPayload { - witness_version: RADROOTS_TRADE_TRANSITION_WITNESS_VERSION, - proof_target: RADROOTS_TRADE_TRANSITION_PROOF_TARGET.to_owned(), - listing_event_id: request.listing_event_id.as_str().to_owned(), - request_event_id: request.request_event_id.as_str().to_owned(), - decision_event_id: request.decision_event_id.as_str().to_owned(), - inventory_bins: request.inventory_bins.clone(), - inventory_sequence: request.inventory_sequence, - previous_state_root: request.previous_state_root.clone(), - proof_mode: request.proof_mode, - reducer_program_hash: RADROOTS_TRADE_TRANSITION_REDUCER_PROGRAM_HASH.to_owned(), - radroots_protocol_version: RADROOTS_TRADE_TRANSITION_PROTOCOL_VERSION.to_owned(), - sp1_program_hash: request.sp1_program_hash.clone(), - sp1_verifying_key_hash: request.sp1_verifying_key_hash.clone(), - }; - let content = serde_json::to_string(&payload).expect("DVM proof request payload serializes"); - let mut tags = vec![ - vec!["a".to_owned(), request.listing_addr.as_str().to_owned()], - vec!["p".to_owned(), request.worker_pubkey.as_str().to_owned()], - vec![ - "i".to_owned(), - request.decision_event_id.as_str().to_owned(), - "event".to_owned(), - "radroots:order_decision_event".to_owned(), - ], - ]; - canonicalize_tags(&mut tags); - let parts = RadrootsNip01EventWireParts { - kind: KIND_TRADE_TRANSITION_PROOF_REQUEST, - content, - tags, - }; - let frozen_draft = RadrootsEventDraft::new( - DVM_TRADE_TRANSITION_PROOF_REQUEST_CONTRACT_ID, - parts.kind, - u64::from(created_at.try_into_nostr_created_at()?), - parts.tags, - parts.content, - request.actor.pubkey().as_str(), - ) - .expect("DVM proof request draft is valid"); - authorize_actor_for_draft(&request.actor, &frozen_draft)?; - let expected_event_id = RadrootsEventId::parse(frozen_draft.expected_event_id_str()) - .expect("frozen DVM proof request draft produces a valid event id"); - Ok(DvmTradeTransitionProofPlan { - worker_pubkey: request.worker_pubkey, - listing_addr: request.listing_addr, - listing_event_id: request.listing_event_id, - request_event_id: request.request_event_id, - decision_event_id: request.decision_event_id, - proof_mode: request.proof_mode, - expected_receipt_proof_system: request.proof_mode.proof_system(), - expected_event_id, - frozen_draft, - payload, - created_at, - }) -} - -#[cfg(feature = "runtime")] -fn validate_inventory_bins( - inventory_bins: &[RadrootsTradeInventoryBinWitnessDto], -) -> Result<(), RadrootsSdkError> { - if inventory_bins.is_empty() { - return Err(RadrootsSdkError::InvalidRequest { - message: "DVM proof request inventory bins cannot be empty".to_owned(), - }); - } - for bin in inventory_bins { - if bin.bin_id.as_str().trim().is_empty() { - return Err(RadrootsSdkError::InvalidRequest { - message: "DVM proof request inventory bin id cannot be empty".to_owned(), - }); - } - if bin.previous_reserved > bin.listing_capacity { - return Err(RadrootsSdkError::InvalidRequest { - message: format!( - "DVM proof request inventory bin `{}` previous_reserved exceeds listing_capacity", - bin.bin_id.as_str() - ), - }); - } - } - Ok(()) -} - -#[cfg(feature = "runtime")] -fn validate_sp1_identity( - request: &DvmTradeTransitionProofPrepareRequest, -) -> Result<(), RadrootsSdkError> { - validate_optional_hash32(&request.previous_state_root, "previous_state_root")?; - validate_optional_hash32(&request.sp1_program_hash, "sp1_program_hash")?; - validate_optional_hash32(&request.sp1_verifying_key_hash, "sp1_verifying_key_hash")?; - let has_sp1_identity = - request.sp1_program_hash.is_some() || request.sp1_verifying_key_hash.is_some(); - if request.proof_mode == DvmProofMode::None && has_sp1_identity { - return Err(RadrootsSdkError::InvalidRequest { - message: "DVM proof mode none cannot include SP1 identity hashes".to_owned(), - }); - } - if request.proof_mode.requires_sp1_identity() - && (request.sp1_program_hash.is_none() || request.sp1_verifying_key_hash.is_none()) - { - return Err(RadrootsSdkError::InvalidRequest { - message: format!( - "DVM proof mode {} requires SP1 program and verifying key hashes", - request.proof_mode.as_str() - ), - }); - } - Ok(()) -} - -#[cfg(feature = "runtime")] -fn validate_optional_hash32( - value: &Option<String>, - field: &'static str, -) -> Result<(), RadrootsSdkError> { - if let Some(value) = value { - let hash = value.as_str(); - if hash.len() != 66 || !hash.starts_with("0x") || !is_lower_hex(&hash[2..]) { - return Err(RadrootsSdkError::InvalidRequest { - message: format!("DVM proof request {field} must be 0x-prefixed lowercase hex32"), - }); - } - } - Ok(()) -} - -#[cfg(feature = "runtime")] -fn is_lower_hex(value: &str) -> bool { - value - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) -} - -#[cfg(feature = "runtime")] -fn dvm_trade_transition_proof_receipt( - plan: DvmTradeTransitionProofPlan, - enqueue: crate::workflow_runtime::SdkWorkflowEnqueueReceipt, -) -> DvmTradeTransitionProofReceipt { - DvmTradeTransitionProofReceipt { - worker_pubkey: plan.worker_pubkey, - listing_addr: plan.listing_addr, - listing_event_id: plan.listing_event_id, - request_event_id: plan.request_event_id, - decision_event_id: plan.decision_event_id, - expected_event_id: plan.expected_event_id, - signed_event_id: enqueue.signed_event_id, - proof_mode: plan.proof_mode, - expected_receipt_proof_system: plan.expected_receipt_proof_system, - 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 = "runtime")] -fn parse_event_id(value: &str, field: &'static str) -> Result<RadrootsEventId, RadrootsSdkError> { - RadrootsEventId::parse(value).map_err(|error| RadrootsSdkError::InvalidRequest { - message: format!("{field} is invalid: {error}"), - }) -} - -#[cfg(feature = "runtime")] -fn validation_receipt_sdk_error( - error: radroots_trade::validation_receipt::RadrootsValidationReceiptError, -) -> RadrootsSdkError { - RadrootsSdkError::InvalidRequest { - message: format!("validation receipt event is invalid: {error}"), - } -} - -#[cfg(feature = "runtime")] -fn sdk_timestamp_ms(timestamp: RadrootsSdkTimestamp) -> Result<i64, RadrootsSdkError> { - let seconds = i64::try_from(timestamp.unix_seconds()).map_err(|_| { - RadrootsSdkError::TimestampOutOfRange { - value: timestamp.unix_seconds(), - } - })?; - seconds - .checked_mul(1_000) - .ok_or(RadrootsSdkError::TimestampOutOfRange { - value: timestamp.unix_seconds(), - }) -} - -#[cfg(all(test, feature = "runtime"))] -#[path = "../tests/unit/dvm_runtime_tests.rs"] -mod tests; diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs @@ -18,7 +18,6 @@ pub enum RadrootsSdkErrorClass { Authorization, Clock, Configuration, - LocalMutation, Request, Storage, Transport, @@ -30,7 +29,6 @@ pub enum RadrootsSdkErrorClass { #[serde(rename_all = "snake_case")] #[non_exhaustive] pub enum RadrootsSdkRecoveryAction { - RetryOutboxEnqueue, InspectLocalStores, InspectGeoNamesAsset, RetryOperationWithSameIdempotencyKey, @@ -60,27 +58,6 @@ pub enum RadrootsSdkGeoNamesErrorKind { } #[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum RadrootsSdkPartialLocalMutationFailure { - OutboxEnqueue, - OutboxIdempotencyConflict, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct RadrootsSdkPartialLocalMutationError { - pub event_id: Option<String>, - pub operation_kind: String, - pub idempotency_digest_prefix: Option<String>, - pub stored: bool, - pub queued: bool, - pub recovery: RadrootsSdkRecoveryAction, - pub failure: RadrootsSdkPartialLocalMutationFailure, -} - -#[cfg(feature = "runtime")] #[derive(Debug)] #[non_exhaustive] pub enum RadrootsSdkError { @@ -156,7 +133,7 @@ pub enum RadrootsSdkError { }, TradeAmbiguous { operation: String, - locator: RadrootsTradeLocator, + locator: Box<RadrootsTradeLocator>, candidates: Vec<RadrootsTradeLocator>, }, PrivacyPreflight { @@ -185,7 +162,11 @@ pub enum RadrootsSdkError { InvalidRequest { message: String, }, - ListingDraft { + UnsupportedProfileSchema { + path: PathBuf, + message: String, + }, + ListingEdit { message: String, }, ListingMutation { @@ -197,6 +178,9 @@ pub enum RadrootsSdkError { PrivateStore { message: String, }, + StudioStore { + message: String, + }, GeoNames { kind: RadrootsSdkGeoNamesErrorKind, message: String, @@ -207,7 +191,6 @@ pub enum RadrootsSdkError { Projection { message: String, }, - PartialLocalMutation(RadrootsSdkPartialLocalMutationError), } #[cfg(feature = "runtime")] @@ -247,10 +230,12 @@ impl RadrootsSdkError { Self::Authority { .. } => "authority", Self::EventStore { .. } => "event_store", Self::InvalidRequest { .. } => "invalid_request", - Self::ListingDraft { .. } => "listing_draft", + Self::UnsupportedProfileSchema { .. } => "unsupported_profile_schema", + Self::ListingEdit { .. } => "listing_edit", Self::ListingMutation { .. } => "listing_mutation", Self::Outbox { .. } => "outbox", Self::PrivateStore { .. } => "private_store", + Self::StudioStore { .. } => "studio_store", Self::GeoNames { kind, .. } => match kind { RadrootsSdkGeoNamesErrorKind::Configuration => "geonames_configuration", RadrootsSdkGeoNamesErrorKind::Download => "geonames_download", @@ -261,7 +246,6 @@ impl RadrootsSdkError { }, Self::Transport { .. } => "transport", Self::Projection { .. } => "projection", - Self::PartialLocalMutation(_) => "partial_local_mutation", } } @@ -271,6 +255,7 @@ impl RadrootsSdkError { | Self::EventStore { .. } | Self::Outbox { .. } | Self::PrivateStore { .. } + | Self::StudioStore { .. } | Self::Projection { .. } => RadrootsSdkErrorClass::Storage, Self::GeoNames { kind, .. } => match kind { RadrootsSdkGeoNamesErrorKind::Configuration => RadrootsSdkErrorClass::Configuration, @@ -300,7 +285,8 @@ impl RadrootsSdkError { | Self::SignerProtocol { .. } | Self::SignerAuthChallengePending { .. } | Self::InvalidRequest { .. } - | Self::ListingDraft { .. } + | Self::UnsupportedProfileSchema { .. } + | Self::ListingEdit { .. } | Self::ListingMutation { .. } => RadrootsSdkErrorClass::Request, Self::ProductSyncUnsupported { .. } | Self::ReticulumPreviewTransportUnavailable { .. } => { @@ -310,7 +296,6 @@ impl RadrootsSdkError { | Self::Transport { .. } | Self::SignerRequestTimedOut { .. } | Self::SignerTransport { .. } => RadrootsSdkErrorClass::Transport, - Self::PartialLocalMutation(_) => RadrootsSdkErrorClass::LocalMutation, } } @@ -322,6 +307,7 @@ impl RadrootsSdkError { | Self::EventStore { .. } | Self::Outbox { .. } | Self::PrivateStore { .. } + | Self::StudioStore { .. } | Self::GeoNames { kind: RadrootsSdkGeoNamesErrorKind::Cache | RadrootsSdkGeoNamesErrorKind::Download, @@ -331,7 +317,6 @@ impl RadrootsSdkError { | Self::SignerRequestTimedOut { .. } | Self::SignerTransport { .. } | Self::Projection { .. } - | Self::PartialLocalMutation(_) ) } @@ -341,6 +326,7 @@ impl RadrootsSdkError { | Self::EventStore { .. } | Self::Outbox { .. } | Self::PrivateStore { .. } + | Self::StudioStore { .. } | Self::Projection { .. } => vec![RadrootsSdkRecoveryAction::InspectLocalStores], Self::GeoNames { kind, .. } => match kind { RadrootsSdkGeoNamesErrorKind::Configuration => { @@ -372,6 +358,9 @@ impl RadrootsSdkError { } Self::TradeAmbiguous { .. } => vec![RadrootsSdkRecoveryAction::SelectTradeRoot], Self::PrivacyPreflight { .. } => vec![RadrootsSdkRecoveryAction::FixRequest], + Self::UnsupportedProfileSchema { .. } => { + vec![RadrootsSdkRecoveryAction::InspectLocalStores] + } Self::ProductSyncUnsupported { .. } => { vec![RadrootsSdkRecoveryAction::EnableRequiredFeature] } @@ -387,14 +376,13 @@ impl RadrootsSdkError { Self::SignerAuthChallengePending { .. } => { vec![RadrootsSdkRecoveryAction::CompleteSignerAuthentication] } - Self::PartialLocalMutation(error) => vec![error.recovery], Self::ClockBeforeUnixEpoch | Self::TimestampOutOfRange { .. } | Self::TradeStatusLimitInvalid { .. } | Self::InvalidTradeId { .. } | Self::SignerProtocol { .. } | Self::InvalidRequest { .. } - | Self::ListingDraft { .. } + | Self::ListingEdit { .. } | Self::ListingMutation { .. } => vec![RadrootsSdkRecoveryAction::FixRequest], } } @@ -488,14 +476,17 @@ impl RadrootsSdkError { | Self::Authority { message } | Self::EventStore { message } | Self::InvalidRequest { message } - | Self::ListingDraft { message } + | Self::ListingEdit { message } | Self::ListingMutation { message } | Self::Outbox { message } | Self::PrivateStore { message } + | Self::StudioStore { message } | Self::Transport { message } | Self::Projection { message } => json!({ "message": message }), + Self::UnsupportedProfileSchema { path, message } => { + json!({ "path": path.display().to_string(), "message": message }) + } Self::GeoNames { kind, message } => json!({ "kind": kind, "message": message }), - Self::PartialLocalMutation(error) => json!(error), }; json!({ "code": self.code(), @@ -507,42 +498,6 @@ impl RadrootsSdkError { }) } - pub fn partial_local_mutation(error: RadrootsSdkPartialLocalMutationError) -> Self { - Self::PartialLocalMutation(error) - } - - pub fn partial_outbox_enqueue_mutation( - event_id: impl Into<String>, - operation_kind: impl Into<String>, - idempotency_digest_prefix: impl Into<String>, - ) -> Self { - Self::PartialLocalMutation(RadrootsSdkPartialLocalMutationError { - event_id: Some(event_id.into()), - operation_kind: operation_kind.into(), - idempotency_digest_prefix: Some(idempotency_digest_prefix.into()), - stored: true, - queued: false, - recovery: RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey, - failure: RadrootsSdkPartialLocalMutationFailure::OutboxEnqueue, - }) - } - - pub fn partial_outbox_idempotency_conflict_mutation( - event_id: impl Into<String>, - operation_kind: impl Into<String>, - idempotency_digest_prefix: impl Into<String>, - ) -> Self { - Self::PartialLocalMutation(RadrootsSdkPartialLocalMutationError { - event_id: Some(event_id.into()), - operation_kind: operation_kind.into(), - idempotency_digest_prefix: Some(idempotency_digest_prefix.into()), - stored: true, - queued: false, - recovery: RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey, - failure: RadrootsSdkPartialLocalMutationFailure::OutboxIdempotencyConflict, - }) - } - pub(crate) fn empty_transport_targets(operation: impl Into<String>) -> Self { Self::EmptyTransportTargets { operation: operation.into(), @@ -700,12 +655,18 @@ impl fmt::Display for RadrootsSdkError { Self::Authority { message } => write!(f, "sdk authority error: {message}"), Self::EventStore { message } => write!(f, "sdk event store error: {message}"), Self::InvalidRequest { message } => write!(f, "sdk invalid request: {message}"), - Self::ListingDraft { message } => write!(f, "sdk listing draft error: {message}"), + Self::UnsupportedProfileSchema { path, message } => write!( + f, + "sdk unsupported profile schema at `{}`: {message}", + path.display() + ), + Self::ListingEdit { message } => write!(f, "sdk listing edit error: {message}"), Self::ListingMutation { message } => { write!(f, "sdk listing mutation error: {message}") } Self::Outbox { message } => write!(f, "sdk outbox error: {message}"), Self::PrivateStore { message } => write!(f, "sdk private store error: {message}"), + Self::StudioStore { message } => write!(f, "sdk studio store error: {message}"), Self::GeoNames { kind, message } => { write!(f, "sdk GeoNames {kind:?} error: {message}") } @@ -713,20 +674,6 @@ impl fmt::Display for RadrootsSdkError { write!(f, "sdk transport error: {message}") } Self::Projection { message } => write!(f, "sdk projection error: {message}"), - Self::PartialLocalMutation(error) => write!( - f, - "sdk local mutation partially completed: event_id={}, operation_kind={}, idempotency_digest_prefix={}, stored={}, queued={}, failure={:?}, recovery={:?}", - error.event_id.as_deref().unwrap_or("<unknown>"), - error.operation_kind, - error - .idempotency_digest_prefix - .as_deref() - .unwrap_or("<none>"), - error.stored, - error.queued, - error.failure, - error.recovery - ), } } } @@ -818,16 +765,16 @@ impl From<radroots_geocoder::GeocoderError> for RadrootsSdkError { } #[cfg(feature = "runtime")] -impl From<radroots_trade::listing::RadrootsListingDraftError> for RadrootsSdkError { - fn from(error: radroots_trade::listing::RadrootsListingDraftError) -> Self { +impl From<radroots_trade::listing::RadrootsListingEditError> for RadrootsSdkError { + fn from(error: radroots_trade::listing::RadrootsListingEditError) -> Self { match error { - radroots_trade::listing::RadrootsListingDraftError::ActorRoleUnsatisfied { + radroots_trade::listing::RadrootsListingEditError::ActorRoleUnsatisfied { required_role, } => Self::UnauthorizedActor { operation: "listing.prepare_publish".to_owned(), reason: format!("missing role {required_role:?}"), }, - error => Self::ListingDraft { + error => Self::ListingEdit { message: error.to_string(), }, } diff --git a/crates/sdk/src/farms_runtime.rs b/crates/sdk/src/farms_runtime.rs @@ -103,7 +103,7 @@ impl FarmEnqueuePublishRequest { } pub fn with_idempotency_key(mut self, idempotency_key: SdkIdempotencyKey) -> Self { - self.idempotency_key = Some(idempotency_key.into()); + self.idempotency_key = Some(idempotency_key); self } @@ -1017,6 +1017,6 @@ fn farm_private_location_candidate( } } -#[cfg(all(test, feature = "runtime"))] +#[cfg(all(test, feature = "runtime", feature = "signer-adapters"))] #[path = "../tests/unit/farms_runtime_tests.rs"] mod tests; diff --git a/crates/sdk/src/idempotency.rs b/crates/sdk/src/idempotency.rs @@ -2,7 +2,6 @@ use crate::RadrootsSdkError; use core::fmt; use radroots_event::ids::{RadrootsEventId, RadrootsPublicKey}; use serde::ser::SerializeStruct; -use sha2::{Digest, Sha256}; pub const SDK_IDEMPOTENCY_KEY_MAX_LEN: usize = 256; @@ -30,6 +29,9 @@ impl SdkIdempotencyKey { "idempotency key must not contain control characters", )); } + if !is_uuid_v7(value) { + return Err(invalid_request("idempotency key must be a UUIDv7")); + } Ok(Self(value.to_owned())) } @@ -40,22 +42,6 @@ impl SdkIdempotencyKey { pub fn into_string(self) -> String { self.0 } - - pub(crate) fn derive( - operation_kind: &'static str, - expected_event_id: &str, - expected_pubkey: &str, - ) -> Self { - let input = SdkIdempotencyDerivationInput { - operation_kind, - expected_event_id, - expected_pubkey, - }; - let bytes = serde_json::to_vec(&input).expect("idempotency derivation input serializes"); - let digest = hex::encode(Sha256::digest(bytes)); - Self::new(format!("{operation_kind}:{digest}")) - .expect("derived idempotency key satisfies SDK validation") - } } impl fmt::Debug for SdkIdempotencyKey { @@ -79,13 +65,6 @@ impl serde::Serialize for SdkIdempotencyKey { } } -#[derive(serde::Serialize)] -struct SdkIdempotencyDerivationInput<'a> { - operation_kind: &'static str, - expected_event_id: &'a str, - expected_pubkey: &'a str, -} - #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct SdkTradeIdempotencyRecord { pub idempotency_key: SdkIdempotencyKey, @@ -118,6 +97,21 @@ fn invalid_request(message: impl Into<String>) -> RadrootsSdkError { } } +fn is_uuid_v7(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == 36 + && bytes[8] == b'-' + && bytes[13] == b'-' + && bytes[18] == b'-' + && bytes[23] == b'-' + && bytes[14] == b'7' + && matches!(bytes[19], b'8' | b'9' | b'a' | b'b') + && bytes + .iter() + .enumerate() + .all(|(index, byte)| matches!(index, 8 | 13 | 18 | 23) || byte.is_ascii_hexdigit()) +} + #[cfg(test)] #[path = "../tests/unit/idempotency_tests.rs"] mod tests; diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -14,13 +14,14 @@ mod actor_json; ))] pub mod adapters; #[cfg(feature = "runtime")] -mod dvm_runtime; -#[cfg(feature = "runtime")] mod error; #[cfg(feature = "runtime")] mod farm; #[cfg(feature = "runtime")] mod farms_runtime; +#[cfg(test)] +#[path = "../tests/support/fixture_signer.rs"] +pub(crate) mod fixture_signer; #[cfg(feature = "runtime")] mod geonames; #[cfg(feature = "runtime")] @@ -45,9 +46,14 @@ mod private_store; mod product_clients; #[cfg(feature = "runtime")] mod runtime; +#[cfg(test)] +#[path = "../tests/support/serializer_failure.rs"] +pub(crate) mod serializer_failure; #[cfg(all(feature = "runtime", feature = "signer-adapters"))] mod signer_provider; #[cfg(feature = "runtime")] +mod studio_store; +#[cfg(feature = "runtime")] mod sync_runtime; #[cfg(feature = "runtime")] mod trade_storage; @@ -59,18 +65,8 @@ mod workflow_runtime; pub use radroots_runtime_contract_v1 as runtime_contract_v1; #[cfg(feature = "runtime")] -pub use crate::dvm_runtime::{ - DVM_TRADE_TRANSITION_PROOF_REQUEST_CONTRACT_ID, - DVM_TRADE_TRANSITION_PROOF_REQUEST_OPERATION_KIND, DvmProofMode, - DvmTradeTransitionProofEnqueueRequest, DvmTradeTransitionProofPlan, - DvmTradeTransitionProofPrepareRequest, DvmTradeTransitionProofReceipt, - DvmTradeTransitionProofRequestPayload, DvmValidationReceiptIngestReceipt, - DvmValidationReceiptIngestRequest, -}; -#[cfg(feature = "runtime")] pub use crate::error::{ RadrootsSdkError, RadrootsSdkErrorClass, RadrootsSdkGeoNamesErrorKind, - RadrootsSdkPartialLocalMutationError, RadrootsSdkPartialLocalMutationFailure, RadrootsSdkRecoveryAction, }; #[cfg(feature = "runtime")] @@ -166,20 +162,16 @@ pub use crate::orders_runtime::{ TradeValidationReceiptNostrEvidenceReceipt, TradeValidationReceiptNostrRelayOutcomeKind, TradeValidationReceiptNostrRelayOutcomeReceipt, TradeValidationReceiptNostrRelayTransportOutcomeKind, TradeValidationReceiptTags, - TradeValidationReceiptVerifyRequest, TradeValidationReceiptWorkerEvidence, - TradeValidationReceiptWorkerEvidenceSelection, TradeValidationTrustDecision, + TradeValidationReceiptVerifyRequest, TradeValidationTrustDecision, }; #[cfg(all(feature = "runtime", feature = "signer-adapters"))] pub use crate::orders_runtime::{ - TRADE_CANCELLATION_OPERATION_KIND, TRADE_DECISION_OPERATION_KIND, - TRADE_REVISION_DECISION_OPERATION_KIND, TRADE_REVISION_PROPOSAL_OPERATION_KIND, - TRADE_SUBMIT_OPERATION_KIND, TradeAcceptRequest, TradeCancelRequest, TradeCancellationPlan, - TradeCancellationReceipt, TradeDecisionPlan, TradeDecisionReceipt, TradeDeclineRequest, - TradeEvidenceMode, TradeMutationOutcome, TradeProposeRequest, TradeRevisionDecisionPlan, - TradeRevisionDecisionReceipt, TradeRevisionDecisionRequest, TradeRevisionProposalPlan, - TradeRevisionProposalReceipt, TradeRevisionProposalRequest, TradeSubmitPlan, - TradeSubmitReceipt, TradeWorkflowEnqueueReceipt, TradeWorkflowIdempotencyReceipt, - TradeWorkflowKind, TradeWorkflowPlan, TradeWorkflowRetryAdvice, + 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::{ @@ -190,18 +182,19 @@ pub use crate::privacy::{ pub use crate::product_clients::TradeBuyerClient; #[cfg(feature = "runtime")] pub use crate::product_clients::{ - DvmClient, FarmsClient, GeoNamesClient, ListingsClient, MarketClient, SyncClient, - TradeResyncClient, TradeSellerClient, TradeValidationReceiptsClient, TradesClient, + FarmsClient, GeoNamesClient, ListingsClient, MarketClient, SyncClient, TradeResyncClient, + TradeSellerClient, TradeValidationReceiptsClient, TradesClient, }; #[cfg(feature = "runtime")] pub use crate::runtime::{ - 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, StorageCheckpointReceipt, - StorageCheckpointRequest, StorageStatusReceipt, StorageStatusRequest, + BackupReceipt, BackupRequest, IntegrityReceipt, IntegrityRequest, QuarantineResetReceipt, + QuarantineResetRequest, 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, }; #[cfg(all(feature = "runtime", feature = "signer-adapters"))] pub use crate::signer_provider::{ @@ -241,8 +234,6 @@ pub use crate::transport::{ TargetSet, TransportProfile, TransportReceipt, }; #[cfg(feature = "runtime")] -pub use radroots_trade::dvm::RadrootsTradeInventoryBinWitnessDto; -#[cfg(feature = "runtime")] pub use radroots_trade::validation_receipt::{ RadrootsTradeCommitmentConfidence, RadrootsTradeValidationAuthority, RadrootsTradeValidationTrustPolicy, RadrootsTradeValidationTrustState, diff --git a/crates/sdk/src/listings_runtime.rs b/crates/sdk/src/listings_runtime.rs @@ -18,8 +18,8 @@ use radroots_event::{ use radroots_outbox::RadrootsOutboxEnqueueStatus; #[cfg(feature = "runtime")] use radroots_trade::listing::{ - RadrootsCanonicalListingDraft, RadrootsListingDraftDocumentV1, RadrootsListingMutation, - build_listing_mutation_draft, canonicalize_listing_draft, + RadrootsCanonicalListingEdit, RadrootsListingEditDocumentV1, RadrootsListingMutation, + build_listing_mutation_draft, canonicalize_listing_edit, }; #[cfg(feature = "runtime")] pub const LISTING_PUBLISH_OPERATION_KIND: &str = "listing.publish.v1"; @@ -30,7 +30,7 @@ pub const LISTING_PUBLISH_OPERATION_KIND: &str = "listing.publish.v1"; pub struct ListingPreparePublishRequest { #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] pub actor: RadrootsActorContext, - pub document: RadrootsListingDraftDocumentV1, + pub document: RadrootsListingEditDocumentV1, pub created_at: Option<RadrootsSdkTimestamp>, } @@ -39,14 +39,14 @@ impl ListingPreparePublishRequest { pub fn new(actor: RadrootsActorContext, listing: RadrootsListing) -> Self { Self { actor, - document: RadrootsListingDraftDocumentV1::new(listing), + document: RadrootsListingEditDocumentV1::new(listing), created_at: None, } } pub fn from_document( actor: RadrootsActorContext, - document: RadrootsListingDraftDocumentV1, + document: RadrootsListingEditDocumentV1, ) -> Self { Self { actor, @@ -67,7 +67,7 @@ impl ListingPreparePublishRequest { pub struct ListingEnqueuePublishRequest { #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] pub actor: RadrootsActorContext, - pub document: RadrootsListingDraftDocumentV1, + pub document: RadrootsListingEditDocumentV1, pub target_policy: TargetPolicy, pub idempotency_key: Option<SdkIdempotencyKey>, pub created_at: Option<RadrootsSdkTimestamp>, @@ -82,14 +82,14 @@ impl ListingEnqueuePublishRequest { ) -> Self { Self::from_document( actor, - RadrootsListingDraftDocumentV1::new(listing), + RadrootsListingEditDocumentV1::new(listing), target_policy, ) } pub fn from_document( actor: RadrootsActorContext, - document: RadrootsListingDraftDocumentV1, + document: RadrootsListingEditDocumentV1, target_policy: TargetPolicy, ) -> Self { Self { @@ -115,7 +115,7 @@ impl ListingEnqueuePublishRequest { } pub fn with_idempotency_key(mut self, idempotency_key: SdkIdempotencyKey) -> Self { - self.idempotency_key = Some(idempotency_key.into()); + self.idempotency_key = Some(idempotency_key); self } @@ -137,7 +137,6 @@ impl ListingEnqueuePublishRequest { #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct ListingPublishPlan { pub public_listing_addr: RadrootsListingAddress, - pub draft_listing_addr: RadrootsListingAddress, pub expected_event_id: RadrootsEventId, pub frozen_draft: RadrootsEventDraft, pub created_at: RadrootsSdkTimestamp, @@ -166,7 +165,6 @@ impl From<RadrootsOutboxEnqueueStatus> for SdkMutationState { #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct ListingEnqueueReceipt { pub public_listing_addr: RadrootsListingAddress, - pub draft_listing_addr: RadrootsListingAddress, pub expected_event_id: RadrootsEventId, pub signed_event_id: RadrootsEventId, pub local_event_seq: i64, @@ -301,7 +299,6 @@ fn listing_enqueue_receipt( ) -> ListingEnqueueReceipt { ListingEnqueueReceipt { public_listing_addr: plan.public_listing_addr, - draft_listing_addr: plan.draft_listing_addr, expected_event_id: plan.expected_event_id, signed_event_id: enqueue.signed_event_id, local_event_seq: enqueue.local_event_seq, @@ -313,36 +310,34 @@ fn listing_enqueue_receipt( } #[cfg(feature = "runtime")] -fn canonical_listing_draft( +fn canonical_listing_edit( actor: &RadrootsActorContext, - document: RadrootsListingDraftDocumentV1, -) -> Result<RadrootsCanonicalListingDraft, RadrootsSdkError> { - canonicalize_listing_draft(actor, document).map_err(Into::into) + document: RadrootsListingEditDocumentV1, +) -> Result<RadrootsCanonicalListingEdit, RadrootsSdkError> { + canonicalize_listing_edit(actor, document).map_err(Into::into) } #[cfg(feature = "runtime")] fn listing_publish_plan( actor: &RadrootsActorContext, - document: RadrootsListingDraftDocumentV1, + document: RadrootsListingEditDocumentV1, created_at: RadrootsSdkTimestamp, ) -> Result<ListingPublishPlan, RadrootsSdkError> { let created_at_nostr = created_at.try_into_nostr_created_at()?; - let canonical = canonical_listing_draft(actor, document)?; + let canonical = canonical_listing_edit(actor, document)?; let public_listing_addr = canonical.public_listing_addr().clone(); - let draft_listing_addr = canonical.draft_listing_addr().clone(); let mutation = RadrootsListingMutation::publish(canonical); let frozen_draft = build_listing_mutation_draft(&mutation, u64::from(created_at_nostr))?; let expected_event_id = RadrootsEventId::parse(frozen_draft.expected_event_id_str()) - .expect("frozen listing draft produces a valid event id"); + .expect("frozen listing edit produces a valid event id"); Ok(ListingPublishPlan { public_listing_addr, - draft_listing_addr, expected_event_id, frozen_draft, created_at, }) } -#[cfg(all(test, feature = "runtime"))] +#[cfg(all(test, feature = "runtime", feature = "signer-adapters"))] #[path = "../tests/unit/listings_runtime_tests.rs"] mod tests; diff --git a/crates/sdk/src/order.rs b/crates/sdk/src/order.rs @@ -1,80 +1,54 @@ pub use radroots_event::order::*; -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] pub use radroots_event_codec::error::EventEncodeError; pub use radroots_event_codec::order::RadrootsOrderEnvelopeParseError; use radroots_event::RadrootsEventEnvelope; -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] use radroots_event::wire::RadrootsNip01EventWireParts; -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] use radroots_event::{RadrootsEventPtr, ids::RadrootsEventId, tags::TAG_E}; -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] #[derive(Debug, Clone)] pub struct RadrootsOrderRequestDraft { parts: RadrootsNip01EventWireParts, } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] #[derive(Debug, Clone)] pub struct RadrootsOrderDecisionDraft { parts: RadrootsNip01EventWireParts, } -#[cfg(any(feature = "signer-adapters", test))] -#[derive(Debug, Clone)] -pub struct RadrootsOrderRevisionProposalDraft { - parts: RadrootsNip01EventWireParts, -} - -#[cfg(any(feature = "signer-adapters", test))] -#[derive(Debug, Clone)] -pub struct RadrootsOrderRevisionDecisionDraft { - parts: RadrootsNip01EventWireParts, -} - -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] #[derive(Debug, Clone)] pub struct RadrootsOrderCancellationDraft { parts: RadrootsNip01EventWireParts, } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] impl RadrootsOrderRequestDraft { pub fn into_wire_parts(self) -> RadrootsNip01EventWireParts { self.parts } } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] impl RadrootsOrderDecisionDraft { pub fn into_wire_parts(self) -> RadrootsNip01EventWireParts { self.parts } } -#[cfg(any(feature = "signer-adapters", test))] -impl RadrootsOrderRevisionProposalDraft { - pub fn into_wire_parts(self) -> RadrootsNip01EventWireParts { - self.parts - } -} - -#[cfg(any(feature = "signer-adapters", test))] -impl RadrootsOrderRevisionDecisionDraft { - pub fn into_wire_parts(self) -> RadrootsNip01EventWireParts { - self.parts - } -} - -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] impl RadrootsOrderCancellationDraft { pub fn into_wire_parts(self) -> RadrootsNip01EventWireParts { self.parts } } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] fn with_contract_root_event_tag( mut parts: RadrootsNip01EventWireParts, root_event_id: &RadrootsEventId, @@ -85,7 +59,7 @@ fn with_contract_root_event_tag( parts } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] pub fn build_order_request_draft( listing_event: &RadrootsEventPtr, payload: &RadrootsOrderRequest, @@ -95,7 +69,7 @@ pub fn build_order_request_draft( }) } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] pub fn build_order_decision_draft( root_event_id: &RadrootsEventId, prev_event_id: &RadrootsEventId, @@ -113,43 +87,7 @@ pub fn build_order_decision_draft( }) } -#[cfg(any(feature = "signer-adapters", test))] -pub fn build_order_revision_proposal_draft( - root_event_id: &RadrootsEventId, - prev_event_id: &RadrootsEventId, - payload: &RadrootsOrderRevisionProposal, -) -> Result<RadrootsOrderRevisionProposalDraft, EventEncodeError> { - Ok(RadrootsOrderRevisionProposalDraft { - parts: with_contract_root_event_tag( - radroots_event_codec::order::order_revision_proposal_event_build( - root_event_id, - prev_event_id, - payload, - )?, - root_event_id, - ), - }) -} - -#[cfg(any(feature = "signer-adapters", test))] -pub fn build_order_revision_decision_draft( - root_event_id: &RadrootsEventId, - prev_event_id: &RadrootsEventId, - payload: &RadrootsOrderRevisionDecision, -) -> Result<RadrootsOrderRevisionDecisionDraft, EventEncodeError> { - Ok(RadrootsOrderRevisionDecisionDraft { - parts: with_contract_root_event_tag( - radroots_event_codec::order::order_revision_decision_event_build( - root_event_id, - prev_event_id, - payload, - )?, - root_event_id, - ), - }) -} - -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] pub fn build_order_cancellation_draft( root_event_id: &RadrootsEventId, prev_event_id: &RadrootsEventId, diff --git a/crates/sdk/src/orders_runtime.rs b/crates/sdk/src/orders_runtime.rs @@ -8,9 +8,7 @@ use crate::sync_runtime::SyncProjectionRefreshReceipt; use crate::sync_runtime::{SyncProjectionRefreshRequest, refresh_product_projections_for_sdk}; #[cfg(feature = "signer-adapters")] use crate::workflow_runtime::enqueue_configured_signed_workflow; -#[cfg(all(feature = "runtime", test))] -use crate::{NostrRelayUrlPolicy, workflow_runtime::enqueue_signed_workflow}; -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] use crate::{ PrivacyPreflightConfirmation, PrivacyPreflightReceipt, ProductSensitivityField, PublishMode, PushOutboxReceipt, PushOutboxRequest, RadrootsSdkRecoveryAction, SatisfactionPolicy, @@ -23,9 +21,7 @@ use crate::{ }; #[cfg(feature = "runtime")] use radroots_authority::RadrootsActorContext; -#[cfg(all(feature = "runtime", test))] -use radroots_authority::RadrootsEventSigner; -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] use radroots_event::wire::RadrootsNip01EventWireParts; #[cfg(feature = "runtime")] use radroots_event::{ @@ -35,20 +31,17 @@ use radroots_event::{ ids::RadrootsEventId, kinds::{ KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST, - KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL, - KIND_TRADE_TRANSITION_PROOF_RESULT, KIND_TRADE_VALIDATION_RECEIPT, + KIND_TRADE_VALIDATION_RECEIPT, }, tags::TAG_P, }; -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] use radroots_event::{ RadrootsEventPtr, draft::RadrootsEventDraft, - ids::RadrootsOrderRevisionId, order::{ RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome, RadrootsOrderInventoryCommitment, RadrootsOrderItem, RadrootsOrderRequest, - RadrootsOrderRevisionDecision, RadrootsOrderRevisionOutcome, RadrootsOrderRevisionProposal, }, }; #[cfg(any(feature = "runtime", feature = "signer-adapters", test))] @@ -64,7 +57,6 @@ use radroots_event::{ #[cfg(feature = "runtime")] use radroots_event_codec::order::{ order_cancellation_from_event, order_decision_from_event, order_request_from_event, - order_revision_decision_from_event, order_revision_proposal_from_event, }; #[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] use radroots_event_store::RadrootsStoredEventTag; @@ -76,12 +68,10 @@ use radroots_nostr::prelude::{ radroots_nostr_filter_tag, }; #[cfg(feature = "runtime")] -use radroots_trade::dvm::RADROOTS_DVM_TAG_VALIDATION_RECEIPT; -#[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(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] use radroots_trade::order::{ RadrootsOrderCanonicalizationError, RadrootsOrderProjectionQueryResult, canonicalize_order_decision_for_signer, canonicalize_order_request_for_signer, @@ -113,14 +103,11 @@ use radroots_transport_nostr::{ RadrootsRelayFetchRequest, RadrootsRelayOutcomeKind, fetch_and_ingest_relay_events, }; #[cfg(feature = "runtime")] -use serde::Deserialize; -#[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::{ - collections::{BTreeMap, BTreeSet}, - time::Duration, -}; +use std::time::Duration; #[cfg(feature = "runtime")] use tokio::{ sync::{mpsc, oneshot}, @@ -140,48 +127,36 @@ pub const TRADE_STATUS_WATCH_MAX_CAPACITY: usize = 128; 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(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] pub const TRADE_SUBMIT_OPERATION_KIND: &str = "trade.submit.v1"; -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] pub const TRADE_DECISION_OPERATION_KIND: &str = "trade.decision.v1"; -#[cfg(any(feature = "signer-adapters", test))] -pub const TRADE_REVISION_PROPOSAL_OPERATION_KIND: &str = "trade.revision.proposal.v1"; -#[cfg(any(feature = "signer-adapters", test))] -pub const TRADE_REVISION_DECISION_OPERATION_KIND: &str = "trade.revision.decision.v1"; -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] pub const TRADE_CANCELLATION_OPERATION_KIND: &str = "trade.cancellation.v1"; -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] const TRADE_SUBMIT_CONTRACT_ID: &str = "radroots.order.request.v1"; -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] const TRADE_DECISION_CONTRACT_ID: &str = "radroots.order.decision.v1"; -#[cfg(any(feature = "signer-adapters", test))] -const TRADE_REVISION_PROPOSAL_CONTRACT_ID: &str = "radroots.order.revision_proposal.v1"; -#[cfg(any(feature = "signer-adapters", test))] -const TRADE_REVISION_DECISION_CONTRACT_ID: &str = "radroots.order.revision_decision.v1"; -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] const TRADE_CANCELLATION_CONTRACT_ID: &str = "radroots.order.cancellation.v1"; -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "snake_case")] #[non_exhaustive] pub enum TradeWorkflowKind { Submit, Decision, - RevisionProposal, - RevisionDecision, Cancellation, } -#[cfg(any(feature = "signer-adapters", test))] +#[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::RevisionProposal => TRADE_REVISION_PROPOSAL_OPERATION_KIND, - Self::RevisionDecision => TRADE_REVISION_DECISION_OPERATION_KIND, Self::Cancellation => TRADE_CANCELLATION_OPERATION_KIND, } } @@ -190,14 +165,12 @@ impl TradeWorkflowKind { match self { Self::Submit => TRADE_SUBMIT_CONTRACT_ID, Self::Decision => TRADE_DECISION_CONTRACT_ID, - Self::RevisionProposal => TRADE_REVISION_PROPOSAL_CONTRACT_ID, - Self::RevisionDecision => TRADE_REVISION_DECISION_CONTRACT_ID, Self::Cancellation => TRADE_CANCELLATION_CONTRACT_ID, } } } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct TradeWorkflowPlan { pub kind: TradeWorkflowKind, @@ -207,7 +180,7 @@ pub struct TradeWorkflowPlan { pub created_at: RadrootsSdkTimestamp, } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct TradeWorkflowEnqueueReceipt { pub kind: TradeWorkflowKind, @@ -223,7 +196,7 @@ pub struct TradeWorkflowEnqueueReceipt { pub retry: TradeWorkflowRetryAdvice, } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct TradeWorkflowIdempotencyReceipt { pub digest_prefix: Option<String>, @@ -231,7 +204,7 @@ pub struct TradeWorkflowIdempotencyReceipt { pub safe_to_retry_with_same_idempotency_key: bool, } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct TradeWorkflowRetryAdvice { pub retryable_after_error: bool, @@ -239,7 +212,7 @@ pub struct TradeWorkflowRetryAdvice { pub recovery_actions: Vec<RadrootsSdkRecoveryAction>, } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] #[derive(Clone, Debug, serde::Serialize)] #[non_exhaustive] pub struct TradeSubmitPrepareRequest { @@ -250,100 +223,7 @@ pub struct TradeSubmitPrepareRequest { pub created_at: Option<RadrootsSdkTimestamp>, } -#[cfg(any(feature = "signer-adapters", test))] -#[cfg(test)] -impl TradeSubmitPrepareRequest { - pub fn new( - actor: RadrootsActorContext, - listing_event: RadrootsEventPtr, - order: RadrootsOrderRequest, - ) -> Self { - Self { - actor, - listing_event, - order, - created_at: None, - } - } - - pub fn with_created_at(mut self, created_at: RadrootsSdkTimestamp) -> Self { - self.created_at = Some(created_at); - self - } -} - -#[cfg(feature = "runtime")] -#[cfg(test)] -#[derive(Clone, Debug, serde::Serialize)] -#[non_exhaustive] -pub struct TradeSubmitEnqueueRequest { - #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] - pub actor: RadrootsActorContext, - pub listing_event: RadrootsEventPtr, - pub order: RadrootsOrderRequest, - pub target_policy: TargetPolicy, - pub publish_mode: PublishMode, - pub satisfaction_policy: SatisfactionPolicy, - pub idempotency_key: Option<SdkIdempotencyKey>, - pub created_at: Option<RadrootsSdkTimestamp>, -} - -#[cfg(feature = "runtime")] -#[cfg(test)] -impl TradeSubmitEnqueueRequest { - 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, - target_policy, - publish_mode, - satisfaction_policy, - idempotency_key: None, - created_at: None, - } - } - - pub fn try_with_nostr_targets<I, S>( - mut self, - target_policy: I, - policy: NostrRelayUrlPolicy, - ) -> Result<Self, RadrootsSdkError> - where - I: IntoIterator<Item = S>, - S: AsRef<str>, - { - self.target_policy = TargetPolicy::try_nostr_relays(target_policy, policy)?; - Ok(self) - } - - pub fn with_idempotency_key(mut self, idempotency_key: SdkIdempotencyKey) -> Self { - self.idempotency_key = Some(idempotency_key.into()); - 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(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct TradeSubmitPlan { pub workflow: TradeWorkflowPlan, @@ -357,7 +237,7 @@ pub struct TradeSubmitPlan { pub created_at: RadrootsSdkTimestamp, } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct TradeSubmitReceipt { pub workflow: TradeWorkflowEnqueueReceipt, @@ -445,7 +325,7 @@ pub struct TradeEvidenceIngestReceipt { pub inserted: bool, } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] #[derive(Clone, Debug, serde::Serialize)] #[non_exhaustive] pub struct TradeDecisionPrepareRequest { @@ -456,100 +336,7 @@ pub struct TradeDecisionPrepareRequest { pub created_at: Option<RadrootsSdkTimestamp>, } -#[cfg(any(feature = "signer-adapters", test))] -#[cfg(test)] -impl TradeDecisionPrepareRequest { - pub fn new( - actor: RadrootsActorContext, - request_event: RadrootsEventPtr, - decision: RadrootsOrderDecision, - ) -> Self { - Self { - actor, - request_event, - decision, - created_at: None, - } - } - - pub fn with_created_at(mut self, created_at: RadrootsSdkTimestamp) -> Self { - self.created_at = Some(created_at); - self - } -} - -#[cfg(feature = "runtime")] -#[cfg(test)] -#[derive(Clone, Debug, serde::Serialize)] -#[non_exhaustive] -pub struct TradeDecisionEnqueueRequest { - #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] - pub actor: RadrootsActorContext, - pub request_event: RadrootsEventPtr, - pub decision: RadrootsOrderDecision, - pub target_policy: TargetPolicy, - pub publish_mode: PublishMode, - pub satisfaction_policy: SatisfactionPolicy, - pub idempotency_key: Option<SdkIdempotencyKey>, - pub created_at: Option<RadrootsSdkTimestamp>, -} - -#[cfg(feature = "runtime")] -#[cfg(test)] -impl TradeDecisionEnqueueRequest { - pub fn new( - actor: RadrootsActorContext, - request_event: RadrootsEventPtr, - decision: RadrootsOrderDecision, - target_policy: TargetPolicy, - publish_mode: PublishMode, - satisfaction_policy: SatisfactionPolicy, - ) -> Self { - Self { - actor, - request_event, - decision, - target_policy, - publish_mode, - satisfaction_policy, - idempotency_key: None, - created_at: None, - } - } - - pub fn try_with_nostr_targets<I, S>( - mut self, - target_policy: I, - policy: NostrRelayUrlPolicy, - ) -> Result<Self, RadrootsSdkError> - where - I: IntoIterator<Item = S>, - S: AsRef<str>, - { - self.target_policy = TargetPolicy::try_nostr_relays(target_policy, policy)?; - Ok(self) - } - - pub fn with_idempotency_key(mut self, idempotency_key: SdkIdempotencyKey) -> Self { - self.idempotency_key = Some(idempotency_key.into()); - 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(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct TradeDecisionPlan { pub workflow: TradeWorkflowPlan, @@ -563,7 +350,7 @@ pub struct TradeDecisionPlan { pub created_at: RadrootsSdkTimestamp, } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct TradeDecisionReceipt { pub workflow: TradeWorkflowEnqueueReceipt, @@ -582,99 +369,156 @@ pub struct TradeDecisionReceipt { pub idempotency_digest_prefix: Option<String>, } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] #[derive(Clone, Debug, serde::Serialize)] #[non_exhaustive] -pub struct TradeRevisionProposalPrepareRequest { +pub struct TradeCancellationPrepareRequest { #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] pub actor: RadrootsActorContext, pub root_event: RadrootsEventPtr, pub previous_event: RadrootsEventPtr, - pub proposal: RadrootsOrderRevisionProposal, + pub cancellation: RadrootsOrderCancellation, pub created_at: Option<RadrootsSdkTimestamp>, } -#[cfg(any(feature = "signer-adapters", test))] -#[cfg(test)] -impl TradeRevisionProposalPrepareRequest { - pub fn new( - actor: RadrootsActorContext, - root_event: RadrootsEventPtr, - previous_event: RadrootsEventPtr, - proposal: RadrootsOrderRevisionProposal, - ) -> Self { - Self { - actor, - root_event, - previous_event, - proposal, - created_at: None, - } - } +#[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, +} - 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, 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 = "runtime")] -#[cfg(test)] -#[derive(Clone, Debug, serde::Serialize)] -#[non_exhaustive] -pub struct TradeRevisionProposalEnqueueRequest { - #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] - pub actor: RadrootsActorContext, - pub root_event: RadrootsEventPtr, - pub previous_event: RadrootsEventPtr, - pub proposal: RadrootsOrderRevisionProposal, - pub target_policy: TargetPolicy, - pub publish_mode: PublishMode, - pub satisfaction_policy: SatisfactionPolicy, - pub idempotency_key: Option<SdkIdempotencyKey>, - pub created_at: Option<RadrootsSdkTimestamp>, +#[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 = "runtime")] -#[cfg(test)] -impl TradeRevisionProposalEnqueueRequest { - pub fn new( +#[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, - root_event: RadrootsEventPtr, - previous_event: RadrootsEventPtr, - proposal: RadrootsOrderRevisionProposal, + listing_event: RadrootsEventPtr, + order: RadrootsOrderRequest, target_policy: TargetPolicy, publish_mode: PublishMode, satisfaction_policy: SatisfactionPolicy, ) -> Self { Self { actor, - root_event, - previous_event, - proposal, + 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 try_with_nostr_targets<I, S>( - mut self, - target_policy: I, - policy: NostrRelayUrlPolicy, - ) -> Result<Self, RadrootsSdkError> - where - I: IntoIterator<Item = S>, - S: AsRef<str>, - { - self.target_policy = TargetPolicy::try_nostr_relays(target_policy, policy)?; - Ok(self) + 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_idempotency_key(mut self, idempotency_key: SdkIdempotencyKey) -> Self { - self.idempotency_key = Some(idempotency_key.into()); + pub fn with_privacy_confirmation( + mut self, + privacy_confirmation: PrivacyPreflightConfirmation, + ) -> Self { + self.privacy_confirmation = privacy_confirmation; self } @@ -692,134 +536,53 @@ impl TradeRevisionProposalEnqueueRequest { } } -#[cfg(any(feature = "signer-adapters", test))] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeRevisionProposalPlan { - 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(any(feature = "signer-adapters", test))] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeRevisionProposalReceipt { - pub workflow: TradeWorkflowEnqueueReceipt, - pub locator: RadrootsTradeLocator, - pub order_id: RadrootsOrderId, - pub listing_addr: RadrootsListingAddress, - pub buyer_pubkey: RadrootsPublicKey, - 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(any(feature = "signer-adapters", test))] -#[derive(Clone, Debug, serde::Serialize)] -#[non_exhaustive] -pub struct TradeRevisionDecisionPrepareRequest { - #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] - pub actor: RadrootsActorContext, - pub root_event: RadrootsEventPtr, - pub previous_event: RadrootsEventPtr, - pub decision: RadrootsOrderRevisionDecision, - pub created_at: Option<RadrootsSdkTimestamp>, -} - -#[cfg(any(feature = "signer-adapters", test))] -#[cfg(test)] -impl TradeRevisionDecisionPrepareRequest { - pub fn new( - actor: RadrootsActorContext, - root_event: RadrootsEventPtr, - previous_event: RadrootsEventPtr, - decision: RadrootsOrderRevisionDecision, - ) -> Self { - Self { - actor, - root_event, - previous_event, - decision, - created_at: None, - } - } - - pub fn with_created_at(mut self, created_at: RadrootsSdkTimestamp) -> Self { - self.created_at = Some(created_at); - self - } -} - -#[cfg(feature = "runtime")] -#[cfg(test)] +#[cfg(feature = "signer-adapters")] #[derive(Clone, Debug, serde::Serialize)] #[non_exhaustive] -pub struct TradeRevisionDecisionEnqueueRequest { +pub struct TradeAcceptRequest { #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] pub actor: RadrootsActorContext, - pub root_event: RadrootsEventPtr, - pub previous_event: RadrootsEventPtr, - pub decision: RadrootsOrderRevisionDecision, + 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 = "runtime")] -#[cfg(test)] -impl TradeRevisionDecisionEnqueueRequest { +#[cfg(feature = "signer-adapters")] +impl TradeAcceptRequest { pub fn new( actor: RadrootsActorContext, - root_event: RadrootsEventPtr, - previous_event: RadrootsEventPtr, - decision: RadrootsOrderRevisionDecision, + locator: RadrootsTradeLocator, + inventory_commitments: Vec<RadrootsOrderInventoryCommitment>, target_policy: TargetPolicy, publish_mode: PublishMode, satisfaction_policy: SatisfactionPolicy, + evidence_mode: TradeEvidenceMode, ) -> Self { Self { actor, - root_event, - previous_event, - decision, + locator, + inventory_commitments, target_policy, publish_mode, satisfaction_policy, + evidence_mode, + privacy_confirmation: PrivacyPreflightConfirmation::new(), idempotency_key: None, created_at: None, } } - pub fn try_with_nostr_targets<I, S>( + pub fn with_privacy_confirmation( mut self, - target_policy: I, - policy: NostrRelayUrlPolicy, - ) -> Result<Self, RadrootsSdkError> - where - I: IntoIterator<Item = S>, - S: AsRef<str>, - { - self.target_policy = TargetPolicy::try_nostr_relays(target_policy, policy)?; - Ok(self) - } - - pub fn with_idempotency_key(mut self, idempotency_key: SdkIdempotencyKey) -> Self { - self.idempotency_key = Some(idempotency_key.into()); + privacy_confirmation: PrivacyPreflightConfirmation, + ) -> Self { + self.privacy_confirmation = privacy_confirmation; self } @@ -837,134 +600,53 @@ impl TradeRevisionDecisionEnqueueRequest { } } -#[cfg(any(feature = "signer-adapters", test))] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeRevisionDecisionPlan { - 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(any(feature = "signer-adapters", test))] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeRevisionDecisionReceipt { - pub workflow: TradeWorkflowEnqueueReceipt, - pub locator: RadrootsTradeLocator, - pub order_id: RadrootsOrderId, - pub listing_addr: RadrootsListingAddress, - pub buyer_pubkey: RadrootsPublicKey, - 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(any(feature = "signer-adapters", test))] -#[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(any(feature = "signer-adapters", test))] -#[cfg(test)] -impl TradeCancellationPrepareRequest { - pub fn new( - actor: RadrootsActorContext, - root_event: RadrootsEventPtr, - previous_event: RadrootsEventPtr, - cancellation: RadrootsOrderCancellation, - ) -> Self { - Self { - actor, - root_event, - previous_event, - cancellation, - created_at: None, - } - } - - pub fn with_created_at(mut self, created_at: RadrootsSdkTimestamp) -> Self { - self.created_at = Some(created_at); - self - } -} - -#[cfg(feature = "runtime")] -#[cfg(test)] +#[cfg(feature = "signer-adapters")] #[derive(Clone, Debug, serde::Serialize)] #[non_exhaustive] -pub struct TradeCancellationEnqueueRequest { +pub struct TradeDeclineRequest { #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] pub actor: RadrootsActorContext, - pub root_event: RadrootsEventPtr, - pub previous_event: RadrootsEventPtr, - pub cancellation: RadrootsOrderCancellation, + 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 = "runtime")] -#[cfg(test)] -impl TradeCancellationEnqueueRequest { +#[cfg(feature = "signer-adapters")] +impl TradeDeclineRequest { pub fn new( actor: RadrootsActorContext, - root_event: RadrootsEventPtr, - previous_event: RadrootsEventPtr, - cancellation: RadrootsOrderCancellation, + locator: RadrootsTradeLocator, + reason: impl Into<String>, target_policy: TargetPolicy, publish_mode: PublishMode, satisfaction_policy: SatisfactionPolicy, + evidence_mode: TradeEvidenceMode, ) -> Self { Self { actor, - root_event, - previous_event, - cancellation, + locator, + reason: reason.into(), target_policy, publish_mode, satisfaction_policy, + evidence_mode, + privacy_confirmation: PrivacyPreflightConfirmation::new(), idempotency_key: None, created_at: None, } } - pub fn try_with_nostr_targets<I, S>( + pub fn with_privacy_confirmation( mut self, - target_policy: I, - policy: NostrRelayUrlPolicy, - ) -> Result<Self, RadrootsSdkError> - where - I: IntoIterator<Item = S>, - S: AsRef<str>, - { - self.target_policy = TargetPolicy::try_nostr_relays(target_policy, policy)?; - Ok(self) - } - - pub fn with_idempotency_key(mut self, idempotency_key: SdkIdempotencyKey) -> Self { - self.idempotency_key = Some(idempotency_key.into()); + privacy_confirmation: PrivacyPreflightConfirmation, + ) -> Self { + self.privacy_confirmation = privacy_confirmation; self } @@ -982,143 +664,48 @@ impl TradeCancellationEnqueueRequest { } } -#[cfg(any(feature = "signer-adapters", test))] -#[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(any(feature = "signer-adapters", test))] -#[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(any(feature = "signer-adapters", test))] -#[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(any(feature = "signer-adapters", test))] -#[derive(Clone, Debug, serde::Serialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum TradeEvidenceMode { - LocalOnly, - ResyncBeforeMutation, - RequireExplicitEvidence { - evidence: Vec<TradeEvidenceIngestRequest>, - }, -} - -#[cfg(any(feature = "signer-adapters", test))] -impl TradeEvidenceMode { - pub fn require_explicit_evidence( - evidence: impl IntoIterator<Item = TradeEvidenceIngestRequest>, - ) -> Self { - Self::RequireExplicitEvidence { - evidence: evidence.into_iter().collect(), - } - } -} - -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] #[derive(Clone, Debug, serde::Serialize)] #[non_exhaustive] -pub struct TradeProposeRequest { +pub struct TradeCancelRequest { #[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 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(any(feature = "signer-adapters", test))] -impl TradeProposeRequest { +#[cfg(feature = "signer-adapters")] +impl TradeCancelRequest { pub fn new( actor: RadrootsActorContext, - listing_event: RadrootsEventPtr, - order_id: RadrootsOrderId, - listing_addr: RadrootsListingAddress, - seller_pubkey: RadrootsPublicKey, - items: Vec<RadrootsOrderItem>, - economics: RadrootsOrderEconomics, + locator: RadrootsTradeLocator, + reason: impl Into<String>, target_policy: TargetPolicy, publish_mode: PublishMode, satisfaction_policy: SatisfactionPolicy, + evidence_mode: TradeEvidenceMode, ) -> Self { Self { actor, - listing_event, - order_id, - listing_addr, - seller_pubkey, - items, - economics, - public_note: None, + 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_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, @@ -1141,423 +728,91 @@ impl TradeProposeRequest { } } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "runtime")] #[derive(Clone, Debug, serde::Serialize)] #[non_exhaustive] -pub struct TradeAcceptRequest { +pub struct TradeSellerInboxRequest { #[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>, + pub limit: u32, } -#[cfg(any(feature = "signer-adapters", test))] -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 { +#[cfg(feature = "runtime")] +impl TradeSellerInboxRequest { + pub fn new(actor: RadrootsActorContext) -> Self { Self { actor, - locator, - inventory_commitments, - target_policy, - publish_mode, - satisfaction_policy, - evidence_mode, - privacy_confirmation: PrivacyPreflightConfirmation::new(), - idempotency_key: None, - created_at: None, + limit: TRADE_STATUS_DEFAULT_LIMIT, } } - pub fn with_privacy_confirmation( - mut self, - privacy_confirmation: PrivacyPreflightConfirmation, - ) -> Self { - self.privacy_confirmation = privacy_confirmation; + pub fn with_limit(mut self, limit: u32) -> Self { + self.limit = limit; 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, PartialEq, Eq, serde::Serialize)] +pub struct TradeSellerInboxReceipt { + pub seller_pubkey: RadrootsPublicKey, + pub statuses: Vec<TradeStatusReceipt>, } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "runtime")] #[derive(Clone, Debug, serde::Serialize)] #[non_exhaustive] -pub struct TradeDeclineRequest { - #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] - pub actor: RadrootsActorContext, +pub struct TradeResyncRequest { 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>, + pub limit: u32, + pub validation_trust_policy: RadrootsTradeValidationTrustPolicy, } -#[cfg(any(feature = "signer-adapters", test))] -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 { +#[cfg(feature = "runtime")] +impl TradeResyncRequest { + pub fn new(locator: RadrootsTradeLocator) -> 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, + limit: TRADE_STATUS_DEFAULT_LIMIT, + validation_trust_policy: RadrootsTradeValidationTrustPolicy::production(), } } - pub fn with_privacy_confirmation( + pub fn with_limit(mut self, limit: u32) -> Self { + self.limit = limit; + self + } + + pub fn with_validation_trust_policy( mut self, - privacy_confirmation: PrivacyPreflightConfirmation, + policy: RadrootsTradeValidationTrustPolicy, ) -> Self { - self.privacy_confirmation = privacy_confirmation; + self.validation_trust_policy = policy; self } - pub fn try_with_idempotency_key( + pub fn try_with_trusted_rhi_pubkeys<I, S>( mut self, - idempotency_key: impl AsRef<str>, - ) -> Result<Self, RadrootsSdkError> { - self.idempotency_key = Some(SdkIdempotencyKey::new(idempotency_key)?); + pubkeys: I, + ) -> Result<Self, RadrootsSdkError> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + self.validation_trust_policy.trusted_rhi_pubkeys = parse_worker_pubkeys(pubkeys)?; Ok(self) } - pub fn with_created_at(mut self, created_at: RadrootsSdkTimestamp) -> Self { - self.created_at = Some(created_at); - self - } -} - -#[cfg(any(feature = "signer-adapters", test))] -#[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(any(feature = "signer-adapters", test))] -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(any(feature = "signer-adapters", test))] -#[derive(Clone, Debug, serde::Serialize)] -#[non_exhaustive] -pub struct TradeRevisionProposalRequest { - #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] - pub actor: RadrootsActorContext, - pub locator: RadrootsTradeLocator, - pub revision_id: RadrootsOrderRevisionId, - pub items: Vec<RadrootsOrderItem>, - pub economics: RadrootsOrderEconomics, - pub reason: String, - pub target_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(any(feature = "signer-adapters", test))] -impl TradeRevisionProposalRequest { - pub fn new( - actor: RadrootsActorContext, - locator: RadrootsTradeLocator, - revision_id: RadrootsOrderRevisionId, - items: Vec<RadrootsOrderItem>, - economics: RadrootsOrderEconomics, - reason: impl Into<String>, - target_policy: TargetPolicy, - publish_mode: PublishMode, - satisfaction_policy: SatisfactionPolicy, - evidence_mode: TradeEvidenceMode, - ) -> Self { - Self { - actor, - locator, - revision_id, - items, - economics, - 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(any(feature = "signer-adapters", test))] -#[derive(Clone, Debug, serde::Serialize)] -#[non_exhaustive] -pub struct TradeRevisionDecisionRequest { - #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] - pub actor: RadrootsActorContext, - pub locator: RadrootsTradeLocator, - pub revision_id: RadrootsOrderRevisionId, - pub decision: RadrootsOrderRevisionOutcome, - pub target_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(any(feature = "signer-adapters", test))] -impl TradeRevisionDecisionRequest { - pub fn new( - actor: RadrootsActorContext, - locator: RadrootsTradeLocator, - revision_id: RadrootsOrderRevisionId, - decision: RadrootsOrderRevisionOutcome, - target_policy: TargetPolicy, - publish_mode: PublishMode, - satisfaction_policy: SatisfactionPolicy, - evidence_mode: TradeEvidenceMode, - ) -> Self { - Self { - actor, - locator, - revision_id, - decision, - 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 - } - - pub fn try_with_trusted_rhi_pubkeys<I, S>( - mut self, - pubkeys: I, - ) -> Result<Self, RadrootsSdkError> - where - I: IntoIterator<Item = S>, - S: AsRef<str>, - { - self.validation_trust_policy.trusted_rhi_pubkeys = parse_worker_pubkeys(pubkeys)?; - Ok(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 = "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(()) } } @@ -1612,7 +867,6 @@ pub enum TradeEvidenceQueryBranchKind { LifecycleChain, ValidationReceipts, ListingSnapshot, - WorkerResults, RejectedEvidence, } @@ -1751,7 +1005,6 @@ impl TradeResyncNostrRelayTransportOutcomeKind { pub struct TradeValidationReceiptListRequest { pub order_id: RadrootsOrderId, pub limit: u32, - pub trusted_worker_pubkeys: Vec<RadrootsPublicKey>, } #[cfg(feature = "runtime")] @@ -1760,7 +1013,6 @@ impl TradeValidationReceiptListRequest { Self { order_id, limit: TRADE_STATUS_DEFAULT_LIMIT, - trusted_worker_pubkeys: Vec::new(), } } @@ -1775,18 +1027,6 @@ impl TradeValidationReceiptListRequest { self } - pub fn try_with_trusted_worker_pubkeys<I, S>( - mut self, - pubkeys: I, - ) -> Result<Self, RadrootsSdkError> - where - I: IntoIterator<Item = S>, - S: AsRef<str>, - { - self.trusted_worker_pubkeys = parse_worker_pubkeys(pubkeys)?; - Ok(self) - } - #[cfg(feature = "transport-nostr-runtime")] fn validate(&self) -> Result<(), RadrootsSdkError> { validate_validation_receipt_limit(self.limit) @@ -1798,16 +1038,12 @@ impl TradeValidationReceiptListRequest { #[non_exhaustive] pub struct TradeValidationReceiptInspectRequest { pub receipt_event_id: RadrootsEventId, - pub trusted_worker_pubkeys: Vec<RadrootsPublicKey>, } #[cfg(feature = "runtime")] impl TradeValidationReceiptInspectRequest { pub fn new(receipt_event_id: RadrootsEventId) -> Self { - Self { - receipt_event_id, - trusted_worker_pubkeys: Vec::new(), - } + Self { receipt_event_id } } pub fn parse(receipt_event_id: &str) -> Result<Self, RadrootsSdkError> { @@ -1819,18 +1055,6 @@ impl TradeValidationReceiptInspectRequest { ), }) } - - pub fn try_with_trusted_worker_pubkeys<I, S>( - mut self, - pubkeys: I, - ) -> Result<Self, RadrootsSdkError> - where - I: IntoIterator<Item = S>, - S: AsRef<str>, - { - self.trusted_worker_pubkeys = parse_worker_pubkeys(pubkeys)?; - Ok(self) - } } #[cfg(feature = "runtime")] @@ -1838,16 +1062,12 @@ impl TradeValidationReceiptInspectRequest { #[non_exhaustive] pub struct TradeValidationReceiptVerifyRequest { pub receipt_event_id: RadrootsEventId, - pub trusted_worker_pubkeys: Vec<RadrootsPublicKey>, } #[cfg(feature = "runtime")] impl TradeValidationReceiptVerifyRequest { pub fn new(receipt_event_id: RadrootsEventId) -> Self { - Self { - receipt_event_id, - trusted_worker_pubkeys: Vec::new(), - } + Self { receipt_event_id } } pub fn parse(receipt_event_id: &str) -> Result<Self, RadrootsSdkError> { @@ -1859,18 +1079,6 @@ impl TradeValidationReceiptVerifyRequest { ), }) } - - pub fn try_with_trusted_worker_pubkeys<I, S>( - mut self, - pubkeys: I, - ) -> Result<Self, RadrootsSdkError> - where - I: IntoIterator<Item = S>, - S: AsRef<str>, - { - self.trusted_worker_pubkeys = parse_worker_pubkeys(pubkeys)?; - Ok(self) - } } #[cfg(feature = "runtime")] @@ -1899,7 +1107,6 @@ pub struct TradeValidationReceiptEvent { pub event: RadrootsEventEnvelope, pub receipt: RadrootsTradeValidationReceipt, pub tags: TradeValidationReceiptTags, - pub worker_evidence: TradeValidationReceiptWorkerEvidenceSelection, } #[cfg(feature = "runtime")] @@ -1925,31 +1132,6 @@ pub struct TradeValidationReceiptTags { } #[cfg(feature = "runtime")] -#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)] -pub struct TradeValidationReceiptWorkerEvidenceSelection { - pub trusted: Option<TradeValidationReceiptWorkerEvidence>, - pub untrusted: Option<TradeValidationReceiptWorkerEvidence>, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct TradeValidationReceiptWorkerEvidence { - pub result_event_id: RadrootsEventId, - pub author: RadrootsPublicKey, - pub status: String, - pub prover_backend: String, - pub proof_mode: String, - pub proof_system: String, - pub proof_generated: bool, - pub sp1_execute_checked: bool, - pub sp1_execute_public_values_hash: Option<String>, - pub cryptographic_proof_verified: bool, - pub public_values_hash: String, - pub validation_authority: Option<RadrootsTradeValidationAuthority>, - pub commitment_confidence: Option<RadrootsTradeCommitmentConfidence>, -} - -#[cfg(feature = "runtime")] #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct TradeValidationReceiptNostrEvidenceReceipt { pub inserted_count: usize, @@ -2360,7 +1542,6 @@ pub struct TradeStatusReceipt { pub decision_event_id: Option<RadrootsEventId>, pub agreement_event_id: Option<RadrootsEventId>, pub rhi_receipt_event_id: Option<RadrootsEventId>, - pub pending_revision_event_id: Option<RadrootsEventId>, pub cancellation_event_id: Option<RadrootsEventId>, pub last_event_id: Option<RadrootsEventId>, pub issues: Vec<SdkTradeStatusIssue>, @@ -2381,7 +1562,6 @@ pub struct TradeStatusEvidenceSummary { pub has_decision: bool, pub has_agreement: bool, pub has_validation_receipt: bool, - pub has_pending_revision: bool, pub has_cancellation: bool, pub has_issues: bool, } @@ -2411,8 +1591,6 @@ pub struct TradeValidationTrustDecision { #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct TradeStatusEligibility { pub can_decide: bool, - pub can_propose_revision: bool, - pub can_decide_revision: bool, pub can_cancel: bool, } @@ -2424,8 +1602,7 @@ pub enum TradeStatusNextActionKind { NoLocalOrder, InspectEvidenceIssues, AwaitSellerDecision, - DecideRevision, - AwaitRhiValidation, + AwaitValidation, Terminal, } @@ -2446,11 +1623,11 @@ pub enum TradeStatusKind { Missing, Ambiguous, Requested, - RevisionProposed, - AgreedPendingRhi, + AgreedPendingValidation, Committed, Declined, Cancelled, + ValidationExpired, Invalid, } @@ -2516,28 +1693,6 @@ pub enum SdkTradeStatusIssueKind { DecisionInventoryCommitmentMismatch, DecisionMissingReason, ConflictingDecisions, - RevisionProposalPayloadInvalid, - RevisionProposalOrderIdMismatch, - RevisionProposalAuthorMismatch, - RevisionProposalCounterpartyMismatch, - RevisionProposalBuyerMismatch, - RevisionProposalSellerMismatch, - RevisionProposalListingAddressInvalid, - RevisionProposalListingMismatch, - RevisionProposalRootMismatch, - RevisionProposalPreviousMismatch, - RevisionDecisionWithoutProposal, - RevisionDecisionPayloadInvalid, - RevisionDecisionOrderIdMismatch, - RevisionDecisionAuthorMismatch, - RevisionDecisionCounterpartyMismatch, - RevisionDecisionBuyerMismatch, - RevisionDecisionSellerMismatch, - RevisionDecisionListingAddressInvalid, - RevisionDecisionListingMismatch, - RevisionDecisionRootMismatch, - RevisionDecisionPreviousMismatch, - RevisionDecisionRevisionIdMismatch, CancellationWithoutCancellableOrder, CancellationPayloadInvalid, CancellationOrderIdMismatch, @@ -2621,7 +1776,7 @@ impl<'sdk> TradesClient<'sdk> { }) } - #[cfg(any(feature = "signer-adapters", test))] + #[cfg(feature = "signer-adapters")] pub(crate) fn prepare_submit( &self, request: TradeSubmitPrepareRequest, @@ -2636,77 +1791,6 @@ impl<'sdk> TradesClient<'sdk> { } #[cfg(feature = "signer-adapters")] - #[cfg(test)] - pub(crate) async fn enqueue_submit( - &self, - request: TradeSubmitEnqueueRequest, - ) -> Result<TradeSubmitReceipt, RadrootsSdkError> { - let TradeSubmitEnqueueRequest { - actor, - listing_event, - order, - target_policy, - publish_mode, - satisfaction_policy, - idempotency_key, - created_at, - } = request; - validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; - let prepare_request = TradeSubmitPrepareRequest { - actor: actor.clone(), - listing_event, - order, - created_at, - }; - let plan = self.prepare_submit(prepare_request)?; - self.enqueue_prepared_submit( - &actor, - plan, - target_policy, - publish_mode, - satisfaction_policy, - idempotency_key, - ) - .await - } - - #[cfg(test)] - pub(crate) async fn enqueue_submit_with_explicit_signer( - &self, - request: TradeSubmitEnqueueRequest, - signer: &dyn RadrootsEventSigner, - ) -> Result<TradeSubmitReceipt, RadrootsSdkError> { - let TradeSubmitEnqueueRequest { - actor, - listing_event, - order, - target_policy, - publish_mode, - satisfaction_policy, - idempotency_key, - created_at, - } = request; - validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; - let prepare_request = TradeSubmitPrepareRequest { - actor: actor.clone(), - listing_event, - order, - created_at, - }; - let plan = self.prepare_submit(prepare_request)?; - self.enqueue_prepared_submit_with_explicit_signer( - &actor, - plan, - target_policy, - publish_mode, - satisfaction_policy, - idempotency_key, - signer, - ) - .await - } - - #[cfg(feature = "signer-adapters")] pub(crate) async fn enqueue_prepared_submit( &self, actor: &RadrootsActorContext, @@ -2724,629 +1808,77 @@ impl<'sdk> TradesClient<'sdk> { actor, frozen_draft: &plan.frozen_draft, target_policy: target_policy.workflow_target_policy(), - satisfaction_policy: satisfaction_policy, - idempotency_key, - }, - ) - .await?; - Ok(order_submit_receipt(plan, enqueue)) - } - - #[cfg(test)] - pub(crate) async fn enqueue_prepared_submit_with_explicit_signer( - &self, - actor: &RadrootsActorContext, - plan: TradeSubmitPlan, - target_policy: TargetPolicy, - publish_mode: PublishMode, - satisfaction_policy: SatisfactionPolicy, - idempotency_key: Option<SdkIdempotencyKey>, - signer: &dyn RadrootsEventSigner, - ) -> Result<TradeSubmitReceipt, RadrootsSdkError> { - validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; - let enqueue = enqueue_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: satisfaction_policy, + satisfaction_policy, idempotency_key, }, - signer, ) .await?; Ok(order_submit_receipt(plan, enqueue)) } - #[cfg(any(feature = "signer-adapters", test))] + #[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")] - #[cfg(test)] - pub(crate) async fn enqueue_decision( - &self, - request: TradeDecisionEnqueueRequest, - ) -> Result<TradeDecisionReceipt, RadrootsSdkError> { - let TradeDecisionEnqueueRequest { - actor, - request_event, - decision, - target_policy, - publish_mode, - satisfaction_policy, - idempotency_key, - created_at, - } = request; - validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; - let prepare_request = TradeDecisionPrepareRequest { - actor: actor.clone(), - request_event, - decision, - created_at, - }; - let plan = self.prepare_decision(prepare_request)?; - self.enqueue_prepared_decision( - &actor, - plan, - target_policy, - publish_mode, - satisfaction_policy, - idempotency_key, - ) - .await - } - - #[cfg(test)] - pub(crate) async fn enqueue_decision_with_explicit_signer( - &self, - request: TradeDecisionEnqueueRequest, - signer: &dyn RadrootsEventSigner, - ) -> Result<TradeDecisionReceipt, RadrootsSdkError> { - let TradeDecisionEnqueueRequest { - actor, - request_event, - decision, - target_policy, - publish_mode, - satisfaction_policy, - idempotency_key, - created_at, - } = request; - validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; - let prepare_request = TradeDecisionPrepareRequest { - actor: actor.clone(), - request_event, - decision, - created_at, - }; - let plan = self.prepare_decision(prepare_request)?; - self.enqueue_prepared_decision_with_explicit_signer( - &actor, - plan, - target_policy, - publish_mode, - satisfaction_policy, - idempotency_key, - signer, - ) - .await - } - - #[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: satisfaction_policy, - idempotency_key, - }, - ) - .await?; - Ok(order_decision_receipt(plan, enqueue)) - } - - #[cfg(test)] - pub(crate) async fn enqueue_prepared_decision_with_explicit_signer( - &self, - actor: &RadrootsActorContext, - plan: TradeDecisionPlan, - target_policy: TargetPolicy, - publish_mode: PublishMode, - satisfaction_policy: SatisfactionPolicy, - idempotency_key: Option<SdkIdempotencyKey>, - signer: &dyn RadrootsEventSigner, - ) -> 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_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: satisfaction_policy, - idempotency_key, - }, - signer, - ) - .await?; - Ok(order_decision_receipt(plan, enqueue)) - } - - #[cfg(any(feature = "signer-adapters", test))] - pub(crate) fn prepare_revision_proposal( - &self, - request: TradeRevisionProposalPrepareRequest, - ) -> Result<TradeRevisionProposalPlan, RadrootsSdkError> { - let created_at = self.resolved_created_at(request.created_at)?; - order_revision_proposal_plan( - &request.actor, - request.root_event, - request.previous_event, - request.proposal, - created_at, - ) - } - - #[cfg(feature = "signer-adapters")] - #[cfg(test)] - pub(crate) async fn enqueue_revision_proposal( - &self, - request: TradeRevisionProposalEnqueueRequest, - ) -> Result<TradeRevisionProposalReceipt, RadrootsSdkError> { - let TradeRevisionProposalEnqueueRequest { - actor, - root_event, - previous_event, - proposal, - target_policy, - publish_mode, - satisfaction_policy, - idempotency_key, - created_at, - } = request; - validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; - let prepare_request = TradeRevisionProposalPrepareRequest { - actor: actor.clone(), - root_event, - previous_event, - proposal, - created_at, - }; - let plan = self.prepare_revision_proposal(prepare_request)?; - self.enqueue_prepared_revision_proposal( - &actor, - plan, - target_policy, - publish_mode, - satisfaction_policy, - idempotency_key, - ) - .await - } - - #[cfg(test)] - pub(crate) async fn enqueue_revision_proposal_with_explicit_signer( - &self, - request: TradeRevisionProposalEnqueueRequest, - signer: &dyn RadrootsEventSigner, - ) -> Result<TradeRevisionProposalReceipt, RadrootsSdkError> { - let TradeRevisionProposalEnqueueRequest { - actor, - root_event, - previous_event, - proposal, - target_policy, - publish_mode, - satisfaction_policy, - idempotency_key, - created_at, - } = request; - validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; - let prepare_request = TradeRevisionProposalPrepareRequest { - actor: actor.clone(), - root_event, - previous_event, - proposal, - created_at, - }; - let plan = self.prepare_revision_proposal(prepare_request)?; - self.enqueue_prepared_revision_proposal_with_explicit_signer( - &actor, - plan, - target_policy, - publish_mode, - satisfaction_policy, - idempotency_key, - signer, - ) - .await - } - - #[cfg(feature = "signer-adapters")] - pub(crate) async fn enqueue_prepared_revision_proposal( - &self, - actor: &RadrootsActorContext, - plan: TradeRevisionProposalPlan, - target_policy: TargetPolicy, - publish_mode: PublishMode, - satisfaction_policy: SatisfactionPolicy, - idempotency_key: Option<SdkIdempotencyKey>, - ) -> Result<TradeRevisionProposalReceipt, RadrootsSdkError> { - validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; - if !self - .prepared_order_event_exists(&plan.expected_event_id) - .await? - { - self.require_revision_proposal_preflight(&plan).await?; - } - let enqueue = enqueue_configured_signed_workflow( - self.sdk, - SdkWorkflowEnqueueRequest { - operation_kind: TradeWorkflowKind::RevisionProposal.operation_kind(), - actor, - frozen_draft: &plan.frozen_draft, - target_policy: target_policy.workflow_target_policy(), - satisfaction_policy: satisfaction_policy, - idempotency_key, - }, - ) - .await?; - Ok(order_revision_proposal_receipt(plan, enqueue)) - } - - #[cfg(test)] - pub(crate) async fn enqueue_prepared_revision_proposal_with_explicit_signer( - &self, - actor: &RadrootsActorContext, - plan: TradeRevisionProposalPlan, - target_policy: TargetPolicy, - publish_mode: PublishMode, - satisfaction_policy: SatisfactionPolicy, - idempotency_key: Option<SdkIdempotencyKey>, - signer: &dyn RadrootsEventSigner, - ) -> Result<TradeRevisionProposalReceipt, RadrootsSdkError> { - validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; - if !self - .prepared_order_event_exists(&plan.expected_event_id) - .await? - { - self.require_revision_proposal_preflight(&plan).await?; - } - let enqueue = enqueue_signed_workflow( - self.sdk, - SdkWorkflowEnqueueRequest { - operation_kind: TradeWorkflowKind::RevisionProposal.operation_kind(), - actor, - frozen_draft: &plan.frozen_draft, - target_policy: target_policy.workflow_target_policy(), - satisfaction_policy: satisfaction_policy, - idempotency_key, - }, - signer, - ) - .await?; - Ok(order_revision_proposal_receipt(plan, enqueue)) - } - - #[cfg(any(feature = "signer-adapters", test))] - pub(crate) fn prepare_revision_decision( - &self, - request: TradeRevisionDecisionPrepareRequest, - ) -> Result<TradeRevisionDecisionPlan, RadrootsSdkError> { - let created_at = self.resolved_created_at(request.created_at)?; - order_revision_decision_plan( - &request.actor, - request.root_event, - request.previous_event, - request.decision, - created_at, - ) - } - - #[cfg(feature = "signer-adapters")] - #[cfg(test)] - pub(crate) async fn enqueue_revision_decision( - &self, - request: TradeRevisionDecisionEnqueueRequest, - ) -> Result<TradeRevisionDecisionReceipt, RadrootsSdkError> { - let TradeRevisionDecisionEnqueueRequest { - actor, - root_event, - previous_event, - decision, - target_policy, - publish_mode, - satisfaction_policy, - idempotency_key, - created_at, - } = request; - validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; - let prepare_request = TradeRevisionDecisionPrepareRequest { - actor: actor.clone(), - root_event, - previous_event, - decision, - created_at, - }; - let plan = self.prepare_revision_decision(prepare_request)?; - self.enqueue_prepared_revision_decision( - &actor, - plan, - target_policy, - publish_mode, - satisfaction_policy, - idempotency_key, - ) - .await - } - - #[cfg(test)] - pub(crate) async fn enqueue_revision_decision_with_explicit_signer( - &self, - request: TradeRevisionDecisionEnqueueRequest, - signer: &dyn RadrootsEventSigner, - ) -> Result<TradeRevisionDecisionReceipt, RadrootsSdkError> { - let TradeRevisionDecisionEnqueueRequest { - actor, - root_event, - previous_event, - decision, - target_policy, - publish_mode, - satisfaction_policy, - idempotency_key, - created_at, - } = request; - validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; - let prepare_request = TradeRevisionDecisionPrepareRequest { - actor: actor.clone(), - root_event, - previous_event, - decision, - created_at, - }; - let plan = self.prepare_revision_decision(prepare_request)?; - self.enqueue_prepared_revision_decision_with_explicit_signer( - &actor, - plan, - target_policy, - publish_mode, - satisfaction_policy, - idempotency_key, - signer, - ) - .await - } - - #[cfg(feature = "signer-adapters")] - pub(crate) async fn enqueue_prepared_revision_decision( - &self, - actor: &RadrootsActorContext, - plan: TradeRevisionDecisionPlan, - target_policy: TargetPolicy, - publish_mode: PublishMode, - satisfaction_policy: SatisfactionPolicy, - idempotency_key: Option<SdkIdempotencyKey>, - ) -> Result<TradeRevisionDecisionReceipt, RadrootsSdkError> { - validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; - if !self - .prepared_order_event_exists(&plan.expected_event_id) - .await? - { - self.require_revision_decision_preflight(&plan).await?; - } - let enqueue = enqueue_configured_signed_workflow( - self.sdk, - SdkWorkflowEnqueueRequest { - operation_kind: TradeWorkflowKind::RevisionDecision.operation_kind(), - actor, - frozen_draft: &plan.frozen_draft, - target_policy: target_policy.workflow_target_policy(), - satisfaction_policy: satisfaction_policy, - idempotency_key, - }, - ) - .await?; - Ok(order_revision_decision_receipt(plan, enqueue)) - } - - #[cfg(test)] - pub(crate) async fn enqueue_prepared_revision_decision_with_explicit_signer( - &self, - actor: &RadrootsActorContext, - plan: TradeRevisionDecisionPlan, - target_policy: TargetPolicy, - publish_mode: PublishMode, - satisfaction_policy: SatisfactionPolicy, - idempotency_key: Option<SdkIdempotencyKey>, - signer: &dyn RadrootsEventSigner, - ) -> Result<TradeRevisionDecisionReceipt, RadrootsSdkError> { - validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; - if !self - .prepared_order_event_exists(&plan.expected_event_id) - .await? - { - self.require_revision_decision_preflight(&plan).await?; - } - let enqueue = enqueue_signed_workflow( - self.sdk, - SdkWorkflowEnqueueRequest { - operation_kind: TradeWorkflowKind::RevisionDecision.operation_kind(), - actor, - frozen_draft: &plan.frozen_draft, - target_policy: target_policy.workflow_target_policy(), - satisfaction_policy: satisfaction_policy, - idempotency_key, - }, - signer, - ) - .await?; - Ok(order_revision_decision_receipt(plan, enqueue)) - } - - #[cfg(any(feature = "signer-adapters", test))] - 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")] - #[cfg(test)] - pub(crate) async fn enqueue_cancellation( - &self, - request: TradeCancellationEnqueueRequest, - ) -> Result<TradeCancellationReceipt, RadrootsSdkError> { - let TradeCancellationEnqueueRequest { - actor, - root_event, - previous_event, - cancellation, - target_policy, - publish_mode, - satisfaction_policy, - idempotency_key, - created_at, - } = request; - validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; - let prepare_request = TradeCancellationPrepareRequest { - actor: actor.clone(), - root_event, - previous_event, - cancellation, - created_at, - }; - let plan = self.prepare_cancellation(prepare_request)?; - self.enqueue_prepared_cancellation( - &actor, - plan, - target_policy, - publish_mode, - satisfaction_policy, - idempotency_key, - ) - .await - } - - #[cfg(test)] - pub(crate) async fn enqueue_cancellation_with_explicit_signer( - &self, - request: TradeCancellationEnqueueRequest, - signer: &dyn RadrootsEventSigner, - ) -> Result<TradeCancellationReceipt, RadrootsSdkError> { - let TradeCancellationEnqueueRequest { - actor, - root_event, - previous_event, - cancellation, - target_policy, - publish_mode, - satisfaction_policy, - idempotency_key, - created_at, - } = request; - validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; - let prepare_request = TradeCancellationPrepareRequest { - actor: actor.clone(), - root_event, - previous_event, - cancellation, + ) -> 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, - }; - let plan = self.prepare_cancellation(prepare_request)?; - self.enqueue_prepared_cancellation_with_explicit_signer( - &actor, - plan, - target_policy, - publish_mode, - satisfaction_policy.clone(), - idempotency_key, - signer, ) - .await } #[cfg(feature = "signer-adapters")] - pub(crate) async fn enqueue_prepared_cancellation( + pub(crate) async fn enqueue_prepared_decision( &self, actor: &RadrootsActorContext, - plan: TradeCancellationPlan, + plan: TradeDecisionPlan, target_policy: TargetPolicy, publish_mode: PublishMode, satisfaction_policy: SatisfactionPolicy, idempotency_key: Option<SdkIdempotencyKey>, - ) -> Result<TradeCancellationReceipt, RadrootsSdkError> { + ) -> Result<TradeDecisionReceipt, 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?; + self.require_decision_preflight(&plan).await?; } let enqueue = enqueue_configured_signed_workflow( self.sdk, SdkWorkflowEnqueueRequest { - operation_kind: TradeWorkflowKind::Cancellation.operation_kind(), + operation_kind: TradeWorkflowKind::Decision.operation_kind(), actor, frozen_draft: &plan.frozen_draft, target_policy: target_policy.workflow_target_policy(), - satisfaction_policy: satisfaction_policy, + satisfaction_policy, idempotency_key, }, ) .await?; - Ok(order_cancellation_receipt(plan, enqueue)) + 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(test)] - pub(crate) async fn enqueue_prepared_cancellation_with_explicit_signer( + #[cfg(feature = "signer-adapters")] + pub(crate) async fn enqueue_prepared_cancellation( &self, actor: &RadrootsActorContext, plan: TradeCancellationPlan, @@ -3354,7 +1886,6 @@ impl<'sdk> TradesClient<'sdk> { publish_mode: PublishMode, satisfaction_policy: SatisfactionPolicy, idempotency_key: Option<SdkIdempotencyKey>, - signer: &dyn RadrootsEventSigner, ) -> Result<TradeCancellationReceipt, RadrootsSdkError> { validate_trade_enqueue_policy(publish_mode, &satisfaction_policy)?; if !self @@ -3363,17 +1894,16 @@ impl<'sdk> TradesClient<'sdk> { { self.require_cancellation_preflight(&plan).await?; } - let enqueue = enqueue_signed_workflow( + 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: satisfaction_policy, + satisfaction_policy, idempotency_key, }, - signer, ) .await?; Ok(order_cancellation_receipt(plan, enqueue)) @@ -3500,7 +2030,7 @@ impl<'sdk> TradesClient<'sdk> { } } - #[cfg(any(feature = "signer-adapters", test))] + #[cfg(feature = "signer-adapters")] async fn require_decision_preflight( &self, plan: &TradeDecisionPlan, @@ -3509,25 +2039,7 @@ impl<'sdk> TradesClient<'sdk> { require_decision_request_evidence(plan, &query_result.projection) } - #[cfg(any(feature = "signer-adapters", test))] - async fn require_revision_proposal_preflight( - &self, - plan: &TradeRevisionProposalPlan, - ) -> Result<(), RadrootsSdkError> { - let query_result = self.query_order_projection(&plan.order_id).await?; - require_revision_proposal_state(plan, &query_result.projection) - } - - #[cfg(any(feature = "signer-adapters", test))] - async fn require_revision_decision_preflight( - &self, - plan: &TradeRevisionDecisionPlan, - ) -> Result<(), RadrootsSdkError> { - let query_result = self.query_order_projection(&plan.order_id).await?; - require_revision_decision_state(plan, &query_result.projection) - } - - #[cfg(any(feature = "signer-adapters", test))] + #[cfg(feature = "signer-adapters")] async fn require_cancellation_preflight( &self, plan: &TradeCancellationPlan, @@ -3536,7 +2048,7 @@ impl<'sdk> TradesClient<'sdk> { require_cancellation_state(plan, &query_result.projection) } - #[cfg(any(feature = "signer-adapters", test))] + #[cfg(feature = "signer-adapters")] async fn query_order_projection( &self, order_id: &RadrootsOrderId, @@ -3550,7 +2062,7 @@ impl<'sdk> TradesClient<'sdk> { .map_err(projection_error) } - #[cfg(any(feature = "signer-adapters", test))] + #[cfg(feature = "signer-adapters")] async fn prepared_order_event_exists( &self, expected_event_id: &RadrootsEventId, @@ -3654,7 +2166,7 @@ impl<'sdk> TradeResyncClient<'sdk> { if status.status == TradeStatusKind::Ambiguous { return Err(RadrootsSdkError::TradeAmbiguous { operation: "trade.resync".to_owned(), - locator: request.locator, + locator: Box::new(request.locator), candidates: status .ambiguity_candidates .iter() @@ -3750,6 +2262,9 @@ fn trade_evidence_query_plan( .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() @@ -3766,13 +2281,8 @@ fn trade_evidence_query_plan( ), trade_evidence_branch( TradeEvidenceQueryBranchKind::LifecycleChain, - vec![ - KIND_ORDER_DECISION, - KIND_ORDER_REVISION_PROPOSAL, - KIND_ORDER_REVISION_DECISION, - KIND_ORDER_CANCELLATION, - ], - Some((TAG_D, vec![order_id.clone()])), + vec![KIND_ORDER_DECISION, KIND_ORDER_CANCELLATION], + Some(lifecycle_tag), None, limit, true, @@ -3797,14 +2307,6 @@ fn trade_evidence_query_plan( limit, locator.listing_addr.is_some(), ), - trade_evidence_branch( - TradeEvidenceQueryBranchKind::WorkerResults, - vec![KIND_TRADE_TRANSITION_PROOF_RESULT], - root_event_id.map(|value| (TAG_E, vec![value])), - None, - limit, - locator.root_event_id.is_some(), - ), ]; Ok(TradeEvidenceQueryPlan { locator, @@ -4227,14 +2729,6 @@ impl<'sdk> TradeValidationReceiptsClient<'sdk> { .await?; let (mut receipts, mut invalid_receipts) = classify_validation_receipts(events, Some(request.order_id.as_str()))?; - attach_worker_evidence( - self.sdk, - adapter, - &nostr_relay_urls, - &request.trusted_worker_pubkeys, - &mut receipts, - ) - .await?; receipts.sort_by(validation_receipt_event_order); invalid_receipts.sort_by(validation_receipt_invalid_order); Ok(TradeValidationReceiptListReceipt { @@ -4278,7 +2772,6 @@ impl<'sdk> TradeValidationReceiptsClient<'sdk> { validation_receipt_inspect( self.sdk, request.receipt_event_id, - request.trusted_worker_pubkeys, adapter, "trade.validation_receipts.inspect", ) @@ -4317,7 +2810,6 @@ impl<'sdk> TradeValidationReceiptsClient<'sdk> { validation_receipt_inspect( self.sdk, request.receipt_event_id, - request.trusted_worker_pubkeys, adapter, "trade.validation_receipts.verify", ) @@ -4329,7 +2821,6 @@ impl<'sdk> TradeValidationReceiptsClient<'sdk> { async fn validation_receipt_inspect<A>( sdk: &crate::RadrootsClient, receipt_event_id: RadrootsEventId, - trusted_worker_pubkeys: Vec<RadrootsPublicKey>, adapter: &A, operation: &'static str, ) -> Result<TradeValidationReceiptInspectReceipt, RadrootsSdkError> @@ -4351,14 +2842,6 @@ where 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()); - attach_worker_evidence( - sdk, - adapter, - &nostr_relay_urls, - &trusted_worker_pubkeys, - &mut receipts, - ) - .await?; receipts.sort_by(validation_receipt_event_order); invalid_receipts.sort_by(validation_receipt_invalid_order); Ok(TradeValidationReceiptInspectReceipt { @@ -4427,41 +2910,6 @@ fn validation_receipt_inspect_fetch_request( } #[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -fn validation_receipt_worker_fetch_request( - sdk: &crate::RadrootsClient, - receipts: &[TradeValidationReceiptEvent], - nostr_relay_urls: &[String], -) -> Result<Option<RadrootsRelayFetchRequest>, RadrootsSdkError> { - let root_event_ids = receipts - .iter() - .map(|receipt| receipt.tags.root_event_id.clone()) - .collect::<BTreeSet<_>>() - .into_iter() - .collect::<Vec<_>>(); - if root_event_ids.is_empty() { - return Ok(None); - } - let filter = RadrootsNostrFilter::new() - .kind(RadrootsNostrKind::Custom( - KIND_TRADE_TRANSITION_PROOF_RESULT as u16, - )) - .limit(receipts.len().saturating_mul(4).max(1)); - let filter = radroots_nostr_filter_tag(filter, TAG_E, root_event_ids).map_err(|error| { - RadrootsSdkError::InvalidRequest { - message: format!("validation receipt worker evidence filter invalid: {error}"), - } - })?; - Ok(Some( - RadrootsRelayFetchRequest::fetch( - sdk_now_ms(sdk)?, - receipts.len().saturating_mul(4).max(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], @@ -4518,7 +2966,6 @@ fn classify_validation_receipts( event, receipt: verified.receipt, tags: TradeValidationReceiptTags::from(verified.tags), - worker_evidence: TradeValidationReceiptWorkerEvidenceSelection::default(), }), Err(error) => invalid_receipts.push(TradeValidationReceiptInvalidCandidate { event, @@ -4530,180 +2977,6 @@ fn classify_validation_receipts( Ok((receipts, invalid_receipts)) } -#[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] -async fn attach_worker_evidence<A>( - sdk: &crate::RadrootsClient, - adapter: &A, - nostr_relay_urls: &[String], - trusted_worker_pubkeys: &[RadrootsPublicKey], - receipts: &mut [TradeValidationReceiptEvent], -) -> Result<(), RadrootsSdkError> -where - A: RadrootsRelayFetchAdapter, -{ - if receipts.is_empty() { - return Ok(()); - } - let Some(fetch_request) = - validation_receipt_worker_fetch_request(sdk, receipts, nostr_relay_urls)? - else { - return Ok(()); - }; - 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_TRANSITION_PROOF_RESULT, - ) - .await?; - let mut selections = worker_evidence_for_receipts(trusted_worker_pubkeys, receipts, events)?; - for receipt in receipts { - receipt.worker_evidence = selections - .remove(receipt.event.id_str()) - .unwrap_or_default(); - } - Ok(()) -} - -#[cfg(feature = "runtime")] -fn worker_evidence_for_receipts( - trusted_worker_pubkeys: &[RadrootsPublicKey], - receipts: &[TradeValidationReceiptEvent], - events: Vec<RadrootsEventEnvelope>, -) -> Result<BTreeMap<String, TradeValidationReceiptWorkerEvidenceSelection>, RadrootsSdkError> { - let trusted_pubkeys = trusted_worker_pubkeys - .iter() - .map(|pubkey| pubkey.as_str().to_ascii_lowercase()) - .collect::<BTreeSet<_>>(); - let binding_by_receipt_id = receipts - .iter() - .map(|receipt| (receipt.event.id_str(), receipt)) - .collect::<BTreeMap<_, _>>(); - let mut by_receipt = - BTreeMap::<String, Vec<(u64, String, bool, TradeValidationReceiptWorkerEvidence)>>::new(); - - for event in events { - let payload = - match serde_json::from_str::<RawTradeValidationReceiptWorkerResult>(event.content()) { - Ok(payload) => payload, - Err(_) => continue, - }; - let Some(binding) = binding_by_receipt_id.get(payload.receipt_event_id.as_str()) else { - continue; - }; - if !worker_payload_binds_receipt(&payload, binding) { - continue; - } - let author = RadrootsPublicKey::parse(event.author_str()).map_err(|error| { - RadrootsSdkError::InvalidRequest { - message: format!("validation receipt worker evidence author is invalid: {error}"), - } - })?; - let result_event_id = RadrootsEventId::parse(event.id_str()).map_err(|error| { - RadrootsSdkError::InvalidRequest { - message: format!("validation receipt worker evidence event id is invalid: {error}"), - } - })?; - let trusted = trusted_pubkeys.contains(author.as_str().to_ascii_lowercase().as_str()); - let receipt_event_id = payload.receipt_event_id.clone(); - let view = TradeValidationReceiptWorkerEvidence { - result_event_id, - author, - status: payload.status, - prover_backend: payload.prover_backend, - proof_mode: payload.proof_mode, - proof_system: payload.proof_system, - proof_generated: payload.proof_generated, - sp1_execute_checked: payload.sp1_execute_checked, - sp1_execute_public_values_hash: payload.sp1_execute_public_values_hash, - cryptographic_proof_verified: payload.cryptographic_proof_verified, - public_values_hash: payload.public_values_hash, - validation_authority: payload - .validation_authority - .as_deref() - .and_then(RadrootsTradeValidationAuthority::from_label), - commitment_confidence: payload - .confidence - .as_deref() - .and_then(RadrootsTradeCommitmentConfidence::from_label), - }; - by_receipt.entry(receipt_event_id).or_default().push(( - event.created_at_u64(), - event.id_str().to_owned(), - trusted, - view, - )); - } - - Ok(by_receipt - .into_iter() - .map(|(receipt_event_id, mut candidates)| { - candidates.sort_by(|left, right| { - left.0 - .cmp(&right.0) - .then_with(|| left.1.cmp(&right.1)) - .then_with(|| left.2.cmp(&right.2)) - }); - let mut selection = TradeValidationReceiptWorkerEvidenceSelection::default(); - for (_, _, trusted, view) in candidates.into_iter().rev() { - if trusted && selection.trusted.is_none() { - selection.trusted = Some(view); - } else if !trusted && selection.untrusted.is_none() { - selection.untrusted = Some(view); - } - if selection.trusted.is_some() && selection.untrusted.is_some() { - break; - } - } - (receipt_event_id, selection) - }) - .collect()) -} - -#[cfg(feature = "runtime")] -fn worker_payload_binds_receipt( - payload: &RawTradeValidationReceiptWorkerResult, - receipt: &TradeValidationReceiptEvent, -) -> bool { - payload.status == "succeeded" - && payload.worker_role.as_deref() == Some("non_authoritative_prover") - && payload.receipt_kind == Some(KIND_TRADE_VALIDATION_RECEIPT) - && payload.receipt_event_id == receipt.event.id_str() - && payload.order_id.as_deref() == Some(receipt.tags.order_id.as_str()) - && payload.listing_event_id.as_deref() == Some(receipt.tags.listing_event_id.as_str()) - && payload.event_set_root.as_deref() == Some(receipt.tags.event_set_root.as_str()) - && payload.reducer_output_root.as_deref() == Some(receipt.tags.reducer_output_root.as_str()) - && payload.request_event_id.as_deref() == Some(receipt.tags.root_event_id.as_str()) - && payload.decision_event_id.as_deref() == Some(receipt.tags.target_event_id.as_str()) - && payload.public_values_hash == receipt.receipt.public_values_hash - && payload.proof_system == receipt.receipt.proof.system.as_str() - && payload.proof_mode == receipt.receipt.proof.mode.as_deref().unwrap_or("none") - && payload.proof_generated - == (receipt.receipt.proof.system != RadrootsValidationReceiptProofSystem::None) - && payload.cryptographic_proof_verified == payload.proof_generated - && worker_payload_execution_binds_receipt(payload, receipt) -} - -#[cfg(feature = "runtime")] -fn worker_payload_execution_binds_receipt( - payload: &RawTradeValidationReceiptWorkerResult, - receipt: &TradeValidationReceiptEvent, -) -> bool { - if payload.sp1_execute_checked { - return payload.sp1_execute_public_values_hash.as_deref() - == Some(receipt.receipt.public_values_hash.as_str()); - } - payload.sp1_execute_public_values_hash.is_none() - && payload.proof_system == RadrootsValidationReceiptProofSystem::None.as_str() - && payload.proof_mode == "none" - && payload.validation_authority.as_deref() - == Some(RadrootsTradeValidationAuthority::DevDeterministicOnly.as_str()) - && payload.confidence.as_deref() - == Some(RadrootsTradeCommitmentConfidence::LocalOnly.as_str()) -} - #[cfg(feature = "runtime")] async fn apply_trade_status_validation_trust( sdk: &crate::RadrootsClient, @@ -4734,13 +3007,12 @@ async fn trade_status_validation_trust_decision( return Ok(trade_validation_trust_decision( policy, RadrootsTradeValidationTrustState::Pending, - None, - None, - None, - None, + TradeValidationTrustEvidence::none(), false, - Some("validation_receipt_missing"), - Some("validation receipt is not present in local product evidence"), + Some(TradeValidationTrustReason::new( + "validation_receipt_missing", + "validation receipt is not present in local product evidence", + )), )); }; let Some(stored_event) = sdk @@ -4754,26 +3026,24 @@ async fn trade_status_validation_trust_decision( return Ok(trade_validation_trust_decision( policy, RadrootsTradeValidationTrustState::Pending, - Some(receipt_event_id), - None, - None, - None, + TradeValidationTrustEvidence::receipt_event(receipt_event_id), false, - Some("validation_receipt_event_missing"), - Some("validation receipt event is referenced by projection but missing from storage"), + 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, - Some(receipt_event_id), - None, - None, - None, + TradeValidationTrustEvidence::receipt_event(receipt_event_id), false, - Some("validation_receipt_event_kind_invalid"), - Some("validation receipt reference does not point to a validation receipt event"), + 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)?; @@ -4783,13 +3053,12 @@ async fn trade_status_validation_trust_decision( return Ok(trade_validation_trust_decision( policy, RadrootsTradeValidationTrustState::Invalid, - Some(receipt_event_id), - None, - None, - None, + TradeValidationTrustEvidence::receipt_event(receipt_event_id), false, - Some("validation_receipt_author_invalid"), - Some("validation receipt author is not a valid public key"), + Some(TradeValidationTrustReason::new( + "validation_receipt_author_invalid", + "validation receipt author is not a valid public key", + )), )); } }; @@ -4813,13 +3082,12 @@ async fn trade_status_validation_trust_decision( return Ok(trade_validation_trust_decision( policy, RadrootsTradeValidationTrustState::Invalid, - Some(receipt_event_id), - Some(receipt_author), - None, - None, + TradeValidationTrustEvidence::receipt_author(receipt_event_id, receipt_author), false, - Some("validation_receipt_invalid"), - Some(reason.as_str()), + Some(TradeValidationTrustReason::new( + "validation_receipt_invalid", + reason.as_str(), + )), )); } }; @@ -4827,149 +3095,153 @@ async fn trade_status_validation_trust_decision( event, receipt: verified.receipt, tags: TradeValidationReceiptTags::from(verified.tags), - worker_evidence: TradeValidationReceiptWorkerEvidenceSelection::default(), }; if receipt.receipt.result == RadrootsValidationReceiptResult::Invalid { return Ok(trade_validation_trust_decision( policy, RadrootsTradeValidationTrustState::Invalid, - Some(receipt_event_id), - Some(receipt_author), - Some(&receipt.receipt), - None, + TradeValidationTrustEvidence::receipt( + receipt_event_id, + receipt_author, + &receipt.receipt, + None, + ), false, - Some("validation_receipt_result_invalid"), - Some("validation receipt reports an invalid trade transition"), + Some(TradeValidationTrustReason::new( + "validation_receipt_result_invalid", + "validation receipt reports an invalid trade transition", + )), )); } if policy.trusted_rhi_pubkeys.is_empty() { return Ok(trade_validation_trust_decision( policy, RadrootsTradeValidationTrustState::Untrusted, - Some(receipt_event_id), - Some(receipt_author), - Some(&receipt.receipt), - None, + TradeValidationTrustEvidence::receipt( + receipt_event_id, + receipt_author, + &receipt.receipt, + None, + ), false, - Some("validation_trust_policy_empty"), - Some("validation trust policy has no trusted RHI public keys"), + Some(TradeValidationTrustReason::new( + "validation_trust_policy_empty", + "validation trust policy has no trusted RHI public keys", + )), )); } if !policy.trusts_rhi_pubkey(&receipt_author) { return Ok(trade_validation_trust_decision( policy, RadrootsTradeValidationTrustState::Untrusted, - Some(receipt_event_id), - Some(receipt_author), - Some(&receipt.receipt), - None, + TradeValidationTrustEvidence::receipt( + receipt_event_id, + receipt_author, + &receipt.receipt, + None, + ), false, - Some("validation_receipt_author_untrusted"), - Some("validation receipt author is not trusted by the active policy"), + Some(TradeValidationTrustReason::new( + "validation_receipt_author_untrusted", + "validation receipt author is not trusted by the active policy", + )), )); } - let result_events = - validation_receipt_worker_result_events_for_receipt(sdk, &receipt_event_id).await?; - let mut selections = worker_evidence_for_receipts( - &policy.trusted_rhi_pubkeys, - core::slice::from_ref(&receipt), - result_events, - )?; - let selection = selections - .remove(receipt_event_id.as_str()) - .unwrap_or_default(); - let Some(evidence) = selection.trusted.as_ref() else { - if selection.untrusted.is_some() { - return Ok(trade_validation_trust_decision( - policy, - RadrootsTradeValidationTrustState::Untrusted, - Some(receipt_event_id), - Some(receipt_author), - Some(&receipt.receipt), - selection.untrusted.as_ref(), - false, - Some("validation_result_author_untrusted"), - Some("validation result metadata was produced by an untrusted RHI public key"), - )); - } - return Ok(trade_validation_trust_decision( - policy, - RadrootsTradeValidationTrustState::Pending, - Some(receipt_event_id), - Some(receipt_author), - Some(&receipt.receipt), - None, - false, - Some("validation_result_metadata_missing"), - Some("trusted validation result metadata is not present in local product evidence"), - )); - }; - Ok(evaluate_trade_validation_trust_evidence( + Ok(evaluate_trade_validation_trust_receipt( policy, receipt_event_id, receipt_author, &receipt.receipt, - evidence, )) } #[cfg(feature = "runtime")] -async fn validation_receipt_worker_result_events_for_receipt( - sdk: &crate::RadrootsClient, - receipt_event_id: &RadrootsEventId, -) -> Result<Vec<RadrootsEventEnvelope>, RadrootsSdkError> { - let events = sdk - ._event_store - .events_by_tag( - RADROOTS_DVM_TAG_VALIDATION_RECEIPT, - receipt_event_id.as_str(), - TRADE_STATUS_MAX_LIMIT, - ) - .await - .map_err(|error| RadrootsSdkError::EventStore { - message: error.to_string(), - })?; - events - .into_iter() - .filter(|event| event.kind == KIND_TRADE_TRANSITION_PROOF_RESULT) - .map(|event| stored_event_to_nostr_event(&event)) - .collect() +#[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_evidence( +fn evaluate_trade_validation_trust_receipt( policy: &RadrootsTradeValidationTrustPolicy, receipt_event_id: RadrootsEventId, receipt_author: RadrootsPublicKey, receipt: &RadrootsTradeValidationReceipt, - evidence: &TradeValidationReceiptWorkerEvidence, ) -> TradeValidationTrustDecision { - let Some(authority) = evidence.validation_authority else { - return trade_validation_trust_decision( - policy, - RadrootsTradeValidationTrustState::Pending, - Some(receipt_event_id), - Some(receipt_author), - Some(receipt), - Some(evidence), - false, - Some("validation_authority_missing"), - Some("trusted validation result metadata does not declare validation authority"), - ); - }; - let Some(confidence) = evidence.commitment_confidence else { - return trade_validation_trust_decision( - policy, - RadrootsTradeValidationTrustState::Pending, - Some(receipt_event_id), - Some(receipt_author), - Some(receipt), - Some(evidence), - false, - Some("commitment_confidence_missing"), - Some("trusted validation result metadata does not declare commitment confidence"), - ); - }; + let metadata = validation_receipt_trust_metadata(receipt); + let authority = metadata.validation_authority; + let confidence = metadata.commitment_confidence; if authority == RadrootsTradeValidationAuthority::DevDeterministicOnly || confidence == RadrootsTradeCommitmentConfidence::LocalOnly { @@ -4977,38 +3249,47 @@ fn evaluate_trade_validation_trust_evidence( return trade_validation_trust_decision( policy, RadrootsTradeValidationTrustState::Untrusted, - Some(receipt_event_id), - Some(receipt_author), - Some(receipt), - Some(evidence), + TradeValidationTrustEvidence::receipt( + receipt_event_id, + receipt_author, + receipt, + Some(metadata), + ), false, - Some("deterministic_none_not_allowed"), - Some("deterministic-none validation is not allowed by the active policy"), + Some(TradeValidationTrustReason::new( + "deterministic_none_not_allowed", + "deterministic-none validation is not allowed by the active policy", + )), ); } if policy.require_cryptographic_proof { return trade_validation_trust_decision( policy, RadrootsTradeValidationTrustState::Pending, - Some(receipt_event_id), - Some(receipt_author), - Some(receipt), - Some(evidence), + TradeValidationTrustEvidence::receipt( + receipt_event_id, + receipt_author, + receipt, + Some(metadata), + ), false, - Some("cryptographic_proof_required"), - Some("active policy requires cryptographic proof for committed confidence"), + Some(TradeValidationTrustReason::new( + "cryptographic_proof_required", + "active policy requires cryptographic proof for committed confidence", + )), ); } return trade_validation_trust_decision( policy, RadrootsTradeValidationTrustState::TrustedLocal, - Some(receipt_event_id), - Some(receipt_author), - Some(receipt), - Some(evidence), + TradeValidationTrustEvidence::receipt( + receipt_event_id, + receipt_author, + receipt, + Some(metadata), + ), false, None, - None, ); } let cryptographic_metadata = matches!( @@ -5023,31 +3304,36 @@ fn evaluate_trade_validation_trust_evidence( ); if cryptographic_metadata && receipt.proof.system != RadrootsValidationReceiptProofSystem::None - && evidence.cryptographic_proof_verified + && metadata.cryptographic_proof_verified { return trade_validation_trust_decision( policy, RadrootsTradeValidationTrustState::CryptographicCommitted, - Some(receipt_event_id), - Some(receipt_author), - Some(receipt), - Some(evidence), + TradeValidationTrustEvidence::receipt( + receipt_event_id, + receipt_author, + receipt, + Some(metadata), + ), true, None, - None, ); } if policy.require_cryptographic_proof { return trade_validation_trust_decision( policy, RadrootsTradeValidationTrustState::Pending, - Some(receipt_event_id), - Some(receipt_author), - Some(receipt), - Some(evidence), + TradeValidationTrustEvidence::receipt( + receipt_event_id, + receipt_author, + receipt, + Some(metadata), + ), false, - Some("cryptographic_proof_required"), - Some("active policy requires trusted cryptographic proof metadata"), + Some(TradeValidationTrustReason::new( + "cryptographic_proof_required", + "active policy requires trusted cryptographic proof metadata", + )), ); } if matches!( @@ -5066,58 +3352,92 @@ fn evaluate_trade_validation_trust_evidence( return trade_validation_trust_decision( policy, RadrootsTradeValidationTrustState::TrustedLocal, - Some(receipt_event_id), - Some(receipt_author), - Some(receipt), - Some(evidence), + TradeValidationTrustEvidence::receipt( + receipt_event_id, + receipt_author, + receipt, + Some(metadata), + ), false, None, - None, ); } trade_validation_trust_decision( policy, RadrootsTradeValidationTrustState::Pending, - Some(receipt_event_id), - Some(receipt_author), - Some(receipt), - Some(evidence), + TradeValidationTrustEvidence::receipt( + receipt_event_id, + receipt_author, + receipt, + Some(metadata), + ), false, - Some("validation_trust_metadata_insufficient"), - Some("trusted validation result metadata does not satisfy the active policy"), + 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::DevDeterministicOnly, + commitment_confidence: RadrootsTradeCommitmentConfidence::LocalOnly, + cryptographic_proof_verified: false, + } + } else { + TradeValidationTrustMetadata { + validation_authority: RadrootsTradeValidationAuthority::CryptographicProofVerified, + commitment_confidence: RadrootsTradeCommitmentConfidence::CommittedByCryptographicProof, + cryptographic_proof_verified: true, + } + } +} + +#[cfg(feature = "runtime")] fn trade_validation_trust_decision( policy: &RadrootsTradeValidationTrustPolicy, state: RadrootsTradeValidationTrustState, - receipt_event_id: Option<RadrootsEventId>, - receipt_author: Option<RadrootsPublicKey>, - receipt: Option<&RadrootsTradeValidationReceipt>, - evidence: Option<&TradeValidationReceiptWorkerEvidence>, + evidence: TradeValidationTrustEvidence<'_>, production_committed: bool, - reason_code: Option<&str>, - reason: Option<&str>, + 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, trusted_rhi_pubkey_count: policy.trusted_rhi_pubkey_count(), allow_deterministic_none: policy.allow_deterministic_none, require_cryptographic_proof: policy.require_cryptographic_proof, - receipt_event_id, - receipt_author, - result_event_id: evidence.map(|evidence| evidence.result_event_id.clone()), - result_author: evidence.map(|evidence| evidence.author.clone()), - proof_system: receipt.map(|receipt| receipt.proof.system.as_str().to_owned()), - validation_authority: evidence.and_then(|evidence| evidence.validation_authority), - commitment_confidence: evidence.and_then(|evidence| evidence.commitment_confidence), + 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 - .is_some_and(|evidence| evidence.cryptographic_proof_verified), + .metadata + .is_some_and(|metadata| metadata.cryptographic_proof_verified), production_committed, - reason_code: reason_code.map(str::to_owned), - reason: reason.map(str::to_owned), + reason_code, + reason, } } @@ -5130,9 +3450,9 @@ fn apply_validation_trust_decision_to_status(status: &mut TradeStatusReceipt) { RadrootsTradeValidationTrustState::Pending | RadrootsTradeValidationTrustState::Untrusted => { if status.status == TradeStatusKind::Committed { - status.status = TradeStatusKind::AgreedPendingRhi; + status.status = TradeStatusKind::AgreedPendingValidation; status.lifecycle_terminal = false; - status.next_action = TradeStatusNextActionKind::AwaitRhiValidation; + status.next_action = TradeStatusNextActionKind::AwaitValidation; status.last_event_id = status .agreement_event_id .clone() @@ -5150,31 +3470,6 @@ fn apply_validation_trust_decision_to_status(status: &mut TradeStatusReceipt) { } } -#[cfg(feature = "runtime")] -#[derive(Debug, Deserialize)] -struct RawTradeValidationReceiptWorkerResult { - cryptographic_proof_verified: bool, - decision_event_id: Option<String>, - event_set_root: Option<String>, - listing_event_id: Option<String>, - order_id: Option<String>, - proof_generated: bool, - proof_mode: String, - proof_system: String, - public_values_hash: String, - prover_backend: String, - receipt_event_id: String, - receipt_kind: Option<u32>, - reducer_output_root: Option<String>, - request_event_id: Option<String>, - sp1_execute_checked: bool, - sp1_execute_public_values_hash: Option<String>, - status: String, - validation_authority: Option<String>, - confidence: Option<String>, - worker_role: Option<String>, -} - #[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] impl TradeValidationReceiptNostrEvidenceReceipt { fn from_fetch(receipt: RadrootsRelayFetchReceipt) -> Self { @@ -5499,102 +3794,6 @@ impl<'sdk> TradeBuyerClient<'sdk> { ) .await } - - pub async fn accept_revision( - &self, - mut request: TradeRevisionDecisionRequest, - ) -> Result< - TradeMutationOutcome<TradeRevisionDecisionPlan, TradeRevisionDecisionReceipt>, - RadrootsSdkError, - > { - request.decision = RadrootsOrderRevisionOutcome::Accepted; - self.decide_revision(request).await - } - - pub async fn decline_revision( - &self, - request: TradeRevisionDecisionRequest, - ) -> Result< - TradeMutationOutcome<TradeRevisionDecisionPlan, TradeRevisionDecisionReceipt>, - RadrootsSdkError, - > { - self.decide_revision(request).await - } - - async fn decide_revision( - &self, - request: TradeRevisionDecisionRequest, - ) -> Result< - TradeMutationOutcome<TradeRevisionDecisionPlan, TradeRevisionDecisionReceipt>, - RadrootsSdkError, - > { - validate_trade_product_publish_policy(request.publish_mode, &request.satisfaction_policy)?; - let TradeRevisionDecisionRequest { - actor, - locator, - revision_id, - decision, - target_policy, - publish_mode, - satisfaction_policy, - evidence_mode, - privacy_confirmation, - idempotency_key, - created_at, - } = request; - require_trade_product_privacy_preflight( - "trade.revision_decision", - trade_revision_decision_privacy_fields(&decision), - &privacy_confirmation, - )?; - let context = - trade_mutation_context(self.sdk, locator, evidence_mode, "trade.revision_decision") - .await?; - let previous_event_id = context.pending_revision_event_id.clone().ok_or_else(|| { - RadrootsSdkError::InvalidRequest { - message: "trade revision decision requires a pending revision".to_owned(), - } - })?; - let decision = RadrootsOrderRevisionDecision { - revision_id, - order_id: context.order_id.clone(), - listing_addr: context.listing_addr.clone(), - buyer_pubkey: context.buyer_pubkey.clone(), - seller_pubkey: context.seller_pubkey.clone(), - root_event_id: context.root_event_id.clone(), - prev_event_id: previous_event_id.clone(), - decision, - }; - let client = trades_client(self.sdk); - let plan = client.prepare_revision_decision(TradeRevisionDecisionPrepareRequest { - actor: actor.clone(), - root_event: event_ptr(&context.root_event_id), - previous_event: event_ptr(&previous_event_id), - decision, - created_at, - })?; - if publish_mode == PublishMode::DryRun { - return Ok(TradeMutationOutcome::DryRun { plan }); - } - let receipt = client - .enqueue_prepared_revision_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")] @@ -5646,16 +3845,15 @@ impl<'sdk> TradeSellerClient<'sdk> { })?; if let Ok(RadrootsOrderEventRecord::Request(record)) = order_event_record_from_event(&nostr_event) + && record.payload.seller_pubkey == seller_pubkey { - if record.payload.seller_pubkey == seller_pubkey { - locators.push( - RadrootsTradeLocator::from_order_id(record.payload.order_id) - .with_root_event_id(record.event_id) - .with_listing_addr(record.payload.listing_addr) - .with_buyer_pubkey(record.payload.buyer_pubkey) - .with_seller_pubkey(record.payload.seller_pubkey), - ); - } + locators.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)); @@ -5875,81 +4073,6 @@ impl<'sdk> TradeSellerClient<'sdk> { ) .await } - - #[cfg(feature = "signer-adapters")] - pub async fn propose_revision( - &self, - request: TradeRevisionProposalRequest, - ) -> Result< - TradeMutationOutcome<TradeRevisionProposalPlan, TradeRevisionProposalReceipt>, - RadrootsSdkError, - > { - validate_trade_product_publish_policy(request.publish_mode, &request.satisfaction_policy)?; - let TradeRevisionProposalRequest { - actor, - locator, - revision_id, - items, - economics, - 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.propose_revision") - .await?; - let proposal = RadrootsOrderRevisionProposal { - revision_id, - order_id: context.order_id.clone(), - listing_addr: context.listing_addr.clone(), - buyer_pubkey: context.buyer_pubkey.clone(), - seller_pubkey: context.seller_pubkey.clone(), - root_event_id: context.root_event_id.clone(), - prev_event_id: context.previous_event_id.clone(), - items, - economics, - reason, - }; - require_trade_product_privacy_preflight( - "trade.propose_revision", - trade_revision_proposal_privacy_fields(&proposal), - &privacy_confirmation, - )?; - let client = trades_client(self.sdk); - let plan = client.prepare_revision_proposal(TradeRevisionProposalPrepareRequest { - actor: actor.clone(), - root_event: event_ptr(&context.root_event_id), - previous_event: event_ptr(&context.previous_event_id), - proposal, - created_at, - })?; - if publish_mode == PublishMode::DryRun { - return Ok(TradeMutationOutcome::DryRun { plan }); - } - let receipt = client - .enqueue_prepared_revision_proposal( - &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")] @@ -5965,7 +4088,6 @@ struct TradeProductMutationContext { seller_pubkey: RadrootsPublicKey, root_event_id: RadrootsEventId, previous_event_id: RadrootsEventId, - pending_revision_event_id: Option<RadrootsEventId>, } #[cfg(feature = "signer-adapters")] @@ -6082,7 +4204,7 @@ fn trade_mutation_context_from_status( if status.status == TradeStatusKind::Ambiguous { return Err(RadrootsSdkError::TradeAmbiguous { operation: operation.to_owned(), - locator, + locator: Box::new(locator), candidates: status .ambiguity_candidates .into_iter() @@ -6142,7 +4264,6 @@ fn trade_mutation_context_from_status( seller_pubkey, root_event_id, previous_event_id, - pending_revision_event_id: status.pending_revision_event_id, }) } @@ -6307,27 +4428,6 @@ fn trade_decision_privacy_fields(decision: &RadrootsOrderDecision) -> Vec<Produc } #[cfg(feature = "signer-adapters")] -fn trade_revision_proposal_privacy_fields( - proposal: &RadrootsOrderRevisionProposal, -) -> Vec<ProductSensitivityField> { - let mut fields = trade_reason_privacy_fields(&proposal.reason); - if !proposal.items.is_empty() || !proposal.economics.items.is_empty() { - fields.push(ProductSensitivityField::ProtocolMinimizedInventoryFields); - } - fields -} - -#[cfg(feature = "signer-adapters")] -fn trade_revision_decision_privacy_fields( - decision: &RadrootsOrderRevisionOutcome, -) -> Vec<ProductSensitivityField> { - match decision { - RadrootsOrderRevisionOutcome::Accepted => Vec::new(), - RadrootsOrderRevisionOutcome::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(); @@ -6359,7 +4459,7 @@ fn trade_reason_contains_private_coordination(reason: &str) -> bool { .any(|marker| reason.contains(marker)) } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] fn validate_trade_enqueue_policy( publish_mode: PublishMode, satisfaction_policy: &SatisfactionPolicy, @@ -6415,18 +4515,6 @@ fn parse_order_evidence( .payload; (payload.order_id, payload.listing_addr) } - KIND_ORDER_REVISION_PROPOSAL => { - let payload = order_revision_proposal_from_event(event) - .map_err(order_evidence_parse_error)? - .payload; - (payload.order_id, payload.listing_addr) - } - KIND_ORDER_REVISION_DECISION => { - let payload = order_revision_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)? @@ -6533,7 +4621,6 @@ impl TradeStatusReceipt { decision_event_id: projection.decision_event_id, agreement_event_id: projection.agreement_event_id, rhi_receipt_event_id: projection.validation_receipt_event_id, - pending_revision_event_id: projection.pending_revision_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(), @@ -6560,7 +4647,6 @@ impl TradeStatusReceipt { lifecycle_terminal: false, economics: None, agreement_event_id: None, - pending_revision_event_id: None, pending_inventory_reservations: Vec::new(), committed_inventory_reservations: Vec::new(), listing_addr: locator.listing_addr.clone(), @@ -6610,7 +4696,6 @@ impl TradeStatusEvidenceSummary { 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_pending_revision: projection.pending_revision_event_id.is_some(), has_cancellation: projection.cancellation_event_id.is_some(), has_issues: !projection.issues.is_empty(), } @@ -6623,17 +4708,10 @@ impl TradeStatusEligibility { let clean = projection.issues.is_empty(); let open = clean && !projection.lifecycle_terminal; let requested = projection.status == RadrootsTradeWorkflowState::Requested; - let revision_proposed = projection.status == RadrootsTradeWorkflowState::RevisionProposed; - let has_pending_revision = projection.pending_revision_event_id.is_some(); Self { - can_decide: open - && requested - && projection.decision_event_id.is_none() - && !has_pending_revision, - can_propose_revision: open && requested && !has_pending_revision, - can_decide_revision: open && revision_proposed && has_pending_revision, - can_cancel: open && requested && !has_pending_revision, + can_decide: open && requested && projection.decision_event_id.is_none(), + can_cancel: open && requested, } } } @@ -6651,8 +4729,8 @@ impl TradeStatusNextActionKind { { return Self::InspectEvidenceIssues; } - if projection.status == RadrootsTradeWorkflowState::AgreedPendingRhi { - return Self::AwaitRhiValidation; + if projection.status == RadrootsTradeWorkflowState::AgreedPendingValidation { + return Self::AwaitValidation; } if projection.lifecycle_terminal { return Self::Terminal; @@ -6660,14 +4738,11 @@ impl TradeStatusNextActionKind { if eligibility.can_decide { return Self::AwaitSellerDecision; } - if eligibility.can_decide_revision { - return Self::DecideRevision; - } Self::Terminal } } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] fn order_submit_plan( actor: &RadrootsActorContext, listing_event: RadrootsEventPtr, @@ -6714,157 +4789,42 @@ fn order_submit_plan( }) } -#[cfg(any(feature = "signer-adapters", test))] +#[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(any(feature = "signer-adapters", test))] -fn order_revision_proposal_plan( - actor: &RadrootsActorContext, - root_event: RadrootsEventPtr, - previous_event: RadrootsEventPtr, - proposal: RadrootsOrderRevisionProposal, - created_at: RadrootsSdkTimestamp, -) -> Result<TradeRevisionProposalPlan, RadrootsSdkError> { - require_seller_actor(actor, "trade.prepare_revision_proposal")?; - let root_event_id = order_reference_event_id(&root_event, "root")?; - let previous_event_id = order_reference_event_id(&previous_event, "previous")?; - if proposal.seller_pubkey.as_str() != actor.pubkey().as_str() { - return Err(RadrootsSdkError::UnauthorizedActor { - operation: "trade.prepare_revision_proposal".to_owned(), - reason: "actor pubkey must match order seller_pubkey".to_owned(), - }); - } - require_payload_event_refs( - "order revision proposal", - &proposal.root_event_id, - &proposal.prev_event_id, - &root_event_id, - &previous_event_id, - )?; - let created_at_nostr = created_at.try_into_nostr_created_at()?; - let order_id = proposal.order_id.clone(); - let listing_addr = proposal.listing_addr.clone(); - let buyer_pubkey = proposal.buyer_pubkey.clone(); - let seller_pubkey = proposal.seller_pubkey.clone(); - validate_order_payload(&proposal, "order revision proposal")?; - let draft = - order::build_order_revision_proposal_draft(&root_event_id, &previous_event_id, &proposal) - .expect("validated order revision proposal draft encodes"); - let (frozen_draft, expected_event_id) = freeze_order_workflow_draft( - draft.into_wire_parts(), - TRADE_REVISION_PROPOSAL_CONTRACT_ID, - seller_pubkey.as_str(), - created_at_nostr, - "order revision proposal", - ); - Ok(TradeRevisionProposalPlan { - workflow: order_workflow_plan( - TradeWorkflowKind::RevisionProposal, - 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(any(feature = "signer-adapters", test))] -fn order_revision_decision_plan( - actor: &RadrootsActorContext, - root_event: RadrootsEventPtr, - previous_event: RadrootsEventPtr, - decision: RadrootsOrderRevisionDecision, - created_at: RadrootsSdkTimestamp, -) -> Result<TradeRevisionDecisionPlan, RadrootsSdkError> { - require_buyer_actor(actor, "trade.prepare_revision_decision")?; - let root_event_id = order_reference_event_id(&root_event, "root")?; - let previous_event_id = order_reference_event_id(&previous_event, "previous")?; - if decision.buyer_pubkey.as_str() != actor.pubkey().as_str() { +) -> 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_revision_decision".to_owned(), - reason: "actor pubkey must match order buyer_pubkey".to_owned(), + operation: "trade.prepare_decision".to_owned(), + reason: "actor pubkey must match order seller_pubkey".to_owned(), }); } - require_payload_event_refs( - "order revision decision", - &decision.root_event_id, - &decision.prev_event_id, - &root_event_id, - &previous_event_id, - )?; + 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 revision decision")?; - let draft = - order::build_order_revision_decision_draft(&root_event_id, &previous_event_id, &decision) - .expect("validated order revision decision draft encodes"); + 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_REVISION_DECISION_CONTRACT_ID, - buyer_pubkey.as_str(), + TRADE_DECISION_CONTRACT_ID, + decision.seller_pubkey.as_str(), created_at_nostr, - "order revision decision", + "trade.prepare_decision", ); - Ok(TradeRevisionDecisionPlan { + Ok(TradeDecisionPlan { workflow: order_workflow_plan( - TradeWorkflowKind::RevisionDecision, + TradeWorkflowKind::Decision, expected_event_id.clone(), created_at, ), @@ -6872,15 +4832,14 @@ fn order_revision_decision_plan( listing_addr, buyer_pubkey, seller_pubkey, - root_event_id, - previous_event_id, + request_event_id, expected_event_id, frozen_draft, created_at, }) } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] fn order_cancellation_plan( actor: &RadrootsActorContext, root_event: RadrootsEventPtr, @@ -6931,7 +4890,7 @@ fn order_cancellation_plan( }) } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] fn order_workflow_plan( kind: TradeWorkflowKind, expected_event_id: RadrootsEventId, @@ -6946,7 +4905,7 @@ fn order_workflow_plan( } } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] fn order_submit_receipt( plan: TradeSubmitPlan, enqueue: crate::workflow_runtime::SdkWorkflowEnqueueReceipt, @@ -6978,7 +4937,7 @@ fn order_submit_receipt( } } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] fn order_decision_receipt( plan: TradeDecisionPlan, enqueue: crate::workflow_runtime::SdkWorkflowEnqueueReceipt, @@ -7010,73 +4969,7 @@ fn order_decision_receipt( } } -#[cfg(any(feature = "signer-adapters", test))] -fn order_revision_proposal_receipt( - plan: TradeRevisionProposalPlan, - enqueue: crate::workflow_runtime::SdkWorkflowEnqueueReceipt, -) -> TradeRevisionProposalReceipt { - let locator = RadrootsTradeLocator::from_order_id(plan.order_id.clone()) - .with_root_event_id(plan.root_event_id.clone()) - .with_listing_addr(plan.listing_addr.clone()) - .with_buyer_pubkey(plan.buyer_pubkey.clone()) - .with_seller_pubkey(plan.seller_pubkey.clone()); - TradeRevisionProposalReceipt { - workflow: order_workflow_enqueue_receipt( - TradeWorkflowKind::RevisionProposal, - plan.expected_event_id.clone(), - &enqueue, - ), - locator, - order_id: plan.order_id, - listing_addr: plan.listing_addr, - buyer_pubkey: plan.buyer_pubkey, - 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(any(feature = "signer-adapters", test))] -fn order_revision_decision_receipt( - plan: TradeRevisionDecisionPlan, - enqueue: crate::workflow_runtime::SdkWorkflowEnqueueReceipt, -) -> TradeRevisionDecisionReceipt { - let locator = RadrootsTradeLocator::from_order_id(plan.order_id.clone()) - .with_root_event_id(plan.root_event_id.clone()) - .with_listing_addr(plan.listing_addr.clone()) - .with_buyer_pubkey(plan.buyer_pubkey.clone()) - .with_seller_pubkey(plan.seller_pubkey.clone()); - TradeRevisionDecisionReceipt { - workflow: order_workflow_enqueue_receipt( - TradeWorkflowKind::RevisionDecision, - plan.expected_event_id.clone(), - &enqueue, - ), - locator, - order_id: plan.order_id, - listing_addr: plan.listing_addr, - buyer_pubkey: plan.buyer_pubkey, - 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(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] fn order_cancellation_receipt( plan: TradeCancellationPlan, enqueue: crate::workflow_runtime::SdkWorkflowEnqueueReceipt, @@ -7109,7 +5002,7 @@ fn order_cancellation_receipt( } } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] fn order_workflow_enqueue_receipt( kind: TradeWorkflowKind, expected_event_id: RadrootsEventId, @@ -7142,7 +5035,7 @@ fn order_workflow_enqueue_receipt( } } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] fn freeze_order_workflow_draft( parts: RadrootsNip01EventWireParts, contract_id: &str, @@ -7164,7 +5057,7 @@ fn freeze_order_workflow_draft( (frozen_draft, expected_event_id) } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] fn validate_order_payload<T>(payload: &T, operation: &'static str) -> Result<(), RadrootsSdkError> where T: OrderPayloadValidate, @@ -7176,14 +5069,14 @@ where }) } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] trait OrderPayloadValidate { fn validate_order_payload( &self, ) -> Result<(), radroots_event::order::RadrootsOrderPayloadError>; } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] impl OrderPayloadValidate for RadrootsOrderDecision { fn validate_order_payload( &self, @@ -7192,25 +5085,7 @@ impl OrderPayloadValidate for RadrootsOrderDecision { } } -#[cfg(any(feature = "signer-adapters", test))] -impl OrderPayloadValidate for RadrootsOrderRevisionProposal { - fn validate_order_payload( - &self, - ) -> Result<(), radroots_event::order::RadrootsOrderPayloadError> { - self.validate() - } -} - -#[cfg(any(feature = "signer-adapters", test))] -impl OrderPayloadValidate for RadrootsOrderRevisionDecision { - fn validate_order_payload( - &self, - ) -> Result<(), radroots_event::order::RadrootsOrderPayloadError> { - self.validate() - } -} - -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] impl OrderPayloadValidate for RadrootsOrderCancellation { fn validate_order_payload( &self, @@ -7260,7 +5135,7 @@ fn sdk_timestamp_ms(timestamp: RadrootsSdkTimestamp) -> Result<i64, RadrootsSdkE i64::try_from(millis).map_err(|_| RadrootsSdkError::TimestampOutOfRange { value: seconds }) } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] fn require_decision_request_evidence( plan: &TradeDecisionPlan, projection: &RadrootsOrderProjection, @@ -7289,14 +5164,6 @@ fn require_decision_request_evidence( ), }); } - if let Some(pending_revision_event_id) = projection.pending_revision_event_id.as_ref() { - return Err(RadrootsSdkError::InvalidRequest { - message: format!( - "order decision for order {} cannot follow pending revision proposal {}", - plan.order_id, pending_revision_event_id - ), - }); - } if !projection.issues.is_empty() { return Err(RadrootsSdkError::InvalidRequest { message: format!( @@ -7330,7 +5197,7 @@ fn require_decision_request_evidence( Ok(()) } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] #[derive(Clone, Copy)] struct OrderLifecycleReferences<'a> { operation: &'static str, @@ -7342,53 +5209,7 @@ struct OrderLifecycleReferences<'a> { previous_event_id: &'a RadrootsEventId, } -#[cfg(any(feature = "signer-adapters", test))] -fn require_revision_proposal_state( - plan: &TradeRevisionProposalPlan, - projection: &RadrootsOrderProjection, -) -> Result<(), RadrootsSdkError> { - let refs = OrderLifecycleReferences { - operation: "order revision proposal", - 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)?; - require_lifecycle_status(&refs, projection, RadrootsTradeWorkflowState::Requested)?; - require_no_lifecycle_terminal(&refs, projection)?; - require_no_pending_revision(&refs, projection)?; - require_lifecycle_previous_is_current(&refs, projection) -} - -#[cfg(any(feature = "signer-adapters", test))] -fn require_revision_decision_state( - plan: &TradeRevisionDecisionPlan, - projection: &RadrootsOrderProjection, -) -> Result<(), RadrootsSdkError> { - let refs = OrderLifecycleReferences { - operation: "order revision decision", - 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)?; - require_lifecycle_status( - &refs, - projection, - RadrootsTradeWorkflowState::RevisionProposed, - )?; - require_no_lifecycle_terminal(&refs, projection)?; - require_pending_revision(&refs, projection)?; - require_lifecycle_previous_is_current(&refs, projection) -} - -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] fn require_cancellation_state( plan: &TradeCancellationPlan, projection: &RadrootsOrderProjection, @@ -7414,11 +5235,10 @@ fn require_cancellation_state( )); } require_no_lifecycle_terminal(&refs, projection)?; - require_no_pending_revision(&refs, projection)?; require_lifecycle_previous_is_current(&refs, projection) } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] fn require_clean_lifecycle_projection( refs: OrderLifecycleReferences<'_>, projection: &RadrootsOrderProjection, @@ -7473,27 +5293,7 @@ fn require_clean_lifecycle_projection( ) } -#[cfg(any(feature = "signer-adapters", test))] -fn require_lifecycle_status( - refs: &OrderLifecycleReferences<'_>, - projection: &RadrootsOrderProjection, - expected: RadrootsTradeWorkflowState, -) -> Result<(), RadrootsSdkError> { - if projection.status == expected { - Ok(()) - } else { - Err(lifecycle_invalid( - refs.operation, - refs.order_id, - format!( - "requires {:?} local state; current state is {:?}", - expected, projection.status - ), - )) - } -} - -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] fn require_no_lifecycle_terminal( refs: &OrderLifecycleReferences<'_>, projection: &RadrootsOrderProjection, @@ -7509,48 +5309,7 @@ fn require_no_lifecycle_terminal( } } -#[cfg(any(feature = "signer-adapters", test))] -fn require_pending_revision( - refs: &OrderLifecycleReferences<'_>, - projection: &RadrootsOrderProjection, -) -> Result<(), RadrootsSdkError> { - match projection.pending_revision_event_id.as_ref() { - Some(pending_revision_event_id) if pending_revision_event_id == refs.previous_event_id => { - Ok(()) - } - Some(pending_revision_event_id) => Err(lifecycle_invalid( - refs.operation, - refs.order_id, - format!( - "previous event {} does not match pending revision proposal {}", - refs.previous_event_id, pending_revision_event_id - ), - )), - None => Err(lifecycle_invalid( - refs.operation, - refs.order_id, - "requires pending revision proposal local state", - )), - } -} - -#[cfg(any(feature = "signer-adapters", test))] -fn require_no_pending_revision( - refs: &OrderLifecycleReferences<'_>, - projection: &RadrootsOrderProjection, -) -> Result<(), RadrootsSdkError> { - if let Some(pending_revision_event_id) = projection.pending_revision_event_id.as_ref() { - Err(lifecycle_invalid( - refs.operation, - refs.order_id, - format!("cannot follow pending revision proposal {pending_revision_event_id}"), - )) - } else { - Ok(()) - } -} - -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] fn require_lifecycle_previous_is_current( refs: &OrderLifecycleReferences<'_>, projection: &RadrootsOrderProjection, @@ -7573,7 +5332,7 @@ fn require_lifecycle_previous_is_current( } } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] fn lifecycle_invalid( operation: &'static str, order_id: &RadrootsOrderId, @@ -7584,7 +5343,7 @@ fn lifecycle_invalid( } } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] fn require_projection_match<T>( operation: &'static str, field: &'static str, @@ -7610,7 +5369,7 @@ where } } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] fn require_buyer_actor( actor: &RadrootsActorContext, operation: &'static str, @@ -7640,7 +5399,7 @@ fn require_seller_actor( } } -#[cfg(any(feature = "signer-adapters", test))] +#[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 { @@ -7649,7 +5408,7 @@ fn listing_event_id(listing_event: &RadrootsEventPtr) -> Result<RadrootsEventId, }) } -#[cfg(any(feature = "signer-adapters", test))] +#[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 { @@ -7658,7 +5417,7 @@ fn request_event_id(request_event: &RadrootsEventPtr) -> Result<RadrootsEventId, }) } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] fn order_reference_event_id( event: &RadrootsEventPtr, label: &'static str, @@ -7668,34 +5427,7 @@ fn order_reference_event_id( }) } -#[cfg(any(feature = "signer-adapters", test))] -fn require_payload_event_refs( - operation: &'static str, - payload_root_event_id: &RadrootsEventId, - payload_previous_event_id: &RadrootsEventId, - root_event_id: &RadrootsEventId, - previous_event_id: &RadrootsEventId, -) -> Result<(), RadrootsSdkError> { - if payload_root_event_id != root_event_id { - return Err(RadrootsSdkError::InvalidRequest { - message: format!( - "{operation} root_event_id {} does not match root evidence {}", - payload_root_event_id, root_event_id - ), - }); - } - if payload_previous_event_id != previous_event_id { - return Err(RadrootsSdkError::InvalidRequest { - message: format!( - "{operation} prev_event_id {} does not match previous evidence {}", - payload_previous_event_id, previous_event_id - ), - }); - } - Ok(()) -} - -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] fn order_canonicalization_error(error: RadrootsOrderCanonicalizationError) -> RadrootsSdkError { match error { RadrootsOrderCanonicalizationError::InvalidBuyerSigner => { @@ -7710,7 +5442,7 @@ fn order_canonicalization_error(error: RadrootsOrderCanonicalizationError) -> Ra } } -#[cfg(any(feature = "signer-adapters", test))] +#[cfg(feature = "signer-adapters")] fn order_decision_canonicalization_error( error: RadrootsOrderCanonicalizationError, ) -> RadrootsSdkError { @@ -7725,11 +5457,11 @@ impl From<RadrootsTradeWorkflowState> for TradeStatusKind { match status { RadrootsTradeWorkflowState::Missing => Self::Missing, RadrootsTradeWorkflowState::Requested => Self::Requested, - RadrootsTradeWorkflowState::RevisionProposed => Self::RevisionProposed, - RadrootsTradeWorkflowState::AgreedPendingRhi => Self::AgreedPendingRhi, + RadrootsTradeWorkflowState::AgreedPendingValidation => Self::AgreedPendingValidation, RadrootsTradeWorkflowState::Committed => Self::Committed, RadrootsTradeWorkflowState::Declined => Self::Declined, RadrootsTradeWorkflowState::Cancelled => Self::Cancelled, + RadrootsTradeWorkflowState::ValidationExpired => Self::ValidationExpired, RadrootsTradeWorkflowState::Invalid => Self::Invalid, } } @@ -7808,94 +5540,6 @@ impl From<RadrootsOrderIssue> for SdkTradeStatusIssue { RadrootsOrderIssue::ConflictingDecisions { event_ids } => { Self::new(SdkTradeStatusIssueKind::ConflictingDecisions, event_ids) } - RadrootsOrderIssue::RevisionProposalPayloadInvalid { event_id } => Self::single( - SdkTradeStatusIssueKind::RevisionProposalPayloadInvalid, - event_id, - ), - RadrootsOrderIssue::RevisionProposalOrderIdMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::RevisionProposalOrderIdMismatch, - event_id, - ), - RadrootsOrderIssue::RevisionProposalAuthorMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::RevisionProposalAuthorMismatch, - event_id, - ), - RadrootsOrderIssue::RevisionProposalCounterpartyMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::RevisionProposalCounterpartyMismatch, - event_id, - ), - RadrootsOrderIssue::RevisionProposalBuyerMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::RevisionProposalBuyerMismatch, - event_id, - ), - RadrootsOrderIssue::RevisionProposalSellerMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::RevisionProposalSellerMismatch, - event_id, - ), - RadrootsOrderIssue::RevisionProposalListingAddressInvalid { event_id } => Self::single( - SdkTradeStatusIssueKind::RevisionProposalListingAddressInvalid, - event_id, - ), - RadrootsOrderIssue::RevisionProposalListingMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::RevisionProposalListingMismatch, - event_id, - ), - RadrootsOrderIssue::RevisionProposalRootMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::RevisionProposalRootMismatch, - event_id, - ), - RadrootsOrderIssue::RevisionProposalPreviousMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::RevisionProposalPreviousMismatch, - event_id, - ), - RadrootsOrderIssue::RevisionDecisionWithoutProposal { event_id } => Self::single( - SdkTradeStatusIssueKind::RevisionDecisionWithoutProposal, - event_id, - ), - RadrootsOrderIssue::RevisionDecisionPayloadInvalid { event_id } => Self::single( - SdkTradeStatusIssueKind::RevisionDecisionPayloadInvalid, - event_id, - ), - RadrootsOrderIssue::RevisionDecisionOrderIdMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::RevisionDecisionOrderIdMismatch, - event_id, - ), - RadrootsOrderIssue::RevisionDecisionAuthorMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::RevisionDecisionAuthorMismatch, - event_id, - ), - RadrootsOrderIssue::RevisionDecisionCounterpartyMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::RevisionDecisionCounterpartyMismatch, - event_id, - ), - RadrootsOrderIssue::RevisionDecisionBuyerMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::RevisionDecisionBuyerMismatch, - event_id, - ), - RadrootsOrderIssue::RevisionDecisionSellerMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::RevisionDecisionSellerMismatch, - event_id, - ), - RadrootsOrderIssue::RevisionDecisionListingAddressInvalid { event_id } => Self::single( - SdkTradeStatusIssueKind::RevisionDecisionListingAddressInvalid, - event_id, - ), - RadrootsOrderIssue::RevisionDecisionListingMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::RevisionDecisionListingMismatch, - event_id, - ), - RadrootsOrderIssue::RevisionDecisionRootMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::RevisionDecisionRootMismatch, - event_id, - ), - RadrootsOrderIssue::RevisionDecisionPreviousMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::RevisionDecisionPreviousMismatch, - event_id, - ), - RadrootsOrderIssue::RevisionDecisionRevisionIdMismatch { event_id } => Self::single( - SdkTradeStatusIssueKind::RevisionDecisionRevisionIdMismatch, - event_id, - ), RadrootsOrderIssue::CancellationWithoutCancellableOrder { event_id } => Self::single( SdkTradeStatusIssueKind::CancellationWithoutCancellableOrder, event_id, @@ -8022,6 +5666,6 @@ fn camel_to_snake(value: &str) -> String { output } -#[cfg(all(test, feature = "runtime"))] +#[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 @@ -247,29 +247,8 @@ async fn apply_up(pool: &SqlitePool) -> Result<(), RadrootsSdkError> { sqlx::raw_sql(PRIVATE_STORE_MIGRATION_UP) .execute(pool) .await - .map_err(private_store_error)?; - ensure_farm_location_label_column(pool).await -} - -async fn ensure_farm_location_label_column(pool: &SqlitePool) -> Result<(), RadrootsSdkError> { - let rows = sqlx::query("PRAGMA table_info(sdk_private_farm_location)") - .fetch_all(pool) - .await - .map_err(private_store_error)?; - let has_label = rows - .iter() - .map(|row| row.try_get::<String, _>("name")) - .collect::<Result<Vec<_>, _>>() - .map_err(private_store_error)? - .iter() - .any(|name| name == "label"); - if !has_label { - sqlx::query("ALTER TABLE sdk_private_farm_location ADD COLUMN label TEXT") - .execute(pool) - .await - .map_err(private_store_error)?; - } - Ok(()) + .map(|_| ()) + .map_err(private_store_error) } async fn query_i64(pool: &SqlitePool, sql: &'static str) -> Result<i64, RadrootsSdkError> { diff --git a/crates/sdk/src/product_clients.rs b/crates/sdk/src/product_clients.rs @@ -109,19 +109,6 @@ pub struct TradeValidationReceiptsClient<'client> { #[cfg(feature = "runtime")] #[derive(Clone, Copy)] -pub struct DvmClient<'client> { - pub(crate) sdk: &'client RadrootsClient, -} - -#[cfg(feature = "runtime")] -impl<'client> DvmClient<'client> { - pub(crate) fn new(sdk: &'client RadrootsClient) -> Self { - Self { sdk } - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Copy)] pub struct SyncClient<'client> { pub(crate) sdk: &'client RadrootsClient, } diff --git a/crates/sdk/src/runtime.rs b/crates/sdk/src/runtime.rs @@ -1,8 +1,10 @@ #[cfg(feature = "runtime")] use crate::private_store::{SDK_PRIVATE_STORE_SCHEMA_VERSION, SdkPrivateStore}; #[cfg(feature = "runtime")] +use crate::studio_store::{SDK_STUDIO_STORE_SCHEMA_VERSION, SdkStudioStore}; +#[cfg(feature = "runtime")] use crate::{ - DvmClient, FarmsClient, GeoNamesClient, ListingsClient, MarketClient, RadrootsGeoNamesConfig, + FarmsClient, GeoNamesClient, ListingsClient, MarketClient, RadrootsGeoNamesConfig, RadrootsSdkError, SyncClient, TradesClient, transport::TransportProfile, }; #[cfg(all(feature = "runtime", feature = "signer-adapters"))] @@ -15,12 +17,16 @@ use radroots_event_store::RadrootsEventStore; #[cfg(feature = "runtime")] use radroots_outbox::RadrootsOutbox; #[cfg(feature = "runtime")] -use sqlx::{Row, SqlitePool}; +use sqlx::{ + Row, SqlitePool, + sqlite::{SqliteConnectOptions, SqlitePoolOptions}, +}; #[cfg(feature = "runtime")] use std::{ fs, io::ErrorKind, path::{Component, Path, PathBuf}, + str::FromStr, time::{SystemTime, UNIX_EPOCH}, }; @@ -29,36 +35,67 @@ const SDK_STORAGE_MANIFEST_VERSION: u16 = 1; #[cfg(feature = "runtime")] const SDK_STORAGE_MANIFEST_KIND: SdkBackupManifestKind = SdkBackupManifestKind::StorageBackup; #[cfg(feature = "runtime")] -const SDK_EVENT_STORE_SCHEMA_VERSION: i64 = 1; -#[cfg(feature = "runtime")] -const SDK_OUTBOX_SCHEMA_VERSION: i64 = 1; +const SDK_RUNTIME_SCHEMA_VERSION: i64 = 1; #[cfg(feature = "runtime")] const SDK_PRIVATE_STORE_SCHEMA_VERSION_CURRENT: i64 = SDK_PRIVATE_STORE_SCHEMA_VERSION; #[cfg(feature = "runtime")] -const EVENT_STORE_BACKUP_FILE: &str = "event_store.sqlite"; +const SDK_STUDIO_STORE_SCHEMA_VERSION_CURRENT: i64 = SDK_STUDIO_STORE_SCHEMA_VERSION; +#[cfg(feature = "runtime")] +const RUNTIME_SQLITE_FILE: &str = "runtime.sqlite"; #[cfg(feature = "runtime")] -const OUTBOX_BACKUP_FILE: &str = "outbox.sqlite"; +const PRIVATE_SQLITE_FILE: &str = "private.sqlite"; #[cfg(feature = "runtime")] -const PRIVATE_STORE_BACKUP_FILE: &str = "private.sqlite"; +const STUDIO_SQLITE_FILE: &str = "studio.sqlite"; #[cfg(feature = "runtime")] const BACKUP_MANIFEST_FILE: &str = "manifest.json"; - #[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +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, + actor_pubkey TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + request_digest_sha256_hex TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + completed_at_ms INTEGER, + UNIQUE(contract_version, operation_id, actor_pubkey, idempotency_key) +); + +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 +); + +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 +); +"#; + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)] #[non_exhaustive] pub enum RadrootsSdkStorageConfig { + #[default] Memory, Directory(PathBuf), } #[cfg(feature = "runtime")] -impl Default for RadrootsSdkStorageConfig { - fn default() -> Self { - Self::Memory - } -} - -#[cfg(feature = "runtime")] #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)] pub struct RadrootsSdkTimestamp(u64); @@ -78,10 +115,11 @@ impl RadrootsSdkTimestamp { } #[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "snake_case")] #[non_exhaustive] pub enum RadrootsSdkClock { + #[default] System, Fixed(RadrootsSdkTimestamp), #[cfg(test)] @@ -89,13 +127,6 @@ pub enum RadrootsSdkClock { } #[cfg(feature = "runtime")] -impl Default for RadrootsSdkClock { - fn default() -> Self { - Self::System - } -} - -#[cfg(feature = "runtime")] impl RadrootsSdkClock { pub fn now(&self) -> Result<RadrootsSdkTimestamp, RadrootsSdkError> { match self { @@ -120,9 +151,9 @@ fn sdk_timestamp_from_system_time( #[cfg(feature = "runtime")] #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct RadrootsSdkStoragePaths { - pub event_store_path: PathBuf, - pub outbox_path: PathBuf, - pub private_store_path: PathBuf, + pub runtime_path: PathBuf, + pub private_path: PathBuf, + pub studio_path: PathBuf, } #[cfg(feature = "runtime")] @@ -157,6 +188,7 @@ pub struct StorageStatusReceipt { pub event_store: SdkEventStoreStorageStatus, pub outbox: SdkOutboxStorageStatus, pub private_store: SdkPrivateStoreStorageStatus, + pub studio_store: SdkStudioStoreStorageStatus, } #[cfg(feature = "runtime")] @@ -176,6 +208,7 @@ pub struct StorageCheckpointReceipt { pub event_store: SdkSqliteWalCheckpointReceipt, pub outbox: SdkSqliteWalCheckpointReceipt, pub private_store: SdkSqliteWalCheckpointReceipt, + pub studio_store: SdkSqliteWalCheckpointReceipt, } #[cfg(feature = "runtime")] @@ -243,6 +276,13 @@ pub struct SdkPrivateStoreStorageStatus { #[cfg(feature = "runtime")] #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct SdkStudioStoreStorageStatus { + pub store: SdkSqliteStoreStatus, + pub studio_state_records: i64, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[non_exhaustive] pub struct BackupRequest { pub destination: PathBuf, @@ -269,9 +309,9 @@ impl BackupRequest { pub struct BackupReceipt { pub destination: PathBuf, pub state: SdkBackupState, - pub event_store_path: Option<PathBuf>, - pub outbox_path: Option<PathBuf>, - pub private_store_path: Option<PathBuf>, + pub runtime_path: Option<PathBuf>, + pub studio_path: Option<PathBuf>, + pub private_path: Option<PathBuf>, pub manifest_path: Option<PathBuf>, pub manifest: SdkBackupManifest, } @@ -313,9 +353,11 @@ pub struct SdkBackupVerification { pub event_store_ok: bool, pub outbox_ok: bool, pub private_store_ok: bool, + pub studio_store_ok: bool, pub event_store_events: i64, pub outbox_events: i64, pub private_farm_locations: i64, + pub studio_state_records: i64, } #[cfg(feature = "runtime")] @@ -337,9 +379,11 @@ pub struct IntegrityReceipt { pub event_store_ok: bool, pub outbox_ok: bool, pub private_store_ok: bool, + pub studio_store_ok: bool, pub event_store_result: String, pub outbox_result: String, pub private_store_result: String, + pub studio_store_result: String, } #[cfg(feature = "runtime")] @@ -397,9 +441,9 @@ pub enum SdkRestoreState { #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct RestoreArchive { pub source: PathBuf, - pub event_store_path: PathBuf, - pub outbox_path: PathBuf, - pub private_store_path: PathBuf, + pub runtime_path: PathBuf, + pub studio_path: PathBuf, + pub private_path: PathBuf, pub manifest_path: PathBuf, pub manifest: SdkBackupManifest, pub verification: SdkBackupVerification, @@ -412,9 +456,9 @@ pub struct RestoreReceipt { pub destination: Option<PathBuf>, pub state: SdkRestoreState, pub destination_paths: Option<RadrootsSdkStoragePaths>, - pub event_store_path: PathBuf, - pub outbox_path: PathBuf, - pub private_store_path: PathBuf, + pub runtime_path: PathBuf, + pub studio_path: PathBuf, + pub private_path: PathBuf, pub manifest_path: PathBuf, pub manifest: SdkBackupManifest, pub verification: SdkBackupVerification, @@ -422,6 +466,40 @@ 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, @@ -495,6 +573,7 @@ impl RadrootsClientBuilder { _event_store: storage.event_store, _outbox: storage.outbox, _private_store: storage.private_store, + _studio_store: storage.studio_store, storage_paths: storage.paths, geonames: self.geonames, clock: self.clock, @@ -511,6 +590,7 @@ pub struct RadrootsClient { pub(crate) _event_store: RadrootsEventStore, pub(crate) _outbox: RadrootsOutbox, pub(crate) _private_store: SdkPrivateStore, + pub(crate) _studio_store: SdkStudioStore, storage_paths: Option<RadrootsSdkStoragePaths>, geonames: Option<RadrootsGeoNamesConfig>, clock: RadrootsSdkClock, @@ -549,10 +629,6 @@ impl RadrootsClient { SyncClient::new(self) } - pub fn dvm(&self) -> DvmClient<'_> { - DvmClient::new(self) - } - pub fn now(&self) -> Result<RadrootsSdkTimestamp, RadrootsSdkError> { self.clock.now() } @@ -605,12 +681,17 @@ impl RadrootsClient { _request: StorageStatusRequest, ) -> Result<StorageStatusReceipt, RadrootsSdkError> { let now_ms = sdk_now_ms(self)?; + if let Some(paths) = &self.storage_paths { + return directory_storage_status_read_only(paths, now_ms).await; + } let event_store_status = event_store_sqlite_status(&self._event_store).await?; let outbox_store_status = outbox_sqlite_status(&self._outbox).await?; let private_store_status = private_store_sqlite_status(&self._private_store).await?; + let studio_store_status = studio_store_sqlite_status(&self._studio_store).await?; let event_summary = event_store_status_summary(&self._event_store).await?; let outbox_summary = outbox_status_summary(&self._outbox, now_ms).await?; let private_summary = self._private_store.status_summary().await?; + let studio_summary = self._studio_store.status_summary().await?; Ok(StorageStatusReceipt { storage: self.storage_kind(), paths: self.storage_paths.clone(), @@ -640,9 +721,23 @@ impl RadrootsClient { store: private_store_status, farm_private_locations: private_summary.farm_private_locations, }, + studio_store: SdkStudioStoreStorageStatus { + store: studio_store_status, + studio_state_records: studio_summary.studio_state_records, + }, }) } + pub async fn inspect_storage_status( + path: impl Into<PathBuf>, + _request: StorageStatusRequest, + ) -> Result<StorageStatusReceipt, RadrootsSdkError> { + let path = path.into(); + reject_pre_v1_profile(&path)?; + let paths = storage_paths_for_directory(&path); + directory_storage_status_read_only(&paths, 0).await + } + pub async fn storage_checkpoint( &self, _request: StorageCheckpointRequest, @@ -665,12 +760,19 @@ impl RadrootsClient { SqliteStoreRole::PrivateStore, ) .await?; + let studio_store = sqlite_wal_checkpoint( + self._studio_store.pool(), + &self._studio_store.pragma_journal_mode().await?, + SqliteStoreRole::StudioStore, + ) + .await?; Ok(StorageCheckpointReceipt { storage: self.storage_kind(), paths: self.storage_paths.clone(), event_store, outbox, private_store, + studio_store, }) } @@ -685,14 +787,17 @@ impl RadrootsClient { let private_store_integrity = sqlite_integrity_result(self._private_store.pool(), SqliteStoreRole::PrivateStore) .await?; + let studio_store_integrity = + sqlite_integrity_result(self._studio_store.pool(), SqliteStoreRole::StudioStore) + .await?; let checked_paths = self .storage_paths .as_ref() .map(|paths| { vec![ - paths.event_store_path.clone(), - paths.outbox_path.clone(), - paths.private_store_path.clone(), + paths.runtime_path.clone(), + paths.private_path.clone(), + paths.studio_path.clone(), ] }) .unwrap_or_default(); @@ -701,9 +806,11 @@ impl RadrootsClient { event_store_ok: event_store_integrity.ok, outbox_ok: outbox_integrity.ok, private_store_ok: private_store_integrity.ok, + studio_store_ok: studio_store_integrity.ok, event_store_result: event_store_integrity.result, outbox_result: outbox_integrity.result, private_store_result: private_store_integrity.result, + studio_store_result: studio_store_integrity.result, }) } @@ -716,21 +823,21 @@ impl RadrootsClient { prepare_backup_destination(&request.destination, request.overwrite)?; let created_at_ms = sdk_now_ms(self)?; let backup_paths = RadrootsSdkStoragePaths { - event_store_path: request.destination.join(EVENT_STORE_BACKUP_FILE), - outbox_path: request.destination.join(OUTBOX_BACKUP_FILE), - private_store_path: request.destination.join(PRIVATE_STORE_BACKUP_FILE), + runtime_path: request.destination.join(RUNTIME_SQLITE_FILE), + private_path: request.destination.join(PRIVATE_SQLITE_FILE), + studio_path: request.destination.join(STUDIO_SQLITE_FILE), }; let manifest_backup_paths = RadrootsSdkStoragePaths { - event_store_path: PathBuf::from(EVENT_STORE_BACKUP_FILE), - outbox_path: PathBuf::from(OUTBOX_BACKUP_FILE), - private_store_path: PathBuf::from(PRIVATE_STORE_BACKUP_FILE), + runtime_path: PathBuf::from(RUNTIME_SQLITE_FILE), + private_path: PathBuf::from(PRIVATE_SQLITE_FILE), + studio_path: PathBuf::from(STUDIO_SQLITE_FILE), }; let manifest_path = request.destination.join(BACKUP_MANIFEST_FILE); let source_status = self.storage_status(StorageStatusRequest::new()).await?; let backup_verification = backup_sqlite_stores( self._event_store.pool(), - self._outbox.pool(), self._private_store.pool(), + self._studio_store.pool(), &backup_paths, ) .await?; @@ -788,15 +895,21 @@ impl RadrootsClient { destination: Some(destination), state, destination_paths: Some(destination_paths), - event_store_path: archive.event_store_path, - outbox_path: archive.outbox_path, - private_store_path: archive.private_store_path, + runtime_path: archive.runtime_path, + studio_path: archive.studio_path, + private_path: archive.private_path, manifest_path: archive.manifest_path, manifest: archive.manifest, verification: archive.verification, restored_paths, }) } + + pub async fn quarantine_reset_storage( + request: QuarantineResetRequest, + ) -> Result<QuarantineResetReceipt, RadrootsSdkError> { + quarantine_reset_storage(request).await + } } #[cfg(feature = "runtime")] @@ -805,7 +918,7 @@ async fn event_store_sqlite_status( ) -> Result<SdkSqliteStoreStatus, RadrootsSdkError> { sqlite_store_status( event_store.pool(), - SDK_EVENT_STORE_SCHEMA_VERSION, + SDK_RUNTIME_SCHEMA_VERSION, event_store.pragma_journal_mode().await?, event_store.pragma_foreign_keys().await? != 0, event_store.pragma_busy_timeout().await?, @@ -820,7 +933,7 @@ async fn outbox_sqlite_status( ) -> Result<SdkSqliteStoreStatus, RadrootsSdkError> { sqlite_store_status( outbox.pool(), - SDK_OUTBOX_SCHEMA_VERSION, + SDK_RUNTIME_SCHEMA_VERSION, outbox.pragma_journal_mode().await?, outbox.pragma_foreign_keys().await? != 0, outbox.pragma_busy_timeout().await?, @@ -845,6 +958,290 @@ async fn private_store_sqlite_status( } #[cfg(feature = "runtime")] +async fn studio_store_sqlite_status( + studio_store: &SdkStudioStore, +) -> Result<SdkSqliteStoreStatus, RadrootsSdkError> { + sqlite_store_status( + studio_store.pool(), + SDK_STUDIO_STORE_SCHEMA_VERSION_CURRENT, + studio_store.pragma_journal_mode().await?, + studio_store.pragma_foreign_keys().await? != 0, + studio_store.pragma_busy_timeout().await?, + SqliteStoreRole::StudioStore, + ) + .await +} + +#[cfg(feature = "runtime")] +async fn directory_storage_status_read_only( + paths: &RadrootsSdkStoragePaths, + now_ms: i64, +) -> Result<StorageStatusReceipt, RadrootsSdkError> { + let runtime_pool = + open_read_only_sqlite_pool(&paths.runtime_path, SqliteStoreRole::RuntimeStore).await?; + let private_pool = + open_read_only_sqlite_pool(&paths.private_path, SqliteStoreRole::PrivateStore).await?; + let studio_pool = + open_read_only_sqlite_pool(&paths.studio_path, SqliteStoreRole::StudioStore).await?; + let event_store_status = sqlite_store_status_from_pool( + &runtime_pool, + SDK_RUNTIME_SCHEMA_VERSION, + SqliteStoreRole::EventStore, + ) + .await?; + let outbox_store_status = sqlite_store_status_from_pool( + &runtime_pool, + SDK_RUNTIME_SCHEMA_VERSION, + SqliteStoreRole::Outbox, + ) + .await?; + let private_store_status = sqlite_store_status_from_pool( + &private_pool, + SDK_PRIVATE_STORE_SCHEMA_VERSION_CURRENT, + SqliteStoreRole::PrivateStore, + ) + .await?; + let studio_store_status = sqlite_store_status_from_pool( + &studio_pool, + SDK_STUDIO_STORE_SCHEMA_VERSION_CURRENT, + SqliteStoreRole::StudioStore, + ) + .await?; + let event_summary = event_store_status_summary_from_pool(&runtime_pool).await?; + let outbox_summary = outbox_status_summary_from_pool(&runtime_pool, now_ms).await?; + let private_summary = private_store_status_summary_from_pool(&private_pool).await?; + let studio_summary = studio_store_status_summary_from_pool(&studio_pool).await?; + Ok(StorageStatusReceipt { + storage: SdkStorageKind::Directory, + paths: Some(paths.clone()), + event_store: SdkEventStoreStorageStatus { + store: event_store_status, + total_events: event_summary.total_events, + projection_eligible_events: event_summary.projection_eligible_events, + transport_observations: event_summary.transport_observations, + last_event_seq: event_summary.last_event_seq, + last_event_updated_at_ms: event_summary.last_event_updated_at_ms, + }, + outbox: SdkOutboxStorageStatus { + store: outbox_store_status, + total_events: outbox_summary.total_events, + pending_events: outbox_summary.pending_events, + retryable_events: outbox_summary.retryable_events, + terminal_events: outbox_summary.terminal_events, + failed_terminal_events: outbox_summary.failed_terminal_events, + preview_unavailable_events: outbox_summary.preview_unavailable_events, + deferred_until_implemented_events: outbox_summary.deferred_until_implemented_events, + ready_signed_events: outbox_summary.ready_signed_events, + publishing_events: outbox_summary.publishing_events, + last_attempt_at_ms: outbox_summary.last_attempt_at_ms, + last_error: outbox_summary.last_error, + }, + private_store: SdkPrivateStoreStorageStatus { + store: private_store_status, + farm_private_locations: private_summary.farm_private_locations, + }, + studio_store: SdkStudioStoreStorageStatus { + store: studio_store_status, + studio_state_records: studio_summary.studio_state_records, + }, + }) +} + +#[cfg(feature = "runtime")] +async fn open_read_only_sqlite_pool( + path: &Path, + store_role: SqliteStoreRole, +) -> Result<SqlitePool, RadrootsSdkError> { + let options = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(false) + .read_only(true); + SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .map_err(|error| store_role.error(error.to_string())) +} + +#[cfg(feature = "runtime")] +async fn sqlite_store_status_from_pool( + pool: &SqlitePool, + schema_version: i64, + store_role: SqliteStoreRole, +) -> Result<SdkSqliteStoreStatus, RadrootsSdkError> { + let journal_mode = sqlite_query_string(pool, "PRAGMA journal_mode", store_role).await?; + let foreign_keys_enabled = + sqlite_query_i64(pool, "PRAGMA foreign_keys", store_role).await? != 0; + let busy_timeout_ms = sqlite_query_i64(pool, "PRAGMA busy_timeout", store_role).await?; + sqlite_store_status( + pool, + schema_version, + journal_mode, + foreign_keys_enabled, + busy_timeout_ms, + store_role, + ) + .await +} + +#[cfg(feature = "runtime")] +async fn event_store_status_summary_from_pool( + pool: &SqlitePool, +) -> Result<radroots_event_store::RadrootsEventStoreStatusSummary, RadrootsSdkError> { + let row = sqlx::query( + "SELECT COUNT(*) AS total_events, COALESCE(SUM(CASE WHEN projection_eligible = 1 THEN 1 ELSE 0 END), 0) AS projection_eligible_events, MAX(seq) AS last_event_seq, MAX(updated_at_ms) AS last_event_updated_at_ms FROM event_envelopes", + ) + .fetch_one(pool) + .await + .map_err(|error| SqliteStoreRole::EventStore.error(error.to_string()))?; + let transport_observations = sqlite_query_i64( + pool, + "SELECT COUNT(*) FROM event_transport_observation", + SqliteStoreRole::EventStore, + ) + .await?; + Ok(radroots_event_store::RadrootsEventStoreStatusSummary { + total_events: row + .try_get("total_events") + .map_err(|error| SqliteStoreRole::EventStore.error(error.to_string()))?, + projection_eligible_events: row + .try_get("projection_eligible_events") + .map_err(|error| SqliteStoreRole::EventStore.error(error.to_string()))?, + transport_observations, + last_event_seq: row + .try_get("last_event_seq") + .map_err(|error| SqliteStoreRole::EventStore.error(error.to_string()))?, + last_event_updated_at_ms: row + .try_get("last_event_updated_at_ms") + .map_err(|error| SqliteStoreRole::EventStore.error(error.to_string()))?, + }) +} + +#[cfg(feature = "runtime")] +async fn outbox_status_summary_from_pool( + pool: &SqlitePool, + now_ms: i64, +) -> Result<radroots_outbox::RadrootsOutboxStatusSummary, RadrootsSdkError> { + let row = sqlx::query( + "SELECT COUNT(*) AS total_events, COALESCE(SUM(CASE WHEN state IN ('draft_queued', 'signing', 'signed', 'publishing') THEN 1 ELSE 0 END), 0) AS pending_events, COALESCE(SUM(CASE WHEN state IN ('sign_retryable', 'publish_retryable') THEN 1 ELSE 0 END), 0) AS retryable_events, COALESCE(SUM(CASE WHEN state IN ('published', 'failed_terminal', 'cancelled') THEN 1 ELSE 0 END), 0) AS terminal_events, COALESCE(SUM(CASE WHEN state = 'failed_terminal' THEN 1 ELSE 0 END), 0) AS failed_terminal_events, COALESCE(SUM(CASE WHEN state = 'preview_unavailable' THEN 1 ELSE 0 END), 0) AS preview_unavailable_events, COALESCE(SUM(CASE WHEN state = 'deferred_until_implemented' THEN 1 ELSE 0 END), 0) AS deferred_until_implemented_events, COALESCE(SUM(CASE WHEN state = 'publishing' THEN 1 ELSE 0 END), 0) AS publishing_events FROM outbox_event", + ) + .fetch_one(pool) + .await + .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))?; + let ready_signed_events = sqlx::query( + "SELECT COUNT(*) FROM outbox_event AS event WHERE event.state IN ('signed', 'publish_retryable') AND event.signed_event_json IS NOT NULL AND event.next_attempt_after_ms <= ? AND (event.claim_token IS NULL OR event.claim_expires_at_ms <= ?) AND EXISTS (SELECT 1 FROM outbox_delivery_plan AS plan JOIN outbox_delivery_target AS target ON target.delivery_plan_id = plan.delivery_plan_id WHERE plan.outbox_event_id = event.outbox_event_id AND plan.status = 'queued' AND target.status IN ('pending', 'failed_retryable'))", + ) + .bind(now_ms) + .bind(now_ms) + .fetch_one(pool) + .await + .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))? + .try_get(0) + .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))?; + let last_attempt_at_ms = + sqlx::query("SELECT MAX(attempted_at_ms) FROM outbox_delivery_attempt") + .fetch_one(pool) + .await + .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))? + .try_get(0) + .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))?; + let last_error = sqlx::query( + "SELECT last_error FROM outbox_event WHERE last_error IS NOT NULL ORDER BY updated_at_ms DESC, outbox_event_id DESC LIMIT 1", + ) + .fetch_optional(pool) + .await + .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))? + .map(|row| row.try_get("last_error")) + .transpose() + .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))?; + Ok(radroots_outbox::RadrootsOutboxStatusSummary { + total_events: row + .try_get("total_events") + .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))?, + pending_events: row + .try_get("pending_events") + .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))?, + retryable_events: row + .try_get("retryable_events") + .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))?, + terminal_events: row + .try_get("terminal_events") + .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))?, + failed_terminal_events: row + .try_get("failed_terminal_events") + .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))?, + preview_unavailable_events: row + .try_get("preview_unavailable_events") + .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))?, + deferred_until_implemented_events: row + .try_get("deferred_until_implemented_events") + .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))?, + ready_signed_events, + publishing_events: row + .try_get("publishing_events") + .map_err(|error| SqliteStoreRole::Outbox.error(error.to_string()))?, + last_attempt_at_ms, + last_error, + }) +} + +#[cfg(feature = "runtime")] +async fn private_store_status_summary_from_pool( + pool: &SqlitePool, +) -> Result<crate::private_store::SdkPrivateStoreStatusSummary, RadrootsSdkError> { + Ok(crate::private_store::SdkPrivateStoreStatusSummary { + farm_private_locations: sqlite_query_i64( + pool, + "SELECT COUNT(*) FROM sdk_private_farm_location", + SqliteStoreRole::PrivateStore, + ) + .await?, + }) +} + +#[cfg(feature = "runtime")] +async fn studio_store_status_summary_from_pool( + pool: &SqlitePool, +) -> Result<crate::studio_store::SdkStudioStoreStatusSummary, RadrootsSdkError> { + Ok(crate::studio_store::SdkStudioStoreStatusSummary { + studio_state_records: sqlite_query_i64( + pool, + "SELECT COUNT(*) FROM sdk_studio_state", + SqliteStoreRole::StudioStore, + ) + .await?, + }) +} + +#[cfg(feature = "runtime")] +async fn sqlite_query_i64( + pool: &SqlitePool, + sql: &'static str, + store_role: SqliteStoreRole, +) -> Result<i64, RadrootsSdkError> { + let row = sqlx::query(sql) + .fetch_one(pool) + .await + .map_err(|error| store_role.error(error.to_string()))?; + row.try_get(0) + .map_err(|error| store_role.error(error.to_string())) +} + +#[cfg(feature = "runtime")] +async fn sqlite_query_string( + pool: &SqlitePool, + sql: &'static str, + store_role: SqliteStoreRole, +) -> Result<String, RadrootsSdkError> { + let row = sqlx::query(sql) + .fetch_one(pool) + .await + .map_err(|error| store_role.error(error.to_string()))?; + row.try_get(0) + .map_err(|error| store_role.error(error.to_string())) +} + +#[cfg(feature = "runtime")] async fn event_store_status_summary( event_store: &RadrootsEventStore, ) -> Result<radroots_event_store::RadrootsEventStoreStatusSummary, RadrootsSdkError> { @@ -861,27 +1258,27 @@ async fn outbox_status_summary( #[cfg(feature = "runtime")] async fn backup_sqlite_stores( - event_store_pool: &SqlitePool, - outbox_pool: &SqlitePool, + runtime_pool: &SqlitePool, private_store_pool: &SqlitePool, + studio_store_pool: &SqlitePool, backup_paths: &RadrootsSdkStoragePaths, ) -> Result<SdkBackupVerification, RadrootsSdkError> { sqlite_vacuum_into( - event_store_pool, - &backup_paths.event_store_path, - SqliteStoreRole::EventStore, + runtime_pool, + &backup_paths.runtime_path, + SqliteStoreRole::RuntimeStore, ) .await?; sqlite_vacuum_into( - outbox_pool, - &backup_paths.outbox_path, - SqliteStoreRole::Outbox, + private_store_pool, + &backup_paths.private_path, + SqliteStoreRole::PrivateStore, ) .await?; sqlite_vacuum_into( - private_store_pool, - &backup_paths.private_store_path, - SqliteStoreRole::PrivateStore, + studio_store_pool, + &backup_paths.studio_path, + SqliteStoreRole::StudioStore, ) .await?; verify_backup_paths(backup_paths).await @@ -898,9 +1295,9 @@ fn write_backup_receipt( Ok(BackupReceipt { destination, state: SdkBackupState::Completed, - event_store_path: Some(backup_paths.event_store_path), - outbox_path: Some(backup_paths.outbox_path), - private_store_path: Some(backup_paths.private_store_path), + runtime_path: Some(backup_paths.runtime_path), + studio_path: Some(backup_paths.studio_path), + private_path: Some(backup_paths.private_path), manifest_path: Some(manifest_path), manifest, }) @@ -947,30 +1344,30 @@ async fn inspect_restore_archive(source: PathBuf) -> Result<RestoreArchive, Radr } })?; validate_restore_manifest(&manifest)?; - let event_store_path = restore_archive_member_path( + let runtime_path = restore_archive_member_path( &source_root, - &manifest.backup_paths.event_store_path, - "event store", + &manifest.backup_paths.runtime_path, + "runtime store", )?; - let outbox_path = - restore_archive_member_path(&source_root, &manifest.backup_paths.outbox_path, "outbox")?; - let private_store_path = restore_archive_member_path( + let studio_path = + restore_archive_member_path(&source_root, &manifest.backup_paths.studio_path, "studio")?; + let private_path = restore_archive_member_path( &source_root, - &manifest.backup_paths.private_store_path, + &manifest.backup_paths.private_path, "private store", )?; let verification = verify_backup_paths(&RadrootsSdkStoragePaths { - event_store_path: event_store_path.clone(), - outbox_path: outbox_path.clone(), - private_store_path: private_store_path.clone(), + runtime_path: runtime_path.clone(), + studio_path: studio_path.clone(), + private_path: private_path.clone(), }) .await?; validate_restore_verification(&verification, &manifest.backup_verification)?; Ok(RestoreArchive { source, - event_store_path, - outbox_path, - private_store_path, + runtime_path, + studio_path, + private_path, manifest_path, manifest, verification, @@ -1082,7 +1479,11 @@ fn validate_restore_verification( actual: &SdkBackupVerification, manifest: &SdkBackupVerification, ) -> Result<(), RadrootsSdkError> { - if !actual.event_store_ok || !actual.outbox_ok || !actual.private_store_ok { + if !actual.event_store_ok + || !actual.outbox_ok + || !actual.private_store_ok + || !actual.studio_store_ok + { return Err(RadrootsSdkError::InvalidRequest { message: "restore backup stores failed integrity checks".to_owned(), }); @@ -1165,9 +1566,9 @@ fn preflight_restore_destination( } } Ok(RadrootsSdkStoragePaths { - event_store_path: destination.join(EVENT_STORE_BACKUP_FILE), - outbox_path: destination.join(OUTBOX_BACKUP_FILE), - private_store_path: destination.join(PRIVATE_STORE_BACKUP_FILE), + runtime_path: destination.join(RUNTIME_SQLITE_FILE), + studio_path: destination.join(STUDIO_SQLITE_FILE), + private_path: destination.join(PRIVATE_SQLITE_FILE), }) } @@ -1202,9 +1603,9 @@ async fn restore_archive_to_destination( message: error.to_string(), })?; let staging_paths = RadrootsSdkStoragePaths { - event_store_path: staging.join(EVENT_STORE_BACKUP_FILE), - outbox_path: staging.join(OUTBOX_BACKUP_FILE), - private_store_path: staging.join(PRIVATE_STORE_BACKUP_FILE), + runtime_path: staging.join(RUNTIME_SQLITE_FILE), + studio_path: staging.join(STUDIO_SQLITE_FILE), + private_path: staging.join(PRIVATE_SQLITE_FILE), }; if let Err(error) = copy_restore_archive_to_staging(archive, &staging_paths).await { let _ = remove_existing_restore_path(&staging); @@ -1262,14 +1663,14 @@ async fn copy_restore_archive_to_staging( staging_paths: &RadrootsSdkStoragePaths, ) -> Result<(), RadrootsSdkError> { copy_restore_file( - &archive.event_store_path, - &staging_paths.event_store_path, - "event store", + &archive.runtime_path, + &staging_paths.runtime_path, + "runtime store", )?; - copy_restore_file(&archive.outbox_path, &staging_paths.outbox_path, "outbox")?; + copy_restore_file(&archive.studio_path, &staging_paths.studio_path, "studio")?; copy_restore_file( - &archive.private_store_path, - &staging_paths.private_store_path, + &archive.private_path, + &staging_paths.private_path, "private store", )?; let staging_verification = verify_backup_paths(staging_paths).await?; @@ -1386,6 +1787,7 @@ struct OpenedRuntimeStorage { event_store: RadrootsEventStore, outbox: RadrootsOutbox, private_store: SdkPrivateStore, + studio_store: SdkStudioStore, paths: Option<RadrootsSdkStoragePaths>, } @@ -1394,58 +1796,253 @@ async fn open_storage( storage: &RadrootsSdkStorageConfig, ) -> Result<OpenedRuntimeStorage, RadrootsSdkError> { match storage { - RadrootsSdkStorageConfig::Memory => Ok(OpenedRuntimeStorage { - event_store: RadrootsEventStore::open_memory().await?, - outbox: RadrootsOutbox::open_memory().await?, - private_store: SdkPrivateStore::open_memory().await?, - paths: None, - }), + RadrootsSdkStorageConfig::Memory => open_memory_storage().await, RadrootsSdkStorageConfig::Directory(path) => open_directory_storage(path).await, } } #[cfg(feature = "runtime")] +async fn open_memory_storage() -> Result<OpenedRuntimeStorage, RadrootsSdkError> { + let runtime_pool = open_runtime_memory_pool().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?; + Ok(OpenedRuntimeStorage { + event_store, + outbox, + private_store: SdkPrivateStore::open_memory().await?, + studio_store: SdkStudioStore::open_memory().await?, + paths: None, + }) +} + +#[cfg(feature = "runtime")] async fn open_directory_storage(path: &Path) -> Result<OpenedRuntimeStorage, RadrootsSdkError> { + reject_pre_v1_profile(path)?; fs::create_dir_all(path).map_err(|error| RadrootsSdkError::Io { path: path.to_path_buf(), message: error.to_string(), })?; - let paths = RadrootsSdkStoragePaths { - event_store_path: path.join("event_store.sqlite"), - outbox_path: path.join("outbox.sqlite"), - private_store_path: path.join("private.sqlite"), - }; + let paths = storage_paths_for_directory(path); + let runtime_pool = open_runtime_file_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?; Ok(OpenedRuntimeStorage { - event_store: RadrootsEventStore::open_file(&paths.event_store_path).await?, - outbox: RadrootsOutbox::open_file(&paths.outbox_path).await?, - private_store: SdkPrivateStore::open_file(&paths.private_store_path).await?, + event_store, + outbox, + private_store: SdkPrivateStore::open_file(&paths.private_path).await?, + studio_store: SdkStudioStore::open_file(&paths.studio_path).await?, paths: Some(paths), }) } #[cfg(feature = "runtime")] +fn storage_paths_for_directory(path: &Path) -> RadrootsSdkStoragePaths { + RadrootsSdkStoragePaths { + runtime_path: path.join(RUNTIME_SQLITE_FILE), + private_path: path.join(PRIVATE_SQLITE_FILE), + studio_path: path.join(STUDIO_SQLITE_FILE), + } +} + +#[cfg(feature = "runtime")] +async fn open_runtime_memory_pool() -> Result<SqlitePool, RadrootsSdkError> { + let options = SqliteConnectOptions::from_str("sqlite::memory:") + .map_err(|error| runtime_store_error(error.to_string()))?; + SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .map_err(|error| runtime_store_error(error.to_string())) +} + +#[cfg(feature = "runtime")] +async fn open_runtime_file_pool(path: &Path) -> Result<SqlitePool, RadrootsSdkError> { + let options = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true); + SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .map_err(|error| runtime_store_error(error.to_string())) +} + +#[cfg(feature = "runtime")] +async fn apply_sdk_runtime_schema(pool: &SqlitePool) -> Result<(), RadrootsSdkError> { + sqlx::raw_sql(SDK_RUNTIME_MIGRATION_UP) + .execute(pool) + .await + .map_err(|error| runtime_store_error(error.to_string()))?; + sqlx::query("PRAGMA user_version = 1") + .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; use explicit quarantine reset" + .to_owned(), + }); + } + } + 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)?; + } + 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(), + }); + } + 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(), + }); + } + Err(error) => { + return Err(RadrootsSdkError::Io { + path: request.profile.clone(), + message: error.to_string(), + }); + } + } + 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 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() + ), + }); + } + 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) +} + +#[cfg(feature = "runtime")] +fn runtime_store_error(message: String) -> RadrootsSdkError { + RadrootsSdkError::EventStore { message } +} + +#[cfg(feature = "runtime")] #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SqliteStoreRole { + RuntimeStore, EventStore, Outbox, PrivateStore, + StudioStore, } #[cfg(feature = "runtime")] impl SqliteStoreRole { fn label(self) -> &'static str { match self { + Self::RuntimeStore => "runtime store", Self::EventStore => "event store", Self::Outbox => "outbox", Self::PrivateStore => "private store", + Self::StudioStore => "studio store", } } fn error(self, message: String) -> RadrootsSdkError { match self { + Self::RuntimeStore => RadrootsSdkError::EventStore { message }, Self::EventStore => RadrootsSdkError::EventStore { message }, Self::Outbox => RadrootsSdkError::Outbox { message }, Self::PrivateStore => RadrootsSdkError::PrivateStore { message }, + Self::StudioStore => RadrootsSdkError::StudioStore { message }, } } } @@ -1612,24 +2209,30 @@ async fn sqlite_vacuum_into( async fn verify_backup_paths( paths: &RadrootsSdkStoragePaths, ) -> Result<SdkBackupVerification, RadrootsSdkError> { - let event_store = RadrootsEventStore::open_file(&paths.event_store_path).await?; - let outbox = RadrootsOutbox::open_file(&paths.outbox_path).await?; - let private_store = SdkPrivateStore::open_file(&paths.private_store_path).await?; + let event_store = RadrootsEventStore::open_file(&paths.runtime_path).await?; + let outbox = RadrootsOutbox::open_file(&paths.runtime_path).await?; + let private_store = SdkPrivateStore::open_file(&paths.private_path).await?; + let studio_store = SdkStudioStore::open_file(&paths.studio_path).await?; let event_store_integrity = sqlite_integrity_result(event_store.pool(), SqliteStoreRole::EventStore).await?; let outbox_integrity = sqlite_integrity_result(outbox.pool(), SqliteStoreRole::Outbox).await?; let private_store_integrity = sqlite_integrity_result(private_store.pool(), SqliteStoreRole::PrivateStore).await?; + let studio_store_integrity = + sqlite_integrity_result(studio_store.pool(), SqliteStoreRole::StudioStore).await?; let event_summary = event_store.status_summary().await?; let outbox_summary = outbox.status_summary(i64::MAX).await?; let private_summary = private_store.status_summary().await?; + let studio_summary = studio_store.status_summary().await?; Ok(SdkBackupVerification { event_store_ok: event_store_integrity.ok, outbox_ok: outbox_integrity.ok, private_store_ok: private_store_integrity.ok, + studio_store_ok: studio_store_integrity.ok, event_store_events: event_summary.total_events, outbox_events: outbox_summary.total_events, private_farm_locations: private_summary.farm_private_locations, + studio_state_records: studio_summary.studio_state_records, }) } diff --git a/crates/sdk/src/signer_provider.rs b/crates/sdk/src/signer_provider.rs @@ -10,7 +10,6 @@ use radroots_event::draft::{RadrootsEventDraft, RadrootsSignedEvent}; use radroots_event::ids::RadrootsPublicKey; use radroots_event::kinds::{ KIND_FARM, KIND_LISTING, KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST, - KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL, }; use radroots_event::wire::RadrootsNip01EventWire; use radroots_nostr::prelude::{RadrootsNostrEvent, RadrootsNostrKeys}; @@ -28,13 +27,11 @@ use uuid::Uuid; pub type RadrootsSdkNip46TransportFuture<'a, T> = RadrootsNostrConnectClientTransportFuture<'a, T>; -pub const RADROOTS_SDK_MYC_NIP46_PRODUCT_SIGN_EVENT_KINDS: [u32; 7] = [ +pub const RADROOTS_SDK_MYC_NIP46_PRODUCT_SIGN_EVENT_KINDS: [u32; 5] = [ KIND_FARM, KIND_LISTING, KIND_ORDER_REQUEST, KIND_ORDER_DECISION, - KIND_ORDER_REVISION_PROPOSAL, - KIND_ORDER_REVISION_DECISION, KIND_ORDER_CANCELLATION, ]; pub const RADROOTS_SDK_MYC_NIP46_DEFAULT_REQUEST_TIMEOUT_MS: u64 = 30_000; @@ -172,7 +169,7 @@ pub struct RadrootsSdkSignReceipt { pub enum RadrootsSdkSignerProvider { #[cfg(feature = "local-signer")] LocalKey(RadrootsSdkLocalKeySigner), - MycNip46(RadrootsSdkMycNip46Signer), + MycNip46(Box<RadrootsSdkMycNip46Signer>), } impl RadrootsSdkSignerProvider { diff --git a/crates/sdk/src/studio_store.rs b/crates/sdk/src/studio_store.rs @@ -0,0 +1,129 @@ +#![cfg(feature = "runtime")] + +use crate::RadrootsSdkError; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use sqlx::{Row, SqlitePool}; +use std::path::Path; +use std::str::FromStr; + +pub(crate) const SDK_STUDIO_STORE_SCHEMA_VERSION: i64 = 1; + +const STUDIO_STORE_MIGRATION_UP: &str = r#" +CREATE TABLE IF NOT EXISTS sdk_studio_state ( + key TEXT PRIMARY KEY NOT NULL, + value_json TEXT NOT NULL, + updated_at_ms INTEGER NOT NULL +); +"#; + +#[derive(Clone)] +pub(crate) struct SdkStudioStore { + pool: SqlitePool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SdkStudioStoreStatusSummary { + pub studio_state_records: i64, +} + +impl SdkStudioStore { + pub async fn open_memory() -> Result<Self, RadrootsSdkError> { + let options = SqliteConnectOptions::from_str("sqlite::memory:").map_err(studio_error)?; + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .map_err(studio_error)?; + configure_connection(&pool, false).await?; + apply_up(&pool).await?; + Ok(Self { pool }) + } + + pub async fn open_file(path: impl AsRef<Path>) -> Result<Self, RadrootsSdkError> { + let options = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .map_err(studio_error)?; + configure_connection(&pool, true).await?; + apply_up(&pool).await?; + Ok(Self { pool }) + } + + pub fn pool(&self) -> &SqlitePool { + &self.pool + } + + pub async fn pragma_foreign_keys(&self) -> Result<i64, RadrootsSdkError> { + query_i64(&self.pool, "PRAGMA foreign_keys").await + } + + pub async fn pragma_busy_timeout(&self) -> Result<i64, RadrootsSdkError> { + query_i64(&self.pool, "PRAGMA busy_timeout").await + } + + pub async fn pragma_journal_mode(&self) -> Result<String, RadrootsSdkError> { + query_string(&self.pool, "PRAGMA journal_mode").await + } + + pub async fn status_summary(&self) -> Result<SdkStudioStoreStatusSummary, RadrootsSdkError> { + Ok(SdkStudioStoreStatusSummary { + studio_state_records: query_i64(&self.pool, "SELECT COUNT(*) FROM sdk_studio_state") + .await?, + }) + } +} + +async fn configure_connection( + pool: &SqlitePool, + file_backed: bool, +) -> Result<(), RadrootsSdkError> { + sqlx::query("PRAGMA foreign_keys = ON") + .execute(pool) + .await + .map_err(studio_error)?; + sqlx::query("PRAGMA busy_timeout = 5000") + .execute(pool) + .await + .map_err(studio_error)?; + if file_backed { + sqlx::query("PRAGMA journal_mode = WAL") + .execute(pool) + .await + .map_err(studio_error)?; + } + Ok(()) +} + +async fn apply_up(pool: &SqlitePool) -> Result<(), RadrootsSdkError> { + sqlx::raw_sql(STUDIO_STORE_MIGRATION_UP) + .execute(pool) + .await + .map(|_| ()) + .map_err(studio_error) +} + +async fn query_i64(pool: &SqlitePool, sql: &'static str) -> Result<i64, RadrootsSdkError> { + let row = sqlx::query(sql) + .fetch_one(pool) + .await + .map_err(studio_error)?; + row.try_get(0).map_err(studio_error) +} + +async fn query_string(pool: &SqlitePool, sql: &'static str) -> Result<String, RadrootsSdkError> { + let row = sqlx::query(sql) + .fetch_one(pool) + .await + .map_err(studio_error)?; + row.try_get(0).map_err(studio_error) +} + +fn studio_error(error: impl std::fmt::Display) -> RadrootsSdkError { + RadrootsSdkError::StudioStore { + message: error.to_string(), + } +} diff --git a/crates/sdk/src/sync_runtime.rs b/crates/sdk/src/sync_runtime.rs @@ -332,21 +332,15 @@ impl SyncProjectionRefreshReceipt { } #[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "snake_case")] #[non_exhaustive] pub enum SdkRelayAuthPolicy { + #[default] DetectOnly, } #[cfg(feature = "runtime")] -impl Default for SdkRelayAuthPolicy { - fn default() -> Self { - Self::DetectOnly - } -} - -#[cfg(feature = "runtime")] #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] #[non_exhaustive] pub struct PushOutboxRequest { diff --git a/crates/sdk/src/transport.rs b/crates/sdk/src/transport.rs @@ -196,7 +196,7 @@ impl TargetPolicy { Self::MeshScope(scope) } - #[cfg(any(feature = "signer-adapters", test))] + #[cfg(feature = "signer-adapters")] pub(crate) fn workflow_target_policy(self) -> Self { self } @@ -544,18 +544,13 @@ impl HybridProfile { } } -#[derive(Clone, PartialEq, Eq)] +#[derive(Clone, Default, PartialEq, Eq)] pub enum ProxyAuth { + #[default] None, BearerToken(String), } -impl Default for ProxyAuth { - fn default() -> Self { - Self::None - } -} - impl core::fmt::Debug for ProxyAuth { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { @@ -613,21 +608,24 @@ impl ProxyProfile { } } -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "snake_case", tag = "kind")] #[non_exhaustive] pub enum TransportProfile { + #[default] LocalOnly, - Nostr { profile: NostrProfile }, - ReticulumPreview { profile: ReticulumPreviewProfile }, - Hybrid { profile: HybridProfile }, - Proxy { profile: ProxyProfile }, -} - -impl Default for TransportProfile { - fn default() -> Self { - Self::LocalOnly - } + Nostr { + profile: NostrProfile, + }, + ReticulumPreview { + profile: ReticulumPreviewProfile, + }, + Hybrid { + profile: HybridProfile, + }, + Proxy { + profile: ProxyProfile, + }, } impl TransportProfile { diff --git a/crates/sdk/src/workflow_runtime.rs b/crates/sdk/src/workflow_runtime.rs @@ -17,6 +17,8 @@ use radroots_outbox::{ RadrootsOutboxReticulumPreviewBehavior, RadrootsOutboxSignedOperationInput, }; use radroots_transport::{RadrootsTransportKind, RadrootsTransportTarget}; +use sha2::{Digest, Sha256}; +use sqlx::Row; const SDK_LOCAL_EVENT_ENDPOINT_URI: &str = "local:sdk"; @@ -73,34 +75,37 @@ async fn enqueue_signed_workflow_event( signed_event: RadrootsSignedEvent, delivery_plan: SdkResolvedDeliveryPlan, ) -> Result<SdkWorkflowEnqueueReceipt, RadrootsSdkError> { - let idempotency_key = match request.idempotency_key { - Some(idempotency_key) => idempotency_key, - None => SdkIdempotencyKey::derive( - request.operation_kind, - request.frozen_draft.expected_event_id_str(), - request.frozen_draft.expected_pubkey_str(), - ), - }; + let idempotency_key = + request + .idempotency_key + .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 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 preflight_input = signed_outbox_input( + let mut tx = + sdk._event_store + .pool() + .begin() + .await + .map_err(|error| RadrootsSdkError::EventStore { + message: error.to_string(), + })?; + record_runtime_operation_journal( + &mut tx, request.operation_kind, + request.actor, request.frozen_draft, - signed_event.clone(), - delivery_plan_value.clone(), - idempotency_key, - false, + &idempotency_key_for_enqueue, observed_at_ms, - ); - let preflight = sdk - ._outbox - .preflight_signed_operation_idempotency(&preflight_input) - .await?; - let partial_failure_digest_prefix = - digest_prefix(preflight.operation_idempotency_digest.as_str()); + ) + .await?; let local_import_observation = RadrootsTransportObservation::new( RadrootsTransportKind::Local, SDK_LOCAL_EVENT_ENDPOINT_URI, @@ -109,37 +114,35 @@ async fn enqueue_signed_workflow_event( )?; let ingest = RadrootsEventIngest::new(signed_event.clone(), observed_at_ms) .with_observation(local_import_observation); - let ingest_receipt = sdk._event_store.ingest_event(ingest).await?; + let ingest_receipt = sdk + ._event_store + .ingest_event_in_transaction(&mut tx, ingest) + .await?; let outbox_input = signed_outbox_input( request.operation_kind, request.frozen_draft, signed_event, delivery_plan_value, - idempotency_key_for_enqueue, + idempotency_key_for_enqueue.clone(), ingest_receipt.inserted, observed_at_ms, ); let outbox_receipt = sdk ._outbox - .enqueue_signed_operation(outbox_input) + .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| { - if matches!( - error, - radroots_outbox::RadrootsOutboxError::IdempotencyConflict { .. } - ) { - RadrootsSdkError::partial_outbox_idempotency_conflict_mutation( - signed_event_id.as_str(), - request.operation_kind, - partial_failure_digest_prefix.as_str(), - ) - } else { - RadrootsSdkError::partial_outbox_enqueue_mutation( - signed_event_id.as_str(), - request.operation_kind, - partial_failure_digest_prefix.as_str(), - ) - } + .map_err(|error| RadrootsSdkError::EventStore { + message: error.to_string(), })?; let idempotency_digest_prefix = digest_prefix(outbox_receipt.operation_idempotency_digest.as_str()); @@ -316,6 +319,100 @@ 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 = ?", + ) + .bind("1") + .bind(operation_kind) + .bind(actor.pubkey().as_str()) + .bind(idempotency_key.as_str()) + .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") + .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()), + }); + } + return Ok(()); + } + + sqlx::query( + "INSERT INTO sdk_runtime_operation_journal(contract_version, operation_id, actor_pubkey, idempotency_key, request_digest_sha256_hex, created_at_ms) VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind("1") + .bind(operation_kind) + .bind(actor.pubkey().as_str()) + .bind(idempotency_key.as_str()) + .bind(request_digest.as_str()) + .bind(observed_at_ms) + .execute(&mut **tx) + .await + .map(|_| ()) + .map_err(|error| RadrootsSdkError::EventStore { + message: error.to_string(), + }) +} + +async fn complete_runtime_operation_journal( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + operation_kind: &'static str, + actor: &RadrootsActorContext, + idempotency_key: &SdkIdempotencyKey, + observed_at_ms: i64, +) -> Result<(), RadrootsSdkError> { + sqlx::query( + "UPDATE sdk_runtime_operation_journal SET completed_at_ms = ? WHERE contract_version = ? AND operation_id = ? AND actor_pubkey = ? AND idempotency_key = ?", + ) + .bind(observed_at_ms) + .bind("1") + .bind(operation_kind) + .bind(actor.pubkey().as_str()) + .bind(idempotency_key.as_str()) + .execute(&mut **tx) + .await + .map(|_| ()) + .map_err(|error| RadrootsSdkError::EventStore { + message: error.to_string(), + }) +} + +fn runtime_request_digest( + operation_kind: &'static str, + actor: &RadrootsActorContext, + frozen_draft: &RadrootsEventDraft, +) -> String { + 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(), + }); + let bytes = + serde_json::to_vec(&digest_document).expect("runtime journal digest document serializes"); + hex::encode(Sha256::digest(bytes)) +} + #[cfg(test)] #[path = "../tests/unit/workflow_runtime_tests.rs"] mod tests; diff --git a/crates/sdk/tests/dvm_runtime.rs b/crates/sdk/tests/dvm_runtime.rs @@ -1,894 +0,0 @@ -#![cfg(feature = "runtime")] - -use radroots_authority::{ - RadrootsActorContext, RadrootsEventSigner, RadrootsSignerError, RadrootsSignerIdentity, -}; -use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, -}; -use radroots_event::{ - RadrootsEventEnvelope, RadrootsEventPtr, - contract::RadrootsActorRole, - draft::{RadrootsEventDraft, RadrootsSignedEvent}, - ids::{ - RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId, - RadrootsPublicKey, - }, - kinds::{KIND_LISTING, KIND_TRADE_TRANSITION_PROOF_REQUEST}, - order::{ - RadrootsOrderDecision, RadrootsOrderDecisionOutcome, RadrootsOrderEconomicItem, - RadrootsOrderEconomicLine, RadrootsOrderEconomics, RadrootsOrderInventoryCommitment, - RadrootsOrderItem, RadrootsOrderPricingBasis, RadrootsOrderRequest, - }, - wire::RadrootsNip01EventWireParts, -}; -use radroots_event_store::{RadrootsEventIngest, RadrootsEventStore}; -use radroots_nostr::prelude::{ - RadrootsNostrKeys, RadrootsNostrSecretKey, RadrootsNostrTimestamp, radroots_event_from_nostr, - radroots_nostr_build_event, radroots_nostr_sign_frozen_draft, -}; -use radroots_outbox::RadrootsOutbox; -use radroots_sdk::{ - DVM_TRADE_TRANSITION_PROOF_REQUEST_OPERATION_KIND, DvmTradeTransitionProofEnqueueRequest, - DvmTradeTransitionProofPrepareRequest, DvmValidationReceiptIngestRequest, NostrRelayUrlPolicy, - RadrootsClient, RadrootsSdkError, RadrootsSdkStorageConfig, RadrootsSdkTimestamp, - RadrootsTradeInventoryBinWitnessDto, RadrootsTradeValidationTrustState, SdkMutationState, - TargetPolicy, TradeStatusKind, TradeStatusNextActionKind, TradeStatusRequest, -}; -#[cfg(feature = "signer-adapters")] -use radroots_sdk::{RadrootsSdkLocalKeySigner, RadrootsSdkSignerProvider}; -use radroots_trade::validation_receipt::{ - RadrootsTradeCommitmentConfidence, RadrootsTradeValidationReceipt, - RadrootsValidationReceiptProof, RadrootsValidationReceiptProofSystem, - RadrootsValidationReceiptResult, RadrootsValidationReceiptStatement, - RadrootsValidationReceiptType, validation_receipt_event_build, - validation_receipt_public_values_hash_hex, -}; - -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"; - -#[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) -> &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 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") -} - -#[tokio::test] -async fn dvm_trade_transition_proof_request_enqueues_signed_job() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let signer = FixtureSigner::new(SERVICE_SECRET_KEY_HEX); - let actor = service_actor(&signer); - let listing_event_id = deterministic_event_id("listing-event"); - let request_event_id = deterministic_event_id("request-event"); - let decision_event_id = deterministic_event_id("decision-event"); - let request = DvmTradeTransitionProofEnqueueRequest::new( - actor, - signer.pubkey().clone(), - listing_address(), - listing_event_id.clone(), - request_event_id.clone(), - decision_event_id.clone(), - inventory_bins(), - explicit_relays(), - ) - .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_050)) - .with_inventory_sequence(7); - - let receipt = sdk - .dvm() - .enqueue_trade_transition_proof_request_with_explicit_signer(request, &signer) - .await - .expect("enqueue DVM proof request"); - - assert_eq!(receipt.signed_event_id, receipt.expected_event_id); - assert_eq!(receipt.state, SdkMutationState::StoredAndQueued); - assert_eq!(receipt.local_event_seq, 1); - assert_eq!(receipt.listing_event_id, listing_event_id); - assert_eq!(receipt.request_event_id, request_event_id); - assert_eq!(receipt.decision_event_id, decision_event_id); - assert_eq!( - outbox_operation_kind(&sdk, receipt.outbox_operation_id).await, - DVM_TRADE_TRANSITION_PROOF_REQUEST_OPERATION_KIND - ); - - let stored = store - .get_event(receipt.signed_event_id.as_str()) - .await - .expect("get event") - .expect("stored DVM request"); - let content: serde_json::Value = - serde_json::from_str(&stored.content).expect("proof request content"); - let tags: Vec<Vec<String>> = serde_json::from_str(&stored.tags_json).expect("stored tags"); - assert_eq!(stored.kind, KIND_TRADE_TRANSITION_PROOF_REQUEST); - assert_eq!(content["proof_mode"], "none"); - assert_eq!(content["inventory_sequence"], 7); - assert!( - tags.iter() - .any(|tag| tag == &vec!["p".to_owned(), signer.pubkey().as_str().to_owned()]) - ); - assert!( - tags.iter() - .any(|tag| tag.first().map(String::as_str) == Some("a")) - ); - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 1 - ); -} - -#[cfg(feature = "signer-adapters")] -#[tokio::test] -async fn dvm_trade_transition_proof_request_uses_configured_local_signer() { - let tempdir = tempfile::tempdir().expect("tempdir"); - let signer_keys = keys_from_secret(SERVICE_SECRET_KEY_HEX); - let signer_pubkey = signer_keys.public_key().to_hex(); - let sdk = RadrootsClient::builder() - .storage(RadrootsSdkStorageConfig::Directory( - tempdir.path().join("sdk"), - )) - .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)) - .signer_provider(RadrootsSdkSignerProvider::LocalKey( - RadrootsSdkLocalKeySigner::new(signer_keys).expect("local signer"), - )) - .build() - .await - .expect("sdk"); - let request = DvmTradeTransitionProofEnqueueRequest::new( - RadrootsActorContext::test(signer_pubkey.as_str(), [RadrootsActorRole::Service]) - .expect("service actor"), - signer_pubkey.parse().expect("worker pubkey"), - listing_address(), - deterministic_event_id("listing-event"), - deterministic_event_id("request-event"), - deterministic_event_id("decision-event"), - inventory_bins(), - explicit_relays(), - ) - .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_050)); - - let receipt = sdk - .dvm() - .enqueue_trade_transition_proof_request(request) - .await - .expect("configured signer enqueue"); - - assert_eq!(receipt.signed_event_id, receipt.expected_event_id); - assert_eq!( - outbox_operation_kind(&sdk, receipt.outbox_operation_id).await, - DVM_TRADE_TRANSITION_PROOF_REQUEST_OPERATION_KIND - ); -} - -#[cfg(feature = "signer-adapters")] -#[tokio::test] -async fn dvm_configured_enqueue_reports_prepare_and_target_errors_without_mutation() { - let (tempdir, sdk, store) = directory_sdk_and_store_with_signer().await; - let signer_keys = keys_from_secret(SERVICE_SECRET_KEY_HEX); - let signer_pubkey = signer_keys.public_key().to_hex(); - let invalid = DvmTradeTransitionProofEnqueueRequest::new( - RadrootsActorContext::test(signer_pubkey.as_str(), [RadrootsActorRole::Service]) - .expect("service actor"), - signer_pubkey.parse().expect("worker pubkey"), - listing_address(), - deterministic_event_id("listing-event"), - deterministic_event_id("request-event"), - deterministic_event_id("decision-event"), - Vec::new(), - explicit_relays(), - ); - let error = sdk - .dvm() - .enqueue_trade_transition_proof_request(invalid) - .await - .expect_err("prepare failure"); - assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. })); - - let missing_relays = DvmTradeTransitionProofEnqueueRequest::new( - RadrootsActorContext::test(signer_pubkey.as_str(), [RadrootsActorRole::Service]) - .expect("service actor"), - signer_pubkey.parse().expect("worker pubkey"), - listing_address(), - deterministic_event_id("listing-event"), - deterministic_event_id("request-event"), - deterministic_event_id("decision-event"), - inventory_bins(), - TargetPolicy::default_profile(), - ); - let error = sdk - .dvm() - .enqueue_trade_transition_proof_request(missing_relays) - .await - .expect_err("missing configured relays"); - assert!(matches!( - error, - RadrootsSdkError::EmptyTransportTargets { .. } - )); - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 0 - ); - drop(tempdir); -} - -#[tokio::test] -async fn dvm_validation_receipt_ingest_commits_pending_trade_status() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let request_event = signed_order_request_event("order-dvm-ingest", 10); - let request_event_id = RadrootsEventId::parse(request_event.id_str()).expect("request id"); - let decision_event = signed_order_decision_event("order-dvm-ingest", &request_event_id, 11); - let decision_event_id = RadrootsEventId::parse(decision_event.id_str()).expect("decision id"); - for (event, observed_at_ms) in [ - (request_event.clone(), 1_000), - (decision_event.clone(), 1_100), - ] { - store - .ingest_event(RadrootsEventIngest::new( - signed_event_from_envelope(event), - observed_at_ms, - )) - .await - .expect("ingest order event"); - } - let pending = sdk - .trades() - .status(status_request("order-dvm-ingest")) - .await - .expect("pending status"); - assert_eq!(pending.status, TradeStatusKind::AgreedPendingRhi); - assert!(pending.rhi_receipt_event_id.is_none()); - - let listing_event_id = deterministic_event_id("listing-event"); - let receipt_event = signed_validation_receipt_event( - "order-dvm-ingest", - &listing_event_id, - &request_event_id, - &decision_event_id, - 12, - ); - let receipt_event_id = RadrootsEventId::parse(receipt_event.id_str()).expect("receipt id"); - let ingest = sdk - .dvm() - .ingest_validation_receipt( - DvmValidationReceiptIngestRequest::new(signed_event_from_envelope(receipt_event)) - .with_expected_order_id(order_id("order-dvm-ingest")) - .with_expected_listing_event_id(listing_event_id.clone()) - .with_expected_root_event_id(request_event_id.clone()) - .with_expected_target_event_id(decision_event_id.clone()) - .with_observed_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_060)), - ) - .await - .expect("ingest validation receipt"); - - assert_eq!(ingest.receipt_event_id, receipt_event_id); - assert_eq!(ingest.order_id.as_str(), "order-dvm-ingest"); - assert_eq!(ingest.listing_event_id, listing_event_id); - assert_eq!(ingest.root_event_id, request_event_id); - assert_eq!(ingest.target_event_id, decision_event_id); - assert_eq!( - ingest.receipt_type, - RadrootsValidationReceiptType::TradeTransition - ); - assert_eq!(ingest.result, RadrootsValidationReceiptResult::Valid); - assert_eq!( - ingest.proof_system, - RadrootsValidationReceiptProofSystem::None - ); - assert_eq!(ingest.validation_authority, None); - assert_eq!( - ingest.commitment_confidence, - RadrootsTradeCommitmentConfidence::LocalOnly - ); - assert_eq!(ingest.local_event_seq, 3); - assert!(ingest.inserted); - assert_eq!(ingest.refresh.validation_receipts, 1); - assert_eq!(ingest.refresh.trade_upserts, 1); - - let committed = sdk - .trades() - .status(status_request("order-dvm-ingest")) - .await - .expect("committed status"); - assert_eq!(committed.status, TradeStatusKind::AgreedPendingRhi); - assert!(!committed.lifecycle_terminal); - assert_eq!( - committed.next_action, - TradeStatusNextActionKind::AwaitRhiValidation - ); - assert_eq!( - committed.rhi_receipt_event_id, - Some(receipt_event_id.clone()) - ); - assert_eq!(committed.last_event_id, Some(decision_event_id)); - let trust = committed.validation_trust.expect("validation trust"); - assert_eq!(trust.state, RadrootsTradeValidationTrustState::Untrusted); - assert_eq!( - trust.reason_code.as_deref(), - Some("validation_trust_policy_empty") - ); - assert!(!trust.production_committed); - assert_eq!(trust.proof_system.as_deref(), Some("none")); -} - -#[tokio::test] -async fn dvm_validation_receipt_ingest_rejects_binding_mismatch_without_mutation() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let receipt_event = signed_validation_receipt_event( - "order-dvm-mismatch", - &deterministic_event_id("listing-event"), - &deterministic_event_id("request-event"), - &deterministic_event_id("decision-event"), - 12, - ); - - let error = sdk - .dvm() - .ingest_validation_receipt( - DvmValidationReceiptIngestRequest::new(signed_event_from_envelope(receipt_event)) - .with_expected_order_id(order_id("other-order")), - ) - .await - .expect_err("binding mismatch"); - - assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. })); - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 0 - ); -} - -#[tokio::test] -async fn dvm_trade_transition_proof_request_reports_prepare_and_target_errors_without_mutation() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let signer = FixtureSigner::new(SERVICE_SECRET_KEY_HEX); - let actor = service_actor(&signer); - let invalid = DvmTradeTransitionProofEnqueueRequest::new( - actor.clone(), - signer.pubkey().clone(), - listing_address(), - deterministic_event_id("listing-event"), - deterministic_event_id("request-event"), - deterministic_event_id("decision-event"), - Vec::new(), - explicit_relays(), - ); - let error = sdk - .dvm() - .enqueue_trade_transition_proof_request_with_explicit_signer(invalid, &signer) - .await - .expect_err("prepare failure"); - assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. })); - - let missing_relays = DvmTradeTransitionProofEnqueueRequest::new( - actor, - signer.pubkey().clone(), - listing_address(), - deterministic_event_id("listing-event"), - deterministic_event_id("request-event"), - deterministic_event_id("decision-event"), - inventory_bins(), - TargetPolicy::default_profile(), - ); - let error = sdk - .dvm() - .enqueue_trade_transition_proof_request_with_explicit_signer(missing_relays, &signer) - .await - .expect_err("missing configured relays"); - assert!(matches!( - error, - RadrootsSdkError::EmptyTransportTargets { .. } - )); - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 0 - ); -} - -#[tokio::test] -async fn dvm_validation_receipt_ingest_reports_timestamp_and_refresh_errors() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let overflow_observed = signed_validation_receipt_event( - "order-dvm-observed-overflow", - &deterministic_event_id("listing-event"), - &deterministic_event_id("request-event"), - &deterministic_event_id("decision-event"), - 12, - ); - let error = sdk - .dvm() - .ingest_validation_receipt( - DvmValidationReceiptIngestRequest::new(signed_event_from_envelope(overflow_observed)) - .with_observed_at(RadrootsSdkTimestamp::from_unix_seconds(u64::MAX)), - ) - .await - .expect_err("observed overflow"); - assert!(matches!( - error, - RadrootsSdkError::TimestampOutOfRange { value } if value == u64::MAX - )); - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 0 - ); - - let (_tempdir, sdk, store) = - directory_sdk_and_store_with_clock(RadrootsSdkTimestamp::from_unix_seconds(u64::MAX)).await; - let default_observed = signed_validation_receipt_event( - "order-dvm-default-observed-overflow", - &deterministic_event_id("listing-event"), - &deterministic_event_id("request-event"), - &deterministic_event_id("decision-event"), - 12, - ); - let error = sdk - .dvm() - .ingest_validation_receipt(DvmValidationReceiptIngestRequest::new( - signed_event_from_envelope(default_observed), - )) - .await - .expect_err("default observed overflow"); - assert!(matches!( - error, - RadrootsSdkError::TimestampOutOfRange { value } if value == u64::MAX - )); - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 0 - ); - - let (_tempdir, sdk, store) = - directory_sdk_and_store_with_clock(RadrootsSdkTimestamp::from_unix_seconds(u64::MAX)).await; - let refresh_overflow = signed_validation_receipt_event( - "order-dvm-refresh-overflow", - &deterministic_event_id("listing-event"), - &deterministic_event_id("request-event"), - &deterministic_event_id("decision-event"), - 12, - ); - let error = sdk - .dvm() - .ingest_validation_receipt( - DvmValidationReceiptIngestRequest::new(signed_event_from_envelope(refresh_overflow)) - .with_observed_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)), - ) - .await - .expect_err("refresh overflow"); - assert!(matches!( - error, - RadrootsSdkError::TimestampOutOfRange { value } if value == u64::MAX - )); - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 1 - ); -} - -#[tokio::test] -async fn dvm_prepare_and_ingest_use_sdk_clock_defaults() { - let (_tempdir, sdk, _store) = directory_sdk_and_store().await; - let signer = FixtureSigner::new(SERVICE_SECRET_KEY_HEX); - let plan = sdk - .dvm() - .prepare_trade_transition_proof_request(DvmTradeTransitionProofPrepareRequest::new( - service_actor(&signer), - signer.pubkey().clone(), - listing_address(), - deterministic_event_id("listing-event"), - deterministic_event_id("request-event"), - deterministic_event_id("decision-event"), - inventory_bins(), - )) - .expect("default timestamp plan"); - assert_eq!( - plan.created_at, - RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000) - ); - - let receipt_event = signed_validation_receipt_event( - "order-dvm-clock-default", - &deterministic_event_id("listing-event"), - &deterministic_event_id("request-event"), - &deterministic_event_id("decision-event"), - 12, - ); - let ingest = sdk - .dvm() - .ingest_validation_receipt(DvmValidationReceiptIngestRequest::new( - signed_event_from_envelope(receipt_event), - )) - .await - .expect("default observed timestamp ingest"); - - assert_eq!(ingest.local_event_seq, 1); -} - -#[tokio::test] -async fn dvm_validation_receipt_ingest_rejects_invalid_verified_order_id() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let receipt_event = signed_validation_receipt_event( - "bad order id", - &deterministic_event_id("listing-event"), - &deterministic_event_id("request-event"), - &deterministic_event_id("decision-event"), - 12, - ); - - let error = sdk - .dvm() - .ingest_validation_receipt(DvmValidationReceiptIngestRequest::new( - signed_event_from_envelope(receipt_event), - )) - .await - .expect_err("invalid order id"); - - assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. })); - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 0 - ); -} - -#[tokio::test] -async fn dvm_validation_receipt_ingest_rejects_invalid_receipt_event_id() { - let (_tempdir, _sdk, store) = directory_sdk_and_store().await; - let receipt_event = signed_validation_receipt_event( - "order-dvm-bad-receipt-id", - &deterministic_event_id("listing-event"), - &deterministic_event_id("request-event"), - &deterministic_event_id("decision-event"), - 12, - ); - let mut wire = receipt_event.to_nip01_wire(); - wire.id = "bad id".to_owned(); - let raw_json = serde_json::to_string(&wire).expect("raw event json"); - let error = RadrootsSignedEvent::from_wire_verified_id(wire, raw_json) - .expect_err("invalid receipt event id"); - - assert!(error.to_string().contains("event wire id mismatch")); - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 0 - ); -} - -async fn directory_sdk_and_store() -> (tempfile::TempDir, RadrootsClient, RadrootsEventStore) { - directory_sdk_and_store_with_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)).await -} - -async fn directory_sdk_and_store_with_clock( - timestamp: RadrootsSdkTimestamp, -) -> (tempfile::TempDir, RadrootsClient, RadrootsEventStore) { - let tempdir = tempfile::tempdir().expect("tempdir"); - let sdk = RadrootsClient::builder() - .storage(RadrootsSdkStorageConfig::Directory( - tempdir.path().join("sdk"), - )) - .fixed_clock(timestamp) - .build() - .await - .expect("sdk"); - let store = - RadrootsEventStore::open_file(&sdk.storage_paths().expect("paths").event_store_path) - .await - .expect("event store"); - (tempdir, sdk, store) -} - -#[cfg(feature = "signer-adapters")] -async fn directory_sdk_and_store_with_signer() --> (tempfile::TempDir, RadrootsClient, RadrootsEventStore) { - let tempdir = tempfile::tempdir().expect("tempdir"); - let signer_keys = keys_from_secret(SERVICE_SECRET_KEY_HEX); - let sdk = RadrootsClient::builder() - .storage(RadrootsSdkStorageConfig::Directory( - tempdir.path().join("sdk"), - )) - .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)) - .signer_provider(RadrootsSdkSignerProvider::LocalKey( - RadrootsSdkLocalKeySigner::new(signer_keys).expect("local signer"), - )) - .build() - .await - .expect("sdk"); - let store = - RadrootsEventStore::open_file(&sdk.storage_paths().expect("paths").event_store_path) - .await - .expect("event store"); - (tempdir, sdk, store) -} - -fn service_actor(signer: &FixtureSigner) -> RadrootsActorContext { - RadrootsActorContext::test(signer.pubkey().as_str(), [RadrootsActorRole::Service]) - .expect("service actor") -} - -fn explicit_relays() -> TargetPolicy { - TargetPolicy::try_nostr_relays([RELAY], NostrRelayUrlPolicy::Public).expect("relay targets") -} - -fn inventory_bins() -> Vec<RadrootsTradeInventoryBinWitnessDto> { - vec![RadrootsTradeInventoryBinWitnessDto { - bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"), - listing_capacity: 5, - previous_reserved: 1, - }] -} - -fn order_id(raw: &str) -> RadrootsOrderId { - RadrootsOrderId::parse(raw).expect("order id") -} - -fn status_request(raw: &str) -> TradeStatusRequest { - TradeStatusRequest::parse(raw).expect("status request") -} - -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()), - } -} - -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 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(), - } -} - -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, - }], - }, - } -} - -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) -} - -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_validation_receipt_event( - raw_order_id: &str, - listing_event_id: &RadrootsEventId, - root_event_id: &RadrootsEventId, - target_event_id: &RadrootsEventId, - created_at: u32, -) -> RadrootsEventEnvelope { - 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: RadrootsValidationReceiptProof { - inline_proof_base64: None, - mode: None, - program_hash: None, - proof_reference: None, - system: RadrootsValidationReceiptProofSystem::None, - verifying_key_hash: None, - }, - 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(), - statement_type: RadrootsValidationReceiptType::TradeTransition, - }, - version: 1, - }; - let parts = validation_receipt_event_build(raw_order_id, &receipt).expect("receipt event"); - signed_event(SERVICE_SECRET_KEY_HEX, created_at, parts) -} - -fn signed_event( - secret_key_hex: &str, - created_at: u32, - parts: RadrootsNip01EventWireParts, -) -> RadrootsEventEnvelope { - let secret_key = RadrootsNostrSecretKey::from_hex(secret_key_hex).expect("secret key"); - let keys = RadrootsNostrKeys::new(secret_key); - let event = 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"); - radroots_event_from_nostr(&event) -} - -#[cfg(feature = "signer-adapters")] -fn keys_from_secret(secret_key_hex: &str) -> RadrootsNostrKeys { - let secret_key = RadrootsNostrSecretKey::from_hex(secret_key_hex).expect("secret key"); - RadrootsNostrKeys::new(secret_key) -} - -fn hash32(ch: char) -> String { - format!("0x{}", ch.to_string().repeat(64)) -} - -async fn outbox_operation_kind(sdk: &RadrootsClient, operation_id: i64) -> String { - let paths = sdk.storage_paths().expect("paths"); - let outbox = RadrootsOutbox::open_file(&paths.outbox_path) - .await - .expect("outbox"); - outbox - .get_operation(operation_id) - .await - .expect("outbox operation") - .expect("outbox operation") - .operation_kind -} diff --git a/crates/sdk/tests/farms_runtime.rs b/crates/sdk/tests/farms_runtime.rs @@ -264,7 +264,7 @@ async fn farm_prepare_publish_is_side_effect_free() { ); let paths = sdk.storage_paths().expect("paths"); - let event_store = RadrootsEventStore::open_file(&paths.event_store_path) + let event_store = RadrootsEventStore::open_file(&paths.runtime_path) .await .expect("event store"); assert_eq!( @@ -282,7 +282,7 @@ async fn farm_prepare_publish_is_side_effect_free() { .expect("event lookup") .is_none() ); - let outbox = RadrootsOutbox::open_file(&paths.outbox_path) + let outbox = RadrootsOutbox::open_file(&paths.runtime_path) .await .expect("outbox"); assert!( @@ -630,7 +630,7 @@ async fn farm_enqueue_publish_stores_event_and_queues_signed_outbox_without_prof farm(FARM_B_D_TAG, "North Farm"), TargetPolicy::default_profile(), ) - .try_with_idempotency_key("farm-idem-b") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000229") .expect("idempotency key"); let prepared = sdk .farms() @@ -655,7 +655,7 @@ async fn farm_enqueue_publish_stores_event_and_queues_signed_outbox_without_prof assert!(receipt.idempotency_digest_prefix.is_some()); let paths = sdk.storage_paths().expect("paths"); - let event_store = RadrootsEventStore::open_file(&paths.event_store_path) + let event_store = RadrootsEventStore::open_file(&paths.runtime_path) .await .expect("event store"); let status = event_store @@ -675,7 +675,7 @@ async fn farm_enqueue_publish_stores_event_and_queues_signed_outbox_without_prof Some("radroots.farm.profile.v1") ); - let outbox = RadrootsOutbox::open_file(&paths.outbox_path) + let outbox = RadrootsOutbox::open_file(&paths.runtime_path) .await .expect("outbox"); let outbox_event = outbox @@ -711,7 +711,7 @@ async fn farm_enqueue_publish_returns_sanitized_signer_errors_before_mutation() assert!(!message.contains("ffff")); let paths = sdk.storage_paths().expect("paths"); - let event_store = RadrootsEventStore::open_file(&paths.event_store_path) + let event_store = RadrootsEventStore::open_file(&paths.runtime_path) .await .expect("event store"); assert_eq!( @@ -722,7 +722,7 @@ async fn farm_enqueue_publish_returns_sanitized_signer_errors_before_mutation() .total_events, 0 ); - let outbox = RadrootsOutbox::open_file(&paths.outbox_path) + let outbox = RadrootsOutbox::open_file(&paths.runtime_path) .await .expect("outbox"); assert!( @@ -735,7 +735,7 @@ async fn farm_enqueue_publish_returns_sanitized_signer_errors_before_mutation() } #[tokio::test] -async fn farm_enqueue_publish_derives_order_independent_idempotency_key() { +async fn farm_enqueue_publish_uses_explicit_idempotency_key_across_equivalent_target_order() { let (_tempdir, sdk) = directory_sdk().await; let first = FarmEnqueuePublishRequest::new( farmer_actor(), @@ -743,7 +743,9 @@ async fn farm_enqueue_publish_derives_order_independent_idempotency_key() { TargetPolicy::default_profile(), ) .try_with_nostr_targets([RELAY_B, RELAY], NostrRelayUrlPolicy::Public) - .expect("first transport targets"); + .expect("first transport targets") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000022d") + .expect("first idempotency key"); let second = FarmEnqueuePublishRequest::new( farmer_actor(), farm(FARM_D_D_TAG, "North Farm"), @@ -751,7 +753,9 @@ async fn farm_enqueue_publish_derives_order_independent_idempotency_key() { TargetSet::nostr_relays([RELAY, RELAY_B], NostrRelayUrlPolicy::Public) .expect("second transport targets"), ), - ); + ) + .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000022d") + .expect("second idempotency key"); let first_receipt = sdk .farms() @@ -775,7 +779,7 @@ async fn farm_enqueue_publish_derives_order_independent_idempotency_key() { assert_eq!(second_receipt.state, SdkMutationState::AlreadyQueued); let paths = sdk.storage_paths().expect("paths"); - let outbox = RadrootsOutbox::open_file(&paths.outbox_path) + let outbox = RadrootsOutbox::open_file(&paths.runtime_path) .await .expect("outbox"); let relay_urls = outbox @@ -797,7 +801,9 @@ async fn farm_enqueue_publish_pushes_queued_event_with_mock_relay_sync() { TargetPolicy::default_profile(), ) .try_with_nostr_targets([RELAY], NostrRelayUrlPolicy::Public) - .expect("transport targets"); + .expect("transport targets") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000022e") + .expect("idempotency key"); let enqueue_receipt = sdk .farms() .enqueue_publish_with_explicit_signer(enqueue_request, &FixtureSigner::new(FARMER)) @@ -837,7 +843,7 @@ async fn farm_enqueue_publish_pushes_queued_event_with_mock_relay_sync() { ); assert_eq!(adapter.captured_raw_events().len(), 1); - let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path) + let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path) .await .expect("outbox"); let stored = outbox @@ -858,7 +864,9 @@ async fn farm_hybrid_profile_publishes_after_nostr_success_and_retains_reticulum farmer_actor(), farm(FARM_E_D_TAG, "Hybrid Farm"), TargetPolicy::default_profile(), - ), + ) + .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000022f") + .expect("idempotency key"), &FixtureSigner::new(FARMER), ) .await @@ -891,7 +899,7 @@ async fn farm_hybrid_profile_publishes_after_nostr_success_and_retains_reticulum ); assert_eq!(adapter.captured_raw_events().len(), 1); - let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path) + let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path) .await .expect("outbox"); let stored = outbox @@ -931,17 +939,17 @@ async fn farm_enqueue_publish_reports_preflight_idempotency_conflict_without_mut farm(FARM_E_D_TAG, "North Farm"), TargetPolicy::default_profile(), ) - .try_with_idempotency_key("farm-idem-e") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000022a") .expect("idempotency key"); sdk.farms() .enqueue_publish_with_explicit_signer(first, &FixtureSigner::new(FARMER)) .await .expect("first enqueue"); let paths = sdk.storage_paths().expect("paths"); - let event_store = RadrootsEventStore::open_file(&paths.event_store_path) + let event_store = RadrootsEventStore::open_file(&paths.runtime_path) .await .expect("event store"); - let outbox = RadrootsOutbox::open_file(&paths.outbox_path) + let outbox = RadrootsOutbox::open_file(&paths.runtime_path) .await .expect("outbox"); assert_eq!( @@ -966,7 +974,7 @@ async fn farm_enqueue_publish_reports_preflight_idempotency_conflict_without_mut farm(FARM_F_D_TAG, "Changed Farm"), TargetPolicy::default_profile(), ) - .try_with_idempotency_key("farm-idem-e") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000022a") .expect("idempotency key"); let error = sdk .farms() @@ -983,7 +991,11 @@ async fn farm_enqueue_publish_reports_preflight_idempotency_conflict_without_mut error.recovery_actions(), vec![RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey] ); - assert!(!error.to_string().contains("farm-idem-e")); + assert!( + !error + .to_string() + .contains("01890f0e-6c00-7000-8000-00000000022a") + ); assert_eq!( event_store .status_summary() @@ -1043,7 +1055,7 @@ async fn farm_runtime_dtos_serialize_deterministically() { .try_with_nostr_targets([RELAY, RELAY_B], NostrRelayUrlPolicy::Public) .expect("relay targets") .with_idempotency_key( - SdkIdempotencyKey::new("farm-serialized-idempotency").expect("idempotency"), + SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-00000000022b").expect("idempotency"), ) .with_created_at(created_at); let enqueue_json = serde_json::to_value(&enqueue_request).expect("enqueue request json"); @@ -1091,14 +1103,14 @@ async fn farm_runtime_dtos_serialize_deterministically() { "a1997ec4596596af6ffc65e6a30ab7cffa53ea71f524c1c86d64018b96d130af" ] }, - "idempotency_key": { "value": "<redacted>", "len": 27 }, + "idempotency_key": { "value": "<redacted>", "len": 36 }, "created_at": 1_700_000_123 }) ); assert!( !enqueue_json .to_string() - .contains("farm-serialized-idempotency") + .contains("01890f0e-6c00-7000-8000-00000000022b") ); let try_key_request = FarmEnqueuePublishRequest::new( @@ -1106,11 +1118,11 @@ async fn farm_runtime_dtos_serialize_deterministically() { farm(FARM_C_D_TAG, "Queued Farm"), TargetPolicy::default_profile(), ) - .try_with_idempotency_key("farm-serialized-try-key") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000022c") .expect("try idempotency key"); assert_eq!( serde_json::to_value(&try_key_request).expect("try key request json")["idempotency_key"], - serde_json::json!({ "value": "<redacted>", "len": 23 }) + serde_json::json!({ "value": "<redacted>", "len": 36 }) ); let private_upsert = FarmPrivateLocationUpsertRequest::new( diff --git a/crates/sdk/tests/listings_runtime.rs b/crates/sdk/tests/listings_runtime.rs @@ -27,8 +27,9 @@ use radroots_sdk::{ RadrootsSdkRecoveryAction, RadrootsSdkTimestamp, ReticulumPreviewProfile, SdkIdempotencyKey, SdkMutationState, TargetPolicy, TargetSet, TransportProfile, }; -use radroots_trade::listing::RadrootsListingDraftDocumentV1; +use radroots_trade::listing::RadrootsListingEditDocumentV1; use radroots_transport_nostr::{RadrootsMockRelayPublishAdapter, RadrootsNostrTransport}; +use sqlx::Row; #[path = "support/serializer_failure.rs"] mod serializer_failure; @@ -219,7 +220,7 @@ async fn prepare_publish_is_side_effect_free() { ); let paths = sdk.storage_paths().expect("paths"); - let event_store = RadrootsEventStore::open_file(&paths.event_store_path) + let event_store = RadrootsEventStore::open_file(&paths.runtime_path) .await .expect("event store"); assert!( @@ -229,7 +230,7 @@ async fn prepare_publish_is_side_effect_free() { .expect("event lookup") .is_none() ); - let outbox = RadrootsOutbox::open_file(&paths.outbox_path) + let outbox = RadrootsOutbox::open_file(&paths.runtime_path) .await .expect("outbox"); assert!( @@ -263,7 +264,7 @@ async fn enqueue_publish_stores_event_and_queues_signed_outbox_without_publish() listing(LISTING_B_D_TAG, "Coffee"), TargetPolicy::default_profile(), ) - .try_with_idempotency_key("idem-b") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000224") .expect("idempotency key"); let prepared = sdk .listings() @@ -281,7 +282,6 @@ async fn enqueue_publish_stores_event_and_queues_signed_outbox_without_publish() assert_eq!(receipt.expected_event_id, prepared.expected_event_id); assert_eq!(receipt.signed_event_id, receipt.expected_event_id); assert_eq!(receipt.public_listing_addr, prepared.public_listing_addr); - assert_eq!(receipt.draft_listing_addr, prepared.draft_listing_addr); assert_eq!(receipt.local_event_seq, 1); assert_eq!(receipt.outbox_operation_id, 1); assert_eq!(receipt.outbox_event_id, 1); @@ -289,7 +289,7 @@ async fn enqueue_publish_stores_event_and_queues_signed_outbox_without_publish() assert!(receipt.idempotency_digest_prefix.is_some()); let paths = sdk.storage_paths().expect("paths"); - let event_store = RadrootsEventStore::open_file(&paths.event_store_path) + let event_store = RadrootsEventStore::open_file(&paths.runtime_path) .await .expect("event store"); assert!( @@ -300,7 +300,7 @@ async fn enqueue_publish_stores_event_and_queues_signed_outbox_without_publish() .is_some() ); - let outbox = RadrootsOutbox::open_file(&paths.outbox_path) + let outbox = RadrootsOutbox::open_file(&paths.runtime_path) .await .expect("outbox"); let outbox_event = outbox @@ -351,7 +351,10 @@ async fn prepare_then_enqueue_prepared_uses_same_event_id() { &actor, prepared.clone(), TargetPolicy::default_profile(), - None, + Some( + SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000238") + .expect("idempotency"), + ), &FixtureSigner::new(SELLER), ) .await @@ -361,7 +364,7 @@ async fn prepare_then_enqueue_prepared_uses_same_event_id() { assert_eq!(receipt.signed_event_id, prepared.expected_event_id); let paths = sdk.storage_paths().expect("paths"); - let event_store = RadrootsEventStore::open_file(&paths.event_store_path) + let event_store = RadrootsEventStore::open_file(&paths.runtime_path) .await .expect("event store"); assert!( @@ -372,7 +375,7 @@ async fn prepare_then_enqueue_prepared_uses_same_event_id() { .is_some() ); - let outbox = RadrootsOutbox::open_file(&paths.outbox_path) + let outbox = RadrootsOutbox::open_file(&paths.runtime_path) .await .expect("outbox"); let outbox_event = outbox @@ -391,7 +394,7 @@ async fn enqueue_receipt_debug_omits_signed_event_payload_material() { listing(LISTING_A_D_TAG, "Coffee"), TargetPolicy::default_profile(), ) - .try_with_idempotency_key("debug-secret-idempotency") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000225") .expect("idempotency key"); let receipt = sdk .listings() @@ -402,7 +405,7 @@ async fn enqueue_receipt_debug_omits_signed_event_payload_material() { assert!(debug.contains("ListingEnqueueReceipt")); assert!(debug.contains("StoredAndQueued")); - assert!(!debug.contains("debug-secret-idempotency")); + assert!(!debug.contains("01890f0e-6c00-7000-8000-000000000225")); assert!(!debug.contains("raw_json")); assert!(!debug.contains("\"tags\"")); assert!(!debug.contains("\"content\"")); @@ -427,7 +430,7 @@ async fn listing_runtime_dtos_serialize_deterministically() { let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_123); let prepare_request = ListingPreparePublishRequest::from_document( actor(), - RadrootsListingDraftDocumentV1::new(listing(LISTING_A_D_TAG, "Serialized Coffee")), + RadrootsListingEditDocumentV1::new(listing(LISTING_A_D_TAG, "Serialized Coffee")), ) .with_created_at(created_at); let prepare_json = serde_json::to_value(&prepare_request).expect("prepare request json"); @@ -452,7 +455,9 @@ async fn listing_runtime_dtos_serialize_deterministically() { ) .try_with_nostr_targets([RELAY, RELAY_B], NostrRelayUrlPolicy::Public) .expect("relay targets") - .with_idempotency_key(SdkIdempotencyKey::new("serialized-idempotency").expect("idempotency")) + .with_idempotency_key( + SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000226").expect("idempotency"), + ) .with_created_at(created_at); let enqueue_json = serde_json::to_value(&enqueue_request).expect("enqueue request json"); assert_struct_serialize_error_paths(&enqueue_request, 5); @@ -486,20 +491,24 @@ async fn listing_runtime_dtos_serialize_deterministically() { ); assert_eq!( enqueue_json["idempotency_key"], - serde_json::json!({ "value": "<redacted>", "len": 22 }) + serde_json::json!({ "value": "<redacted>", "len": 36 }) + ); + assert!( + !enqueue_json + .to_string() + .contains("01890f0e-6c00-7000-8000-000000000226") ); - assert!(!enqueue_json.to_string().contains("serialized-idempotency")); let try_key_request = ListingEnqueuePublishRequest::from_document( actor(), - RadrootsListingDraftDocumentV1::new(listing(LISTING_C_D_TAG, "Queued Coffee")), + RadrootsListingEditDocumentV1::new(listing(LISTING_C_D_TAG, "Queued Coffee")), TargetPolicy::default_profile(), ) - .try_with_idempotency_key("listing-serialized-try-key") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000227") .expect("try idempotency key"); assert_eq!( serde_json::to_value(&try_key_request).expect("try key request json")["idempotency_key"], - serde_json::json!({ "value": "<redacted>", "len": 26 }) + serde_json::json!({ "value": "<redacted>", "len": 36 }) ); let receipt = sdk @@ -531,7 +540,10 @@ async fn enqueue_publish_convenience_matches_prepare_plus_enqueue_prepared() { &prepared_actor, prepared_plan, TargetPolicy::default_profile(), - None, + Some( + SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000239") + .expect("idempotency"), + ), &FixtureSigner::new(SELLER), ) .await @@ -542,7 +554,9 @@ async fn enqueue_publish_convenience_matches_prepare_plus_enqueue_prepared() { actor(), listing(LISTING_H_D_TAG, "Coffee"), TargetPolicy::default_profile(), - ); + ) + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000239") + .expect("idempotency"); let convenience_receipt = convenience_sdk .listings() .enqueue_publish_with_explicit_signer(convenience_request, &FixtureSigner::new(SELLER)) @@ -619,6 +633,8 @@ async fn explicit_historical_created_at_does_not_backdate_observed_at_ms() { listing(LISTING_K_D_TAG, "Coffee"), TargetPolicy::default_profile(), ) + .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000023a") + .expect("idempotency") .with_created_at(created_at); let receipt = sdk @@ -628,7 +644,7 @@ async fn explicit_historical_created_at_does_not_backdate_observed_at_ms() { .expect("enqueue"); let paths = sdk.storage_paths().expect("paths"); - let event_store = RadrootsEventStore::open_file(&paths.event_store_path) + let event_store = RadrootsEventStore::open_file(&paths.runtime_path) .await .expect("event store"); let stored_event = event_store @@ -640,7 +656,7 @@ async fn explicit_historical_created_at_does_not_backdate_observed_at_ms() { assert_eq!(stored_event.inserted_at_ms, observed_at_ms); assert_eq!(stored_event.updated_at_ms, observed_at_ms); - let outbox = RadrootsOutbox::open_file(&paths.outbox_path) + let outbox = RadrootsOutbox::open_file(&paths.runtime_path) .await .expect("outbox"); let outbox_event = outbox @@ -688,17 +704,17 @@ async fn enqueue_publish_reports_preflight_idempotency_conflict_without_mutation listing(LISTING_D_D_TAG, "Coffee"), TargetPolicy::default_profile(), ) - .try_with_idempotency_key("idem-d") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000228") .expect("idempotency key"); sdk.listings() .enqueue_publish_with_explicit_signer(first, &FixtureSigner::new(SELLER)) .await .expect("first enqueue"); let paths = sdk.storage_paths().expect("paths"); - let event_store = RadrootsEventStore::open_file(&paths.event_store_path) + let event_store = RadrootsEventStore::open_file(&paths.runtime_path) .await .expect("event store"); - let outbox = RadrootsOutbox::open_file(&paths.outbox_path) + let outbox = RadrootsOutbox::open_file(&paths.runtime_path) .await .expect("outbox"); assert_eq!( @@ -723,7 +739,7 @@ async fn enqueue_publish_reports_preflight_idempotency_conflict_without_mutation listing(LISTING_E_D_TAG, "Changed"), TargetPolicy::default_profile(), ) - .try_with_idempotency_key("idem-d") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000228") .expect("idempotency key"); let error = sdk .listings() @@ -740,7 +756,11 @@ async fn enqueue_publish_reports_preflight_idempotency_conflict_without_mutation error.recovery_actions(), vec![RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey] ); - assert!(!error.to_string().contains("idem-d")); + assert!( + !error + .to_string() + .contains("01890f0e-6c00-7000-8000-000000000228") + ); assert_eq!( event_store .status_summary() @@ -760,7 +780,95 @@ async fn enqueue_publish_reports_preflight_idempotency_conflict_without_mutation } #[tokio::test] -async fn enqueue_publish_derives_order_independent_idempotency_key() { +async fn enqueue_publish_rolls_back_event_and_journal_when_outbox_conflicts_after_ingest() { + let (_tempdir, sdk) = directory_sdk().await; + let paths = sdk.storage_paths().expect("paths"); + let idempotency_key = "01890f0e-6c00-7000-8000-000000000120"; + let seeded_event_store = RadrootsEventStore::open_file(&paths.runtime_path) + .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', ?, ?)", + ) + .bind(LISTING_PUBLISH_OPERATION_KIND) + .bind(SELLER) + .bind(idempotency_key) + .bind("conflicting-digest") + .bind(1_700_000_000_000i64) + .bind(1_700_000_000_000i64) + .execute(seeded_event_store.pool()) + .await + .expect("seed conflicting operation") + .last_insert_rowid(); + sqlx::query( + "INSERT INTO outbox_event(operation_id, event_id, expected_pubkey, draft_json, state, attempt_count, next_attempt_after_ms, event_store_ingested, event_store_inserted, created_at_ms, updated_at_ms) VALUES (?, ?, ?, '{}', 'signed', 0, ?, 0, 0, ?, ?)", + ) + .bind(conflicting_operation) + .bind("0".repeat(64)) + .bind(SELLER) + .bind(1_700_000_000_000i64) + .bind(1_700_000_000_000i64) + .bind(1_700_000_000_000i64) + .execute(seeded_event_store.pool()) + .await + .expect("seed conflicting event"); + + let request = ListingEnqueuePublishRequest::new( + actor(), + listing(LISTING_E_D_TAG, "Rollback Coffee"), + TargetPolicy::default_profile(), + ) + .try_with_idempotency_key(idempotency_key) + .expect("idempotency key"); + let prepared = sdk + .listings() + .prepare_publish(ListingPreparePublishRequest::new( + actor(), + listing(LISTING_E_D_TAG, "Rollback Coffee"), + )) + .expect("prepared"); + let error = sdk + .listings() + .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(SELLER)) + .await + .expect_err("outbox conflict"); + assert!(matches!( + error, + RadrootsSdkError::IdempotencyConflict { .. } + )); + + let event_store = RadrootsEventStore::open_file(&paths.runtime_path) + .await + .expect("event store"); + assert!( + event_store + .get_event(prepared.expected_event_id.as_str()) + .await + .expect("event lookup") + .is_none() + ); + let journal_count: i64 = sqlx::query("SELECT COUNT(*) FROM sdk_runtime_operation_journal") + .fetch_one(event_store.pool()) + .await + .expect("journal count") + .try_get(0) + .expect("journal count value"); + assert_eq!(journal_count, 0); + let outbox = RadrootsOutbox::open_file(&paths.runtime_path) + .await + .expect("outbox"); + assert_eq!( + outbox + .status_summary(0) + .await + .expect("outbox status after rollback") + .total_events, + 1 + ); +} + +#[tokio::test] +async fn enqueue_publish_uses_explicit_idempotency_key_across_equivalent_target_order() { let (_tempdir, sdk) = directory_sdk().await; let first = ListingEnqueuePublishRequest::new( actor(), @@ -768,7 +876,9 @@ async fn enqueue_publish_derives_order_independent_idempotency_key() { TargetPolicy::default_profile(), ) .try_with_nostr_targets([RELAY_B, RELAY], NostrRelayUrlPolicy::Public) - .expect("first transport targets"); + .expect("first transport targets") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000023b") + .expect("first idempotency key"); let second = ListingEnqueuePublishRequest::new( actor(), listing(LISTING_F_D_TAG, "Coffee"), @@ -776,7 +886,9 @@ async fn enqueue_publish_derives_order_independent_idempotency_key() { TargetSet::nostr_relays([RELAY, RELAY_B], NostrRelayUrlPolicy::Public) .expect("second transport targets"), ), - ); + ) + .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000023b") + .expect("second idempotency key"); let first_receipt = sdk .listings() @@ -800,7 +912,7 @@ async fn enqueue_publish_derives_order_independent_idempotency_key() { assert_eq!(second_receipt.state, SdkMutationState::AlreadyQueued); let paths = sdk.storage_paths().expect("paths"); - let outbox = RadrootsOutbox::open_file(&paths.outbox_path) + let outbox = RadrootsOutbox::open_file(&paths.runtime_path) .await .expect("outbox"); let relay_urls = outbox @@ -823,7 +935,9 @@ async fn listing_hybrid_profile_publishes_after_nostr_success_and_retains_reticu actor(), listing(LISTING_G_D_TAG, "Hybrid Coffee"), TargetPolicy::default_profile(), - ), + ) + .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000023c") + .expect("idempotency"), &FixtureSigner::new(SELLER), ) .await @@ -856,7 +970,7 @@ async fn listing_hybrid_profile_publishes_after_nostr_success_and_retains_reticu ); assert_eq!(adapter.captured_raw_events().len(), 1); - let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path) + let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path) .await .expect("outbox"); let stored = outbox diff --git a/crates/sdk/tests/market_runtime.rs b/crates/sdk/tests/market_runtime.rs @@ -153,7 +153,9 @@ async fn market_search_refreshes_local_projection_and_reads_fts() { TargetPolicy::default_profile(), ) .try_with_nostr_targets([RELAY], NostrRelayUrlPolicy::Public) - .expect("target relays"); + .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)) diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs @@ -10,27 +10,25 @@ use std::time::{Duration, Instant}; use futures::future::BoxFuture; #[cfg(feature = "transport-nostr-runtime")] use nostr::JsonUtil; +#[cfg(feature = "signer-adapters")] use radroots_authority::RadrootsActorContext; use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, }; +#[cfg(feature = "signer-adapters")] +use radroots_event::contract::RadrootsActorRole; #[cfg(feature = "transport-nostr-runtime")] use radroots_event::ids::RadrootsPublicKey; use radroots_event::wire::RadrootsNip01EventWireParts; use radroots_event::{ RadrootsEventEnvelope, RadrootsEventPtr, - contract::RadrootsActorRole, draft::RadrootsSignedEvent, - ids::{RadrootsEventId, RadrootsListingAddress, RadrootsOrderId, RadrootsOrderRevisionId}, - kinds::{ - KIND_LISTING, KIND_ORDER_DECISION, KIND_ORDER_REQUEST, KIND_POST, - KIND_TRADE_TRANSITION_PROOF_RESULT, KIND_TRADE_VALIDATION_RECEIPT, - }, + 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, - RadrootsOrderRevisionOutcome, }, }; use radroots_event_store::{RadrootsEventIngest, RadrootsEventStore}; @@ -38,29 +36,37 @@ use radroots_nostr::prelude::{ RadrootsNostrKeys, RadrootsNostrSecretKey, RadrootsNostrTimestamp, radroots_event_from_nostr, radroots_nostr_build_event, }; +#[cfg(feature = "signer-adapters")] use radroots_outbox::RadrootsOutbox; -#[cfg(feature = "transport-nostr-runtime")] +#[cfg(any(feature = "signer-adapters", feature = "transport-nostr-runtime"))] +use radroots_sdk::{NostrProfile, NostrRelayUrlPolicy, TransportProfile}; +#[cfg(feature = "signer-adapters")] use radroots_sdk::{ - DvmValidationReceiptIngestRequest, TradeEvidenceQueryBranchKind, - TradeResyncNostrRelayOutcomeKind, TradeResyncNostrRelayTransportOutcomeKind, - TradeResyncRequest, TradeSellerInboxRequest, TradeValidationReceiptInspectRequest, - TradeValidationReceiptListRequest, TradeValidationReceiptVerifyRequest, + PrivacyPreflightConfirmation, PrivacyPreflightStatus, ProductSensitivityField, PublishMode, + RadrootsSdkRecoveryAction, SatisfactionPolicy, SdkMutationState, TargetPolicy, TargetSet, }; use radroots_sdk::{ - NostrProfile, NostrRelayUrlPolicy, PublishMode, RadrootsClient, RadrootsSdkError, - RadrootsSdkRecoveryAction, RadrootsSdkTimestamp, RadrootsTradeValidationTrustPolicy, - RadrootsTradeValidationTrustState, SatisfactionPolicy, SdkMutationState, SdkTradeStatusIssue, - SdkTradeStatusIssueKind, SdkTradeStatusSource, TRADE_STATUS_DEFAULT_LIMIT, - TRADE_STATUS_MAX_LIMIT, TRADE_STATUS_WATCH_MAX_CAPACITY, TRADE_SUBMIT_OPERATION_KIND, - TargetPolicy, TargetSet, TradeAcceptRequest, TradeCancelRequest, TradeDeclineRequest, - TradeEvidenceIngestRequest, TradeEvidenceMode, TradeMutationOutcome, TradeProposeRequest, - TradeRequestEvidenceIngestRequest, TradeRevisionDecisionRequest, TradeRevisionProposalRequest, + 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, TransportProfile, + TradeStatusWatchRequest, }; -use radroots_sdk::{PrivacyPreflightConfirmation, PrivacyPreflightStatus, ProductSensitivityField}; #[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; @@ -95,9 +101,6 @@ fn signed_event_from_envelope(event: RadrootsEventEnvelope) -> RadrootsSignedEve let raw_json = serde_json::to_string(&wire).expect("raw event json"); RadrootsSignedEvent::from_wire_verified_id(wire, raw_json).expect("signed event") } -#[cfg(any())] -const OTHER_PUBLIC_KEY_HEX: &str = - "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; #[cfg(feature = "transport-nostr-runtime")] const RELAY_B: &str = "wss://relay-b.radroots.test"; const PERF_TOTAL_LOCAL_EVENTS: i64 = 100_000; @@ -413,45 +416,6 @@ where .expect_err("struct end failure"); } -#[cfg(any())] -#[derive(Clone)] -struct FixtureSigner { - identity: radroots_authority::RadrootsSignerIdentity, - keys: RadrootsNostrKeys, -} - -#[cfg(any())] -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: radroots_authority::RadrootsSignerIdentity::new(pubkey).expect("identity"), - keys, - } - } -} - -#[cfg(any())] -impl radroots_authority::RadrootsEventSigner for FixtureSigner { - fn pubkey(&self) -> &radroots_event::ids::RadrootsPublicKey { - self.identity.pubkey() - } - - fn sign_frozen_draft( - &self, - draft: &radroots_event::draft::RadrootsEventDraft, - ) -> Result<radroots_event::draft::RadrootsSignedEvent, radroots_authority::RadrootsSignerError> - { - radroots_nostr::prelude::radroots_nostr_sign_frozen_draft(&self.keys, draft).map_err( - |error| radroots_authority::RadrootsSignerError::SigningFailed { - message: error.to_string(), - }, - ) - } -} - async fn directory_sdk_and_store() -> (tempfile::TempDir, RadrootsClient, RadrootsEventStore) { let tempdir = tempfile::tempdir().expect("tempdir"); let sdk = RadrootsClient::builder() @@ -460,10 +424,9 @@ async fn directory_sdk_and_store() -> (tempfile::TempDir, RadrootsClient, Radroo .build() .await .expect("sdk"); - let store = - RadrootsEventStore::open_file(&sdk.storage_paths().expect("paths").event_store_path) - .await - .expect("event store"); + let store = RadrootsEventStore::open_file(&sdk.storage_paths().expect("paths").runtime_path) + .await + .expect("event store"); (tempdir, sdk, store) } @@ -482,10 +445,9 @@ async fn directory_sdk_and_store_with_relays( )); } let sdk = builder.build().await.expect("sdk"); - let store = - RadrootsEventStore::open_file(&sdk.storage_paths().expect("paths").event_store_path) - .await - .expect("event store"); + let store = RadrootsEventStore::open_file(&sdk.storage_paths().expect("paths").runtime_path) + .await + .expect("event store"); (tempdir, sdk, store) } @@ -525,34 +487,16 @@ 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") } -#[cfg(any())] -fn other_buyer_actor() -> RadrootsActorContext { - RadrootsActorContext::test(OTHER_PUBLIC_KEY_HEX, [RadrootsActorRole::Buyer]).expect("actor") -} - -#[cfg(any())] -fn other_seller_actor() -> RadrootsActorContext { - RadrootsActorContext::test(OTHER_PUBLIC_KEY_HEX, [RadrootsActorRole::Seller]).expect("actor") -} - -#[cfg(any())] -fn non_buyer_actor() -> RadrootsActorContext { - RadrootsActorContext::test(BUYER_PUBLIC_KEY_HEX, [RadrootsActorRole::Farmer]).expect("actor") -} - -#[cfg(any())] -fn non_seller_actor() -> RadrootsActorContext { - RadrootsActorContext::test(SELLER_PUBLIC_KEY_HEX, [RadrootsActorRole::Buyer]).expect("actor") -} - fn listing_address() -> RadrootsListingAddress { RadrootsListingAddress::parse(format!( "{KIND_LISTING}:{SELLER_PUBLIC_KEY_HEX}:AAAAAAAAAAAAAAAAAAAAAg" @@ -567,16 +511,19 @@ fn listing_event_ptr() -> RadrootsEventPtr { } } +#[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, @@ -649,6 +596,7 @@ fn order_request(raw_order_id: &str) -> RadrootsOrderRequest { } } +#[cfg(feature = "signer-adapters")] fn trade_propose_request( raw_order_id: &str, publish_mode: PublishMode, @@ -658,314 +606,13 @@ fn trade_propose_request( TradeProposeRequest::new( buyer_actor(), listing_event_ptr(), - order.order_id, - order.listing_addr, - order.seller_pubkey, - order.items, - order.economics, + order, explicit_trade_relays(), publish_mode, satisfaction_policy, ) } -#[cfg(any())] -fn invalid_listing_event_ptr() -> RadrootsEventPtr { - RadrootsEventPtr { - id: String::new(), - relays: Some(RELAY.to_owned()), - } -} - -#[cfg(any())] -#[tokio::test] -async fn order_submit_prepare_is_side_effect_free() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let listing_event = listing_event_ptr(); - let request = TradeSubmitPrepareRequest::new( - buyer_actor(), - listing_event.clone(), - order_request("order-submit-prepare"), - ); - - let prepared = sdk.trades().prepare_submit(request).expect("prepared"); - - assert_eq!(prepared.order_id.as_str(), "order-submit-prepare"); - assert_eq!(prepared.listing_addr, listing_address()); - assert_eq!( - prepared.listing_event_id.as_str(), - listing_event.id.as_str() - ); - assert_eq!(prepared.frozen_draft.kind_u32(), KIND_ORDER_REQUEST); - assert_eq!(prepared.created_at.unix_seconds(), 1_700_000_000); - assert_eq!( - prepared.expected_event_id, - prepared.frozen_draft.expected_event_id_str() - ); - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 0 - ); - assert!( - store - .get_event(prepared.expected_event_id.as_str()) - .await - .expect("event lookup") - .is_none() - ); - - let paths = sdk.storage_paths().expect("paths"); - let outbox = RadrootsOutbox::open_file(&paths.outbox_path) - .await - .expect("outbox"); - assert!( - outbox - .claim_next_ready_event("worker", "claim", 2_000, 1_700_000_000_000) - .await - .expect("claim") - .is_none() - ); -} - -#[cfg(any())] -#[tokio::test] -async fn order_submit_prepare_rejects_missing_listing_evidence() { - let (_tempdir, sdk, _store) = directory_sdk_and_store().await; - let request = TradeSubmitPrepareRequest::new( - buyer_actor(), - invalid_listing_event_ptr(), - order_request("order-submit-missing-listing"), - ); - - let error = sdk - .trades() - .prepare_submit(request) - .expect_err("missing listing evidence"); - - assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. })); -} - -#[cfg(any())] -#[tokio::test] -async fn order_submit_prepare_rejects_invalid_actor_or_payload() { - let (_tempdir, sdk, _store) = directory_sdk_and_store().await; - - let non_buyer = sdk - .trades() - .prepare_submit(TradeSubmitPrepareRequest::new( - non_buyer_actor(), - listing_event_ptr(), - order_request("order-submit-non-buyer"), - )) - .expect_err("non buyer"); - assert!(matches!( - non_buyer, - RadrootsSdkError::UnauthorizedActor { .. } - )); - - let wrong_actor = sdk - .trades() - .prepare_submit(TradeSubmitPrepareRequest::new( - other_buyer_actor(), - listing_event_ptr(), - order_request("order-submit-wrong-actor"), - )) - .expect_err("wrong actor"); - assert!(matches!( - wrong_actor, - RadrootsSdkError::UnauthorizedActor { .. } - )); - - let mut seller_mismatch = order_request("order-submit-seller-mismatch"); - seller_mismatch.seller_pubkey = OTHER_PUBLIC_KEY_HEX.parse().expect("seller pubkey"); - let seller_error = sdk - .trades() - .prepare_submit(TradeSubmitPrepareRequest::new( - buyer_actor(), - listing_event_ptr(), - seller_mismatch, - )) - .expect_err("seller mismatch"); - assert!(matches!( - seller_error, - RadrootsSdkError::InvalidRequest { .. } - )); - - let mut empty_items = order_request("order-submit-empty-items"); - empty_items.items.clear(); - let empty_items_error = sdk - .trades() - .prepare_submit(TradeSubmitPrepareRequest::new( - buyer_actor(), - listing_event_ptr(), - empty_items, - )) - .expect_err("empty items"); - assert!(matches!( - empty_items_error, - RadrootsSdkError::InvalidRequest { .. } - )); - - let mut empty_economics = order_request("order-submit-empty-economics"); - empty_economics.economics.items.clear(); - let empty_economics_error = sdk - .trades() - .prepare_submit(TradeSubmitPrepareRequest::new( - buyer_actor(), - listing_event_ptr(), - empty_economics, - )) - .expect_err("empty economics"); - assert!(matches!( - empty_economics_error, - RadrootsSdkError::InvalidRequest { .. } - )); -} - -#[cfg(any())] -#[tokio::test] -async fn order_submit_enqueue_stores_event_queues_outbox_and_status_sees_request() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let order = order_request("order-submit-enqueue"); - let prepared = sdk - .trades() - .prepare_submit(TradeSubmitPrepareRequest::new( - buyer_actor(), - listing_event_ptr(), - order.clone(), - )) - .expect("prepared"); - assert_eq!(prepared.workflow.kind, TradeWorkflowKind::Submit); - assert_eq!( - prepared.workflow.operation_kind, - TRADE_SUBMIT_OPERATION_KIND - ); - assert_eq!(prepared.workflow.contract_id, "radroots.order.request.v1"); - assert_eq!( - prepared.workflow.expected_event_id, - prepared.expected_event_id - ); - assert_eq!(prepared.workflow.created_at, prepared.created_at); - let request = TradeSubmitEnqueueRequest::new( - buyer_actor(), - listing_event_ptr(), - order, - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY], NostrRelayUrlPolicy::Public) - .expect("transport targets") - .try_with_idempotency_key("order-submit-enqueue-idempotency") - .expect("idempotency key"); - - let receipt = sdk - .trades() - .enqueue_submit_with_explicit_signer(request, &FixtureSigner::new(BUYER_SECRET_KEY_HEX)) - .await - .expect("enqueue"); - - assert_eq!(receipt.order_id, prepared.order_id); - assert_eq!(receipt.listing_addr, prepared.listing_addr); - assert_eq!(receipt.listing_event_id, prepared.listing_event_id); - assert_eq!(receipt.workflow.kind, TradeWorkflowKind::Submit); - assert_eq!(receipt.workflow.operation_kind, TRADE_SUBMIT_OPERATION_KIND); - assert_eq!( - receipt.workflow.expected_event_id, - prepared.expected_event_id - ); - assert_eq!(receipt.workflow.signed_event_id, receipt.signed_event_id); - assert_eq!(receipt.workflow.local_event_seq, receipt.local_event_seq); - assert_eq!( - receipt.workflow.outbox_operation_id, - receipt.outbox_operation_id - ); - assert_eq!(receipt.workflow.outbox_event_id, receipt.outbox_event_id); - assert_eq!(receipt.workflow.state, receipt.state); - assert_eq!( - receipt.workflow.idempotency_digest_prefix, - receipt.idempotency_digest_prefix - ); - assert_eq!( - receipt.workflow.idempotency.digest_prefix, - receipt.idempotency_digest_prefix - ); - assert!(!receipt.workflow.idempotency.replayed_existing_operation); - assert!( - receipt - .workflow - .idempotency - .safe_to_retry_with_same_idempotency_key - ); - assert!(!receipt.workflow.retry.retryable_after_error); - assert!( - receipt - .workflow - .retry - .safe_to_retry_enqueue_with_same_idempotency_key - ); - assert!(receipt.workflow.retry.recovery_actions.is_empty()); - assert_eq!(receipt.expected_event_id, prepared.expected_event_id); - assert_eq!(receipt.signed_event_id, receipt.expected_event_id); - assert_eq!(receipt.local_event_seq, 1); - assert_eq!(receipt.outbox_operation_id, 1); - assert_eq!(receipt.outbox_event_id, 1); - assert_eq!(receipt.state, SdkMutationState::StoredAndQueued); - assert!(receipt.idempotency_digest_prefix.is_some()); - - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 1 - ); - let stored_event = store - .get_event(receipt.signed_event_id.as_str()) - .await - .expect("event lookup") - .expect("stored event"); - assert_eq!(stored_event.kind, KIND_ORDER_REQUEST); - assert_eq!( - stored_event.contract_id.as_deref(), - Some("radroots.order.request.v1") - ); - - let paths = sdk.storage_paths().expect("paths"); - let outbox = RadrootsOutbox::open_file(&paths.outbox_path) - .await - .expect("outbox"); - let outbox_event = outbox - .get_event(receipt.outbox_event_id) - .await - .expect("outbox event") - .expect("outbox event"); - assert_eq!(outbox_event.state, RadrootsOutboxEventState::Signed); - assert_eq!(outbox_event.draft.kind_u32(), KIND_ORDER_REQUEST); - assert!(outbox_event.signed_event.is_some()); - - let status = sdk - .trades() - .status(status_request("order-submit-enqueue")) - .await - .expect("status"); - assert!(status.found); - assert_eq!(status.status, TradeStatusKind::Requested); - assert_eq!(status.event_count, 1); - assert_eq!( - status - .request_event_id - .as_ref() - .map(RadrootsEventId::as_str), - Some(receipt.signed_event_id.as_str()) - ); -} - #[cfg(all( feature = "signer-adapters", feature = "local-signer", @@ -991,7 +638,7 @@ async fn trade_product_clients_propose_inbox_accept_status_and_resync() { PublishMode::EnqueueOnly, SatisfactionPolicy::NoWait, ) - .try_with_idempotency_key("trade-product-facade-propose") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000204") .expect("propose idempotency"), ) .await @@ -1063,7 +710,7 @@ async fn trade_product_clients_propose_inbox_accept_status_and_resync() { SatisfactionPolicy::NoWait, TradeEvidenceMode::LocalOnly, ) - .try_with_idempotency_key("trade-product-facade-accept") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000205") .expect("accept idempotency"), ) .await @@ -1081,7 +728,7 @@ async fn trade_product_clients_propose_inbox_accept_status_and_resync() { .status(TradeStatusRequest::new(propose_receipt.locator.clone())) .await .expect("facade status"); - assert_eq!(status.status, TradeStatusKind::AgreedPendingRhi); + assert_eq!(status.status, TradeStatusKind::AgreedPendingValidation); assert_eq!( status .decision_event_id @@ -1102,7 +749,10 @@ async fn trade_product_clients_propose_inbox_accept_status_and_resync() { .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::AgreedPendingRhi); + assert_eq!( + resync.status.status, + TradeStatusKind::AgreedPendingValidation + ); assert_eq!( resync.status.last_event_id, Some(accept_receipt.signed_event_id) @@ -1132,11 +782,11 @@ async fn trade_product_clients_resync_committed_after_rhi_validation_receipt() { ) .await; let buyer_store = - RadrootsEventStore::open_file(&buyer_sdk.storage_paths().expect("paths").event_store_path) + 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").event_store_path) + RadrootsEventStore::open_file(&seller_sdk.storage_paths().expect("paths").runtime_path) .await .expect("seller event store"); let propose_receipt = expect_enqueued( @@ -1149,7 +799,7 @@ async fn trade_product_clients_resync_committed_after_rhi_validation_receipt() { PublishMode::EnqueueOnly, SatisfactionPolicy::NoWait, ) - .try_with_idempotency_key("trade-product-committed-resync-propose") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000206") .expect("propose idempotency"), ) .await @@ -1199,7 +849,7 @@ async fn trade_product_clients_resync_committed_after_rhi_validation_receipt() { SatisfactionPolicy::NoWait, TradeEvidenceMode::LocalOnly, ) - .try_with_idempotency_key("trade-product-committed-resync-accept") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000207") .expect("accept idempotency"), ) .await @@ -1216,35 +866,14 @@ async fn trade_product_clients_resync_committed_after_rhi_validation_receipt() { 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); - let worker_raw_event = signed_raw_worker_result_event( - "trade-product-committed-resync", - &receipt_event_id, - &propose_receipt.listing_event_id, - &propose_receipt.signed_event_id, - &accept_receipt.signed_event_id, - 34, - ); - let ingest = seller_sdk - .dvm() - .ingest_validation_receipt( - DvmValidationReceiptIngestRequest::new(signed_event_from_envelope(receipt_event)) - .with_expected_order_id(propose_receipt.order_id.clone()) - .with_expected_listing_event_id(propose_receipt.listing_event_id.clone()) - .with_expected_root_event_id(propose_receipt.signed_event_id.clone()) - .with_expected_target_event_id(accept_receipt.signed_event_id.clone()), - ) - .await - .expect("ingest validation receipt"); - assert!(ingest.inserted); - assert_eq!(ingest.receipt_event_id, receipt_event_id); seller_store .ingest_event(RadrootsEventIngest::new( - signed_event_from_envelope(radroots_event_from_nostr(&worker_raw_event)), - 4_050, + signed_event_from_envelope(receipt_event), + 4_040, )) .await - .expect("ingest validation result"); + .expect("ingest validation receipt"); let seller_resync = seller_sdk .trades() @@ -1257,7 +886,7 @@ async fn trade_product_clients_resync_committed_after_rhi_validation_receipt() { .expect("seller resync"); assert_eq!( seller_resync.status.status, - TradeStatusKind::AgreedPendingRhi + TradeStatusKind::AgreedPendingValidation ); assert_eq!( seller_resync.status.rhi_receipt_event_id, @@ -1278,6 +907,7 @@ async fn trade_product_clients_resync_committed_after_rhi_validation_receipt() { let trusted_local_policy = RadrootsTradeValidationTrustPolicy::production() .with_trusted_rhi_pubkeys(vec![service_pubkey.parse().expect("service pubkey")]) + .with_allow_deterministic_none(true) .with_require_cryptographic_proof(false); let seller_trusted_local = seller_sdk .trades() @@ -1299,7 +929,7 @@ async fn trade_product_clients_resync_committed_after_rhi_validation_receipt() { assert!(!seller_trust.production_committed); assert_eq!( seller_trust - .result_author + .receipt_author .as_ref() .map(|pubkey| pubkey.as_str()), Some(service_pubkey.as_str()) @@ -1309,7 +939,6 @@ async fn trade_product_clients_resync_committed_after_rhi_validation_receipt() { 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_raw_event_item(&worker_raw_event, RELAY, 4_201), relay_eose(RELAY), ]); let buyer_resync = buyer_sdk @@ -1323,13 +952,13 @@ async fn trade_product_clients_resync_committed_after_rhi_validation_receipt() { .expect("buyer resync"); assert_eq!( buyer_resync.status.status, - TradeStatusKind::AgreedPendingRhi + TradeStatusKind::AgreedPendingValidation ); assert_eq!( buyer_resync.status.rhi_receipt_event_id, Some(receipt_event_id) ); - assert_eq!(buyer_resync.evidence.inserted_count, 3); + assert_eq!(buyer_resync.evidence.inserted_count, 2); } #[tokio::test] @@ -1352,19 +981,10 @@ async fn trade_status_trust_policy_requires_trusted_cryptographic_receipt_for_co 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"); - let worker_raw_event = signed_raw_sp1_worker_result_event( - order_id, - &receipt_event_id, - &listing_event_id, - &request_event_id, - &decision_event_id, - 73, - ); for (event, observed_at_ms) in [ (request_event, 7_000), (decision_event, 7_100), (receipt_event, 7_200), - (radroots_event_from_nostr(&worker_raw_event), 7_300), ] { store .ingest_event(RadrootsEventIngest::new( @@ -1380,7 +1000,10 @@ async fn trade_status_trust_policy_requires_trusted_cryptographic_receipt_for_co .status(status_request(order_id)) .await .expect("default status"); - assert_eq!(default_status.status, TradeStatusKind::AgreedPendingRhi); + assert_eq!( + default_status.status, + TradeStatusKind::AgreedPendingValidation + ); assert_eq!( default_status .validation_trust @@ -1399,6 +1022,10 @@ async fn trade_status_trust_policy_requires_trusted_cryptographic_receipt_for_co .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, @@ -1414,7 +1041,7 @@ async fn trade_status_trust_policy_requires_trusted_cryptographic_receipt_for_co assert!(trust.cryptographic_proof_verified); assert_eq!(trust.proof_system.as_deref(), Some("sp1_core")); assert_eq!( - trust.result_author.as_ref().map(|pubkey| pubkey.as_str()), + trust.receipt_author.as_ref().map(|pubkey| pubkey.as_str()), Some(service_pubkey.as_str()) ); } @@ -1442,11 +1069,11 @@ async fn trade_product_accept_resync_before_mutation_imports_relay_visible_reque ) .await; let buyer_store = - RadrootsEventStore::open_file(&buyer_sdk.storage_paths().expect("paths").event_store_path) + 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").event_store_path) + RadrootsEventStore::open_file(&seller_sdk.storage_paths().expect("paths").runtime_path) .await .expect("seller event store"); let propose_receipt = expect_enqueued( @@ -1455,11 +1082,11 @@ async fn trade_product_accept_resync_before_mutation_imports_relay_visible_reque .buyer() .propose_trade( trade_propose_request( - "trade-product-resync-before-accept", + "01890f0e-6c00-7000-8000-000000000209", PublishMode::EnqueueOnly, SatisfactionPolicy::NoWait, ) - .try_with_idempotency_key("trade-product-resync-before-accept-propose") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000208") .expect("propose idempotency"), ) .await @@ -1495,7 +1122,7 @@ async fn trade_product_accept_resync_before_mutation_imports_relay_visible_reque SatisfactionPolicy::NoWait, TradeEvidenceMode::ResyncBeforeMutation, ) - .try_with_idempotency_key("trade-product-resync-before-accept") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000209") .expect("accept idempotency"), &adapter, ) @@ -1511,7 +1138,7 @@ async fn trade_product_accept_resync_before_mutation_imports_relay_visible_reque .status(TradeStatusRequest::new(propose_receipt.locator)) .await .expect("seller status after accept"); - assert_eq!(status.status, TradeStatusKind::AgreedPendingRhi); + assert_eq!(status.status, TradeStatusKind::AgreedPendingValidation); assert_eq!( status.decision_event_id, Some(accept_receipt.signed_event_id) @@ -1532,11 +1159,10 @@ async fn trade_product_accept_resync_before_mutation_imports_relay_visible_reque feature = "transport-nostr-runtime" ))] #[tokio::test] -async fn trade_product_revision_status_resync_imports_pending_revision_proposal() { +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 decision_storage_root = tempdir.path().join("decision-sdk"); let buyer_sdk = directory_sdk_with_signer_and_relays( buyer_storage_root.as_path(), BUYER_SECRET_KEY_HEX, @@ -1549,190 +1175,44 @@ async fn trade_product_revision_status_resync_imports_pending_revision_proposal( &[RELAY], ) .await; - let decision_sdk = directory_sdk_with_signer_and_relays( - decision_storage_root.as_path(), - BUYER_SECRET_KEY_HEX, - &[RELAY], - ) - .await; - let buyer_store = - RadrootsEventStore::open_file(&buyer_sdk.storage_paths().expect("paths").event_store_path) - .await - .expect("buyer event store"); let seller_store = - RadrootsEventStore::open_file(&seller_sdk.storage_paths().expect("paths").event_store_path) + RadrootsEventStore::open_file(&seller_sdk.storage_paths().expect("paths").runtime_path) .await .expect("seller event store"); - let decision_store = RadrootsEventStore::open_file( - &decision_sdk - .storage_paths() - .expect("paths") - .event_store_path, - ) - .await - .expect("decision event store"); let propose_receipt = expect_enqueued( buyer_sdk .trades() .buyer() .propose_trade( trade_propose_request( - "trade-product-resync-before-revision-decision", + "trade-product-local-only-no-fetch", PublishMode::EnqueueOnly, SatisfactionPolicy::NoWait, ) - .try_with_idempotency_key("trade-product-resync-before-revision-decision-propose") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000020a") .expect("propose idempotency"), ) .await .expect("propose trade"), ); - seller_sdk + let adapter = RadrootsMockRelayFetchAdapter::new(vec![relay_closed(RELAY, "must not fetch")]); + let error = seller_sdk .trades() - .resync() - .resync_with_fetch_adapter( - TradeResyncRequest::new(propose_receipt.locator.clone()), - &RadrootsMockRelayFetchAdapter::new(vec![ - relay_event_item_from_store( - &buyer_store, - &propose_receipt.signed_event_id, - RELAY, - 4_400, - ) - .await, - relay_eose(RELAY), - ]), - ) - .await - .expect("seller request resync"); - let revision_id: RadrootsOrderRevisionId = "revision-product-resync-before-decision" - .parse() - .expect("revision id"); - let proposal_receipt = expect_enqueued( - seller_sdk - .trades() - .seller() - .propose_revision( - TradeRevisionProposalRequest::new( - seller_actor(), - propose_receipt.locator.clone(), - revision_id, - vec![RadrootsOrderItem { - bin_id: "bin-1".parse().expect("bin id"), - bin_count: 3, - }], - revision_economics(), - "increase quantity", - explicit_trade_relays(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - TradeEvidenceMode::LocalOnly, - ) - .with_privacy_confirmation(public_note_confirmation()) - .try_with_idempotency_key("trade-product-resync-before-revision-proposal") - .expect("revision proposal idempotency"), - ) - .await - .expect("propose revision"), - ); - let adapter = RadrootsMockRelayFetchAdapter::new(vec![ - relay_event_item_from_store(&buyer_store, &propose_receipt.signed_event_id, RELAY, 4_500) - .await, - relay_event_item_from_store( - &seller_store, - &proposal_receipt.signed_event_id, - RELAY, - 4_501, - ) - .await, - relay_eose(RELAY), - ]); - let status = decision_sdk - .trades() - .status_with_fetch_adapter( - TradeStatusRequest::new(propose_receipt.locator.clone()) - .with_source(SdkTradeStatusSource::ResyncThenLocal), - &adapter, - ) - .await - .expect("decision resync status"); - - assert_eq!(status.status, TradeStatusKind::RevisionProposed); - assert_eq!( - status.pending_revision_event_id, - Some(proposal_receipt.signed_event_id) - ); - assert!(status.eligibility.can_decide_revision); - assert_eq!( - decision_store - .status_summary() - .await - .expect("decision imported request and proposal") - .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").event_store_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("trade-product-local-only-no-fetch-propose") - .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, + .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"); @@ -1761,11 +1241,11 @@ async fn trade_product_accept_require_explicit_evidence_ingests_supplied_request 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").event_store_path) + 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").event_store_path) + RadrootsEventStore::open_file(&seller_sdk.storage_paths().expect("paths").runtime_path) .await .expect("seller event store"); let propose_receipt = expect_enqueued( @@ -1774,11 +1254,11 @@ async fn trade_product_accept_require_explicit_evidence_ingests_supplied_request .buyer() .propose_trade( trade_propose_request( - "trade-product-explicit-accept", + "01890f0e-6c00-7000-8000-00000000020c", PublishMode::EnqueueOnly, SatisfactionPolicy::NoWait, ) - .try_with_idempotency_key("trade-product-explicit-accept-propose") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000020b") .expect("propose idempotency"), ) .await @@ -1804,7 +1284,7 @@ async fn trade_product_accept_require_explicit_evidence_ingests_supplied_request TradeEvidenceIngestRequest::new(signed_event_from_envelope(request_event)), ]), ) - .try_with_idempotency_key("trade-product-explicit-accept") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000020c") .expect("accept idempotency"), ) .await @@ -1819,7 +1299,7 @@ async fn trade_product_accept_require_explicit_evidence_ingests_supplied_request .status(TradeStatusRequest::new(propose_receipt.locator)) .await .expect("seller explicit status after accept"); - assert_eq!(status.status, TradeStatusKind::AgreedPendingRhi); + assert_eq!(status.status, TradeStatusKind::AgreedPendingValidation); assert_eq!( seller_store .status_summary() @@ -1841,7 +1321,7 @@ async fn trade_product_accept_require_explicit_evidence_rejects_empty_evidence() 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").event_store_path) + RadrootsEventStore::open_file(&seller_sdk.storage_paths().expect("paths").runtime_path) .await .expect("seller event store"); let propose_receipt = expect_enqueued( @@ -1854,7 +1334,7 @@ async fn trade_product_accept_require_explicit_evidence_rejects_empty_evidence() PublishMode::EnqueueOnly, SatisfactionPolicy::NoWait, ) - .try_with_idempotency_key("trade-product-empty-explicit-accept-propose") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000020d") .expect("propose idempotency"), ) .await @@ -1893,7 +1373,7 @@ async fn trade_product_accept_require_explicit_evidence_rejects_empty_evidence() #[cfg(feature = "transport-nostr-runtime")] #[tokio::test] -async fn trade_validation_receipts_fetch_from_relays_and_select_worker_evidence() { +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"); @@ -1908,30 +1388,8 @@ async fn trade_validation_receipts_fetch_from_relays_and_select_worker_evidence( ); let receipt_event_id = RadrootsEventId::parse(receipt_raw_event.id.to_hex()).expect("receipt event id"); - let worker_raw_event = signed_raw_worker_result_event( - order_id, - &receipt_event_id, - &listing_event_id, - &root_event_id, - &target_event_id, - 34, - ); - let worker_event_id = worker_raw_event.id.to_hex(); - let wrong_root_event_id = deterministic_event_id("validation-receipt-worker-wrong-root"); - let wrong_worker_raw_event = signed_raw_worker_result_event( - order_id, - &receipt_event_id, - &listing_event_id, - &wrong_root_event_id, - &target_event_id, - 35, - ); - let wrong_worker_event_id = wrong_worker_raw_event.id.to_hex(); - let service_pubkey = public_key_hex_for_secret(SERVICE_SECRET_KEY_HEX); let adapter = RadrootsMockRelayFetchAdapter::new(vec![ relay_raw_event_item(&receipt_raw_event, RELAY, 4_000), - relay_raw_event_item(&worker_raw_event, RELAY, 4_001), - relay_raw_event_item(&wrong_worker_raw_event, RELAY, 4_002), relay_eose(RELAY), ]); @@ -1939,10 +1397,7 @@ async fn trade_validation_receipts_fetch_from_relays_and_select_worker_evidence( .trades() .validation_receipts() .list_with_fetch_adapter( - TradeValidationReceiptListRequest::parse(order_id) - .expect("list request") - .try_with_trusted_worker_pubkeys([service_pubkey.as_str()]) - .expect("trusted worker"), + TradeValidationReceiptListRequest::parse(order_id).expect("list request"), &adapter, ) .await @@ -1951,41 +1406,18 @@ async fn trade_validation_receipts_fetch_from_relays_and_select_worker_evidence( 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, 2); + assert_eq!(list.nostr_evidence.out_of_filter_count, 0); assert_eq!(list.receipts[0].event.id_str(), receipt_event_id.as_str()); - let trusted = list.receipts[0] - .worker_evidence - .trusted - .as_ref() - .expect("trusted evidence"); - assert_eq!(trusted.author.as_str(), service_pubkey); - assert_eq!( - trusted - .validation_authority - .map(|authority| authority.as_str()), - Some("trusted_rhi_service_key") - ); assert_eq!( - trusted - .commitment_confidence - .map(|confidence| confidence.as_str()), - Some("committed_by_trusted_service") - ); - assert!( - store - .get_event(wrong_worker_event_id.as_str()) - .await - .expect("wrong worker event lookup") - .is_none() + 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()) - .try_with_trusted_worker_pubkeys([service_pubkey.as_str()]) - .expect("trusted worker"), + TradeValidationReceiptInspectRequest::new(receipt_event_id.clone()), &adapter, ) .await @@ -1997,110 +1429,32 @@ async fn trade_validation_receipts_fetch_from_relays_and_select_worker_evidence( inspect .receipt .as_ref() - .and_then(|receipt| receipt.worker_evidence.trusted.as_ref()) - .map(|evidence| evidence.result_event_id.as_str()), - Some(worker_event_id.as_str()) - ); -} - -#[cfg(feature = "transport-nostr-runtime")] -#[tokio::test] -async fn trade_validation_receipts_classify_worker_evidence_untrusted_without_trust_config() { - let (_tempdir, sdk, _store) = directory_sdk_and_store_with_relays(&[RELAY]).await; - let order_id = "trade-validation-receipts-untrusted-default"; - let listing_event_id = deterministic_event_id("validation-receipt-untrusted-listing"); - let root_event_id = deterministic_event_id("validation-receipt-untrusted-request"); - let target_event_id = deterministic_event_id("validation-receipt-untrusted-decision"); - let receipt_raw_event = signed_raw_validation_receipt_event( - order_id, - &listing_event_id, - &root_event_id, - &target_event_id, - 36, - ); - let receipt_event_id = - RadrootsEventId::parse(receipt_raw_event.id.to_hex()).expect("receipt event id"); - let worker_raw_event = signed_raw_worker_result_event( - order_id, - &receipt_event_id, - &listing_event_id, - &root_event_id, - &target_event_id, - 37, - ); - let worker_event_id = worker_raw_event.id.to_hex(); - let service_pubkey = public_key_hex_for_secret(SERVICE_SECRET_KEY_HEX); - let adapter = RadrootsMockRelayFetchAdapter::new(vec![ - relay_raw_event_item(&receipt_raw_event, RELAY, 4_000), - relay_raw_event_item(&worker_raw_event, RELAY, 4_001), - 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"); - let list_evidence = &list.receipts[0].worker_evidence; - assert!(list_evidence.trusted.is_none()); - let list_untrusted = list_evidence.untrusted.as_ref().expect("list untrusted"); - assert_eq!(list_untrusted.result_event_id.as_str(), worker_event_id); - assert_eq!(list_untrusted.author.as_str(), service_pubkey); - assert_eq!( - list_untrusted - .commitment_confidence - .map(|confidence| confidence.as_str()), - Some("committed_by_trusted_service") - ); - - let inspect = sdk - .trades() - .validation_receipts() - .inspect_with_fetch_adapter( - TradeValidationReceiptInspectRequest::new(receipt_event_id.clone()), - &adapter, - ) - .await - .expect("validation receipt inspect"); - let inspect_evidence = &inspect - .receipt - .as_ref() - .expect("inspect receipt") - .worker_evidence; - assert!(inspect_evidence.trusted.is_none()); - assert_eq!( - inspect_evidence - .untrusted - .as_ref() - .map(|evidence| evidence.result_event_id.as_str()), - Some(worker_event_id.as_str()) + .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), + TradeValidationReceiptVerifyRequest::new(receipt_event_id.clone()), &adapter, ) .await .expect("validation receipt verify"); - let verify_evidence = &verify - .receipt - .as_ref() - .expect("verify receipt") - .worker_evidence; - assert!(verify_evidence.trusted.is_none()); assert_eq!( - verify_evidence - .untrusted + verify + .receipt .as_ref() - .map(|evidence| evidence.result_event_id.as_str()), - Some(worker_event_id.as_str()) + .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() ); } @@ -2294,7 +1648,7 @@ async fn trade_resync_imports_nostr_evidence_into_empty_local_store() { 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(), 5); + assert_eq!(resync.evidence.query_plan.branches.len(), 4); assert!( resync .evidence @@ -2486,8 +1840,8 @@ async fn trade_resync_splits_lifecycle_branch_into_single_kind_filters() { .expect("resync"); let filters = adapter.filters_json(); - assert_eq!(filters.len(), 6); - for kind in [3422, 3423, 3424, 3425, 3432, 3440] { + assert_eq!(filters.len(), 4); + for kind in [3422, 3423, 3432, 3440] { assert!( filters .iter() @@ -2650,6 +2004,7 @@ async fn relay_event_item_from_store( } } +#[cfg(feature = "signer-adapters")] async fn event_from_store( source: &RadrootsEventStore, event_id: &RadrootsEventId, @@ -2708,10 +2063,10 @@ async fn trade_product_propose_idempotency_replays_same_payload_and_conflicts_di 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.event_store_path) + let store = RadrootsEventStore::open_file(&storage_paths.runtime_path) .await .expect("event store"); - let outbox = RadrootsOutbox::open_file(&storage_paths.outbox_path) + let outbox = RadrootsOutbox::open_file(&storage_paths.runtime_path) .await .expect("outbox"); let request = trade_propose_request( @@ -2719,7 +2074,7 @@ async fn trade_product_propose_idempotency_replays_same_payload_and_conflicts_di PublishMode::EnqueueOnly, SatisfactionPolicy::NoWait, ) - .try_with_idempotency_key("trade-product-idempotent-key") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000020e") .expect("idempotency"); let first = expect_enqueued( @@ -2774,7 +2129,7 @@ async fn trade_product_propose_idempotency_replays_same_payload_and_conflicts_di PublishMode::EnqueueOnly, SatisfactionPolicy::NoWait, ) - .try_with_idempotency_key("trade-product-idempotent-key") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000020e") .expect("conflict idempotency"), ) .await @@ -2848,7 +2203,7 @@ async fn trade_product_propose_requires_public_note_privacy_confirmation() { ); let store = - RadrootsEventStore::open_file(&buyer_sdk.storage_paths().expect("paths").event_store_path) + RadrootsEventStore::open_file(&buyer_sdk.storage_paths().expect("paths").runtime_path) .await .expect("event store"); assert_eq!( @@ -2874,13 +2229,13 @@ async fn trade_product_propose_publishes_public_note_after_confirmation() { .buyer() .propose_trade( trade_propose_request( - "trade-product-propose-public-note-confirmed", + "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("trade-product-propose-public-note-confirmed") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000020f") .expect("proposal idempotency"), ) .await @@ -2889,10 +2244,10 @@ async fn trade_product_propose_publishes_public_note_after_confirmation() { assert_eq!( receipt.order_id.as_str(), - "trade-product-propose-public-note-confirmed" + "01890f0e-6c00-7000-8000-00000000020f" ); let store = - RadrootsEventStore::open_file(&buyer_sdk.storage_paths().expect("paths").event_store_path) + RadrootsEventStore::open_file(&buyer_sdk.storage_paths().expect("paths").runtime_path) .await .expect("event store"); assert_eq!( @@ -2939,7 +2294,7 @@ async fn trade_product_propose_blocks_sensitive_fulfillment_note_even_when_confi assert_eq!(*status, PrivacyPreflightStatus::ForbiddenPublicFields); assert!(fields.contains(&ProductSensitivityField::SensitiveFulfillmentDetails)); let store = - RadrootsEventStore::open_file(&buyer_sdk.storage_paths().expect("paths").event_store_path) + RadrootsEventStore::open_file(&buyer_sdk.storage_paths().expect("paths").runtime_path) .await .expect("event store"); assert_eq!( @@ -2968,7 +2323,7 @@ async fn trade_product_decline_requires_public_reason_privacy_confirmation() { PublishMode::EnqueueOnly, SatisfactionPolicy::NoWait, ) - .try_with_idempotency_key("trade-product-privacy-decline-propose") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000210") .expect("propose idempotency"), ) .await @@ -3011,7 +2366,7 @@ async fn trade_product_decline_requires_public_reason_privacy_confirmation() { "public_but_sensitive_notes" ); let store = - RadrootsEventStore::open_file(&seller_sdk.storage_paths().expect("paths").event_store_path) + RadrootsEventStore::open_file(&seller_sdk.storage_paths().expect("paths").runtime_path) .await .expect("event store"); assert_eq!( @@ -3038,7 +2393,7 @@ async fn trade_product_decline_requires_public_reason_privacy_confirmation() { TradeEvidenceMode::LocalOnly, ) .with_privacy_confirmation(public_note_confirmation()) - .try_with_idempotency_key("trade-product-privacy-decline-confirmed") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000211") .expect("decline idempotency"), ) .await @@ -3072,7 +2427,7 @@ async fn trade_product_cancel_blocks_sensitive_fulfillment_reason_before_mutatio PublishMode::EnqueueOnly, SatisfactionPolicy::NoWait, ) - .try_with_idempotency_key("trade-product-privacy-cancel-propose") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000212") .expect("propose idempotency"), ) .await @@ -3109,7 +2464,7 @@ async fn trade_product_cancel_blocks_sensitive_fulfillment_reason_before_mutatio assert_eq!(*status, PrivacyPreflightStatus::ForbiddenPublicFields); assert!(fields.contains(&ProductSensitivityField::SensitiveFulfillmentDetails)); let store = - RadrootsEventStore::open_file(&buyer_sdk.storage_paths().expect("paths").event_store_path) + RadrootsEventStore::open_file(&buyer_sdk.storage_paths().expect("paths").runtime_path) .await .expect("event store"); assert_eq!( @@ -3134,11 +2489,11 @@ async fn trade_product_cancel_enqueues_with_locator_and_updates_status() { .buyer() .propose_trade( trade_propose_request( - "trade-product-cancel", + "01890f0e-6c00-7000-8000-000000000214", PublishMode::EnqueueOnly, SatisfactionPolicy::NoWait, ) - .try_with_idempotency_key("trade-product-cancel-propose") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000213") .expect("propose idempotency"), ) .await @@ -3160,7 +2515,7 @@ async fn trade_product_cancel_enqueues_with_locator_and_updates_status() { TradeEvidenceMode::LocalOnly, ) .with_privacy_confirmation(public_note_confirmation()) - .try_with_idempotency_key("trade-product-cancel") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000214") .expect("cancel idempotency"), ) .await @@ -3186,105 +2541,6 @@ async fn trade_product_cancel_enqueues_with_locator_and_updates_status() { assert_eq!(status.next_action, TradeStatusNextActionKind::Terminal); } -#[cfg(all(feature = "signer-adapters", feature = "local-signer"))] -#[tokio::test] -async fn trade_product_revision_lifecycle_uses_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 seller_sdk = directory_sdk_with_signer(storage_root.as_path(), SELLER_SECRET_KEY_HEX).await; - let propose_receipt = expect_enqueued( - buyer_sdk - .trades() - .buyer() - .propose_trade( - trade_propose_request( - "trade-product-revision", - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_idempotency_key("trade-product-revision-propose") - .expect("propose idempotency"), - ) - .await - .expect("propose trade"), - ); - let revision_id: RadrootsOrderRevisionId = - "revision-product-revision".parse().expect("revision id"); - let proposal = expect_enqueued( - seller_sdk - .trades() - .seller() - .propose_revision( - TradeRevisionProposalRequest::new( - seller_actor(), - propose_receipt.locator.clone(), - revision_id.clone(), - vec![RadrootsOrderItem { - bin_id: "bin-1".parse().expect("bin id"), - bin_count: 3, - }], - revision_economics(), - "increase quantity", - explicit_trade_relays(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - TradeEvidenceMode::LocalOnly, - ) - .with_privacy_confirmation(public_note_confirmation()) - .try_with_idempotency_key("trade-product-revision-proposal") - .expect("revision proposal idempotency"), - ) - .await - .expect("propose revision"), - ); - let pending = buyer_sdk - .trades() - .status(TradeStatusRequest::new(propose_receipt.locator.clone())) - .await - .expect("pending revision status"); - assert_eq!(pending.status, TradeStatusKind::RevisionProposed); - assert_eq!( - pending.pending_revision_event_id, - Some(proposal.signed_event_id.clone()) - ); - assert!(pending.eligibility.can_decide_revision); - - let decision = expect_enqueued( - buyer_sdk - .trades() - .buyer() - .accept_revision( - TradeRevisionDecisionRequest::new( - buyer_actor(), - propose_receipt.locator.clone(), - revision_id, - RadrootsOrderRevisionOutcome::Accepted, - explicit_trade_relays(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - TradeEvidenceMode::LocalOnly, - ) - .try_with_idempotency_key("trade-product-revision-decision") - .expect("revision decision idempotency"), - ) - .await - .expect("accept revision"), - ); - assert_eq!(decision.locator, propose_receipt.locator); - assert_eq!(decision.root_event_id, propose_receipt.signed_event_id); - assert_eq!(decision.previous_event_id, proposal.signed_event_id); - let status = buyer_sdk - .trades() - .status(TradeStatusRequest::new(propose_receipt.locator)) - .await - .expect("status"); - assert_eq!(status.status, TradeStatusKind::AgreedPendingRhi); - assert_eq!(status.last_event_id, Some(decision.signed_event_id)); - assert_eq!(status.pending_revision_event_id, None); - assert_eq!(status.economics, Some(revision_economics())); -} - #[cfg(feature = "signer-adapters")] #[tokio::test] async fn trade_product_propose_dry_run_returns_plan_without_local_side_effects() { @@ -3316,7 +2572,7 @@ async fn trade_product_propose_dry_run_returns_plan_without_local_side_effects() .total_events, 0 ); - let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path) + let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path) .await .expect("outbox"); assert!( @@ -3328,449 +2584,6 @@ async fn trade_product_propose_dry_run_returns_plan_without_local_side_effects() ); } -#[cfg(any())] -#[tokio::test] -async fn order_submit_enqueue_returns_sanitized_signer_errors_before_mutation() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let request = TradeSubmitEnqueueRequest::new( - buyer_actor(), - listing_event_ptr(), - order_request("order-submit-wrong-signer"), - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY], NostrRelayUrlPolicy::Public) - .expect("transport targets"); - - let error = sdk - .trades() - .enqueue_submit_with_explicit_signer(request, &FixtureSigner::new(SELLER_SECRET_KEY_HEX)) - .await - .expect_err("signer error"); - let message = error.to_string(); - - assert!(matches!( - error, - RadrootsSdkError::SignerPubkeyMismatch { .. } - )); - assert!(!message.contains("raw")); - assert!(!message.contains("ffff")); - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 0 - ); - - let paths = sdk.storage_paths().expect("paths"); - let outbox = RadrootsOutbox::open_file(&paths.outbox_path) - .await - .expect("outbox"); - assert!( - outbox - .claim_next_ready_event("worker", "claim", 2_000, 1_700_000_000_000) - .await - .expect("claim") - .is_none() - ); -} - -#[cfg(any())] -#[tokio::test] -async fn order_submit_enqueue_derives_order_independent_idempotency_key() { - let (_tempdir, sdk, _store) = directory_sdk_and_store().await; - let first = TradeSubmitEnqueueRequest::new( - buyer_actor(), - listing_event_ptr(), - order_request("order-submit-idempotent"), - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY_B, RELAY], NostrRelayUrlPolicy::Public) - .expect("first transport targets"); - let second = TradeSubmitEnqueueRequest::new( - buyer_actor(), - listing_event_ptr(), - order_request("order-submit-idempotent"), - TargetPolicy::explicit( - TargetSet::nostr_relays([RELAY, RELAY_B], NostrRelayUrlPolicy::Public) - .expect("second transport targets"), - ), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ); - - let first_receipt = sdk - .trades() - .enqueue_submit_with_explicit_signer(first, &FixtureSigner::new(BUYER_SECRET_KEY_HEX)) - .await - .expect("first enqueue"); - let second_receipt = sdk - .trades() - .enqueue_submit_with_explicit_signer(second, &FixtureSigner::new(BUYER_SECRET_KEY_HEX)) - .await - .expect("second enqueue"); - - assert_eq!( - first_receipt.outbox_event_id, - second_receipt.outbox_event_id - ); - assert_eq!( - first_receipt.idempotency_digest_prefix, - second_receipt.idempotency_digest_prefix - ); - assert_eq!(second_receipt.state, SdkMutationState::AlreadyQueued); - assert!( - !first_receipt - .workflow - .idempotency - .replayed_existing_operation - ); - assert!( - second_receipt - .workflow - .idempotency - .replayed_existing_operation - ); - assert!( - second_receipt - .workflow - .idempotency - .safe_to_retry_with_same_idempotency_key - ); - assert!( - second_receipt - .workflow - .retry - .safe_to_retry_enqueue_with_same_idempotency_key - ); - assert!(!second_receipt.workflow.retry.retryable_after_error); - assert!(second_receipt.workflow.retry.recovery_actions.is_empty()); - - let paths = sdk.storage_paths().expect("paths"); - let outbox = RadrootsOutbox::open_file(&paths.outbox_path) - .await - .expect("outbox"); - let relay_urls = outbox - .delivery_targets(first_receipt.outbox_event_id) - .await - .expect("delivery targets") - .into_iter() - .map(|target| target.endpoint_uri.to_string()) - .collect::<Vec<_>>(); - assert_eq!(relay_urls, vec![RELAY_B.to_owned(), RELAY.to_owned()]); -} - -#[cfg(any())] -#[tokio::test] -async fn order_submit_enqueue_pushes_queued_event_with_mock_relay_sync() { - let (_tempdir, sdk, _store) = directory_sdk_and_store().await; - let enqueue_request = TradeSubmitEnqueueRequest::new( - buyer_actor(), - listing_event_ptr(), - order_request("order-submit-sync"), - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY], NostrRelayUrlPolicy::Public) - .expect("transport targets"); - let enqueue_receipt = sdk - .trades() - .enqueue_submit_with_explicit_signer( - enqueue_request, - &FixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue"); - let adapter = RadrootsMockRelayPublishAdapter::new(); - - let push_receipt = sdk - .sync() - .push_outbox_with_transport( - &RadrootsNostrTransport::new(&adapter), - PushOutboxRequest::new().with_limit(1), - ) - .await - .expect("push"); - - assert_eq!(push_receipt.attempted_events, 1); - assert_eq!(push_receipt.published_events, 1); - assert_eq!(push_receipt.retryable_events, 0); - assert_eq!(push_receipt.terminal_events, 0); - assert_eq!(push_receipt.events.len(), 1); - let event = &push_receipt.events[0]; - assert_eq!(event.event_id, enqueue_receipt.signed_event_id); - assert_eq!(event.outbox_event_id, enqueue_receipt.outbox_event_id); - assert_eq!(event.final_state, PushOutboxEventState::Published); - assert_eq!(event.attempted_count, 1); - assert_eq!(event.accepted_count, 1); - assert_eq!(event.retryable_count, 0); - assert_eq!(event.terminal_count, 0); - assert_eq!(event.quorum, 1); - assert!(event.quorum_met); - assert_eq!(event.targets.len(), 1); - assert_eq!(event.targets[0].endpoint_uri, RELAY); - assert_eq!( - event.targets[0].outcome_kind, - PushOutboxTargetOutcomeKind::Accepted - ); - assert_eq!(adapter.captured_raw_events().len(), 1); - - let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path) - .await - .expect("outbox"); - let stored = outbox - .get_event(enqueue_receipt.outbox_event_id) - .await - .expect("stored") - .expect("stored"); - assert_eq!(stored.state, RadrootsOutboxEventState::Published); -} - -#[cfg(any())] -#[tokio::test] -async fn order_submit_enqueue_reports_partial_local_mutation_after_outbox_conflict() { - let (_tempdir, sdk, _store) = directory_sdk_and_store().await; - let first = TradeSubmitEnqueueRequest::new( - buyer_actor(), - listing_event_ptr(), - order_request("order-submit-conflict-a"), - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY], NostrRelayUrlPolicy::Public) - .expect("first transport targets") - .try_with_idempotency_key("order-submit-conflict-idempotency") - .expect("first idempotency key"); - sdk.trades() - .enqueue_submit_with_explicit_signer(first, &FixtureSigner::new(BUYER_SECRET_KEY_HEX)) - .await - .expect("first enqueue"); - - let second = TradeSubmitEnqueueRequest::new( - buyer_actor(), - listing_event_ptr(), - order_request("order-submit-conflict-b"), - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY], NostrRelayUrlPolicy::Public) - .expect("second transport targets") - .try_with_idempotency_key("order-submit-conflict-idempotency") - .expect("second idempotency key"); - let error = sdk - .trades() - .enqueue_submit_with_explicit_signer(second, &FixtureSigner::new(BUYER_SECRET_KEY_HEX)) - .await - .expect_err("partial"); - - assert!(matches!( - error, - RadrootsSdkError::PartialLocalMutation(ref partial) - if partial.stored - && !partial.queued - && partial.event_id.is_some() - && partial.operation_kind == TRADE_SUBMIT_OPERATION_KIND - && partial.idempotency_digest_prefix.is_some() - && partial.failure == RadrootsSdkPartialLocalMutationFailure::OutboxIdempotencyConflict - && partial.recovery == RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey - )); - assert!(error.retryable()); - assert_eq!( - error.recovery_actions(), - vec![RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey] - ); - let detail = error.detail_json(); - assert_eq!(detail["code"], "partial_local_mutation"); - assert_eq!(detail["retryable"], true); - assert_eq!( - detail["recovery_actions"], - serde_json::json!(["retry_operation_with_same_idempotency_key"]) - ); - assert!( - !error - .to_string() - .contains("order-submit-conflict-idempotency") - ); -} - -#[cfg(any())] -#[tokio::test] -async fn order_submit_runtime_dtos_serialize_deterministically() { - let (_tempdir, sdk, _store) = directory_sdk_and_store().await; - let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_123); - let prepare_request = TradeSubmitPrepareRequest::new( - buyer_actor(), - listing_event_ptr(), - order_request("order-submit-serialized"), - ) - .with_created_at(created_at); - let prepare_json = serde_json::to_value(&prepare_request).expect("prepare request json"); - assert_struct_serialize_error_paths(&prepare_request, 4); - - assert_eq!( - prepare_json["actor"], - serde_json::json!({ - "pubkey": BUYER_PUBLIC_KEY_HEX, - "roles": ["buyer"], - "account_id": null, - "source": "test" - }) - ); - assert_eq!( - prepare_json["listing_event"], - serde_json::json!({ - "id": deterministic_event_id("listing-event").as_str(), - "relays": RELAY - }) - ); - assert_eq!(prepare_json["order"]["order_id"], "order-submit-serialized"); - assert_eq!( - prepare_json["order"]["listing_addr"], - listing_address().as_str() - ); - assert_eq!(prepare_json["order"]["buyer_pubkey"], BUYER_PUBLIC_KEY_HEX); - assert_eq!( - prepare_json["order"]["seller_pubkey"], - SELLER_PUBLIC_KEY_HEX - ); - assert_eq!(prepare_json["order"]["items"][0]["bin_id"], "bin-1"); - assert_eq!(prepare_json["order"]["items"][0]["bin_count"], 2); - assert_eq!(prepare_json["created_at"], 1_700_000_123); - - let enqueue_request = TradeSubmitEnqueueRequest::new( - buyer_actor(), - listing_event_ptr(), - order_request("order-submit-serialized-enqueue"), - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY, RELAY_B], NostrRelayUrlPolicy::Public) - .expect("relay targets") - .with_idempotency_key( - SdkIdempotencyKey::new("order-serialized-idempotency").expect("idempotency"), - ) - .with_created_at(created_at); - let enqueue_json = serde_json::to_value(&enqueue_request).expect("enqueue request json"); - assert_struct_serialize_error_paths(&enqueue_request, 6); - - assert_eq!( - enqueue_json["target_policy"], - serde_json::json!({ - "kind": "explicit", - "targets": [ - { - "kind": "nostr", - "uri": RELAY, - "scope": null, - "label": null, - "fingerprint": "a1997ec4596596af6ffc65e6a30ab7cffa53ea71f524c1c86d64018b96d130af" - }, - { - "kind": "nostr", - "uri": RELAY_B, - "scope": null, - "label": null, - "fingerprint": "5136077cfe7eddcbfaddc5d7bf1f42cdbb8191f3691b86ccc3a81047851cef05" - } - ], - "canonical_targets": [ - "5136077cfe7eddcbfaddc5d7bf1f42cdbb8191f3691b86ccc3a81047851cef05", - "a1997ec4596596af6ffc65e6a30ab7cffa53ea71f524c1c86d64018b96d130af" - ] - }) - ); - assert_eq!( - enqueue_json["idempotency_key"], - serde_json::json!({ "value": "<redacted>", "len": 28 }) - ); - assert_eq!(enqueue_json["created_at"], 1_700_000_123); - assert!( - !enqueue_json - .to_string() - .contains("order-serialized-idempotency") - ); - - let try_key_enqueue = TradeSubmitEnqueueRequest::new( - buyer_actor(), - listing_event_ptr(), - order_request("order-submit-try-idempotency"), - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_idempotency_key("order-submit-try-key") - .expect("try idempotency key"); - assert_eq!( - serde_json::to_value(&try_key_enqueue).expect("try key request json")["idempotency_key"], - serde_json::json!({ "value": "<redacted>", "len": 20 }) - ); - - let receipt = sdk - .trades() - .enqueue_submit_with_explicit_signer( - enqueue_request, - &FixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue"); - let receipt_json = serde_json::to_value(&receipt).expect("receipt json"); - - assert_eq!( - receipt_json, - serde_json::json!({ - "workflow": { - "kind": "submit", - "operation_kind": TRADE_SUBMIT_OPERATION_KIND, - "expected_event_id": receipt.workflow.expected_event_id.as_str(), - "signed_event_id": receipt.workflow.signed_event_id.as_str(), - "local_event_seq": 1, - "outbox_operation_id": 1, - "outbox_event_id": 1, - "state": "stored_and_queued", - "idempotency_digest_prefix": receipt.workflow.idempotency_digest_prefix.as_deref(), - "idempotency": { - "digest_prefix": receipt.workflow.idempotency.digest_prefix.as_deref(), - "replayed_existing_operation": false, - "safe_to_retry_with_same_idempotency_key": true - }, - "retry": { - "retryable_after_error": false, - "safe_to_retry_enqueue_with_same_idempotency_key": true, - "recovery_actions": [] - } - }, - "order_id": receipt.order_id.as_str(), - "locator": { - "trade_id": receipt.order_id.as_str(), - "root_event_id": receipt.signed_event_id.as_str(), - "listing_addr": receipt.listing_addr.as_str(), - "buyer_pubkey": BUYER_PUBLIC_KEY_HEX, - "seller_pubkey": SELLER_PUBLIC_KEY_HEX - }, - "listing_addr": receipt.listing_addr.as_str(), - "buyer_pubkey": BUYER_PUBLIC_KEY_HEX, - "seller_pubkey": SELLER_PUBLIC_KEY_HEX, - "listing_event_id": receipt.listing_event_id.as_str(), - "expected_event_id": receipt.expected_event_id.as_str(), - "signed_event_id": receipt.signed_event_id.as_str(), - "local_event_seq": 1, - "outbox_operation_id": 1, - "outbox_event_id": 1, - "state": "stored_and_queued", - "idempotency_digest_prefix": receipt.idempotency_digest_prefix.as_deref() - }) - ); -} - fn order_decision(raw_order_id: &str) -> RadrootsOrderDecision { RadrootsOrderDecision { order_id: order_id(raw_order_id), @@ -3786,84 +2599,6 @@ fn order_decision(raw_order_id: &str) -> RadrootsOrderDecision { } } -#[cfg(any())] -fn order_revision_proposal( - raw_order_id: &str, - root_event_id: &RadrootsEventId, - previous_event_id: &RadrootsEventId, -) -> RadrootsOrderRevisionProposal { - RadrootsOrderRevisionProposal { - revision_id: format!("revision-{raw_order_id}") - .parse() - .expect("revision id"), - 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"), - root_event_id: root_event_id.clone(), - prev_event_id: previous_event_id.clone(), - items: vec![RadrootsOrderItem { - bin_id: "bin-1".parse().expect("bin id"), - bin_count: 3, - }], - economics: revision_economics(), - reason: "increase quantity".to_owned(), - } -} - -#[cfg(any())] -fn order_revision_decision( - proposal: &RadrootsOrderRevisionProposal, - previous_event_id: &RadrootsEventId, - decision: RadrootsOrderRevisionOutcome, -) -> RadrootsOrderRevisionDecision { - RadrootsOrderRevisionDecision { - revision_id: proposal.revision_id.clone(), - order_id: proposal.order_id.clone(), - listing_addr: proposal.listing_addr.clone(), - buyer_pubkey: proposal.buyer_pubkey.clone(), - seller_pubkey: proposal.seller_pubkey.clone(), - root_event_id: proposal.root_event_id.clone(), - prev_event_id: previous_event_id.clone(), - decision, - } -} - -#[cfg(any())] -fn order_cancellation(raw_order_id: &str) -> RadrootsOrderCancellation { - RadrootsOrderCancellation { - 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"), - reason: "buyer changed pickup plan".to_owned(), - } -} - -fn revision_economics() -> RadrootsOrderEconomics { - RadrootsOrderEconomics { - quote_id: "revision-quote-1".parse().expect("revision quote id"), - quote_version: 2, - pricing_basis: RadrootsOrderPricingBasis::ListingEvent, - currency: RadrootsCoreCurrency::USD, - items: vec![RadrootsOrderEconomicItem { - bin_id: "bin-1".parse().expect("bin id"), - bin_count: 3, - quantity_amount: decimal("1"), - quantity_unit: RadrootsCoreUnit::Each, - unit_price_amount: decimal("5"), - unit_price_currency: RadrootsCoreCurrency::USD, - line_subtotal: usd("15"), - }], - discounts: Vec::<RadrootsOrderEconomicLine>::new(), - adjustments: Vec::<RadrootsOrderEconomicLine>::new(), - subtotal: usd("15"), - discount_total: usd("0"), - adjustment_total: usd("0"), - total: usd("15"), - } -} - #[cfg(feature = "transport-nostr-runtime")] fn signed_raw_validation_receipt_event( raw_order_id: &str, @@ -3991,109 +2726,6 @@ fn validation_receipt_wire_parts_with_proof( validation_receipt_event_build(raw_order_id, &receipt).expect("receipt event") } -fn signed_raw_sp1_worker_result_event( - raw_order_id: &str, - receipt_event_id: &RadrootsEventId, - listing_event_id: &RadrootsEventId, - root_event_id: &RadrootsEventId, - target_event_id: &RadrootsEventId, - created_at: u32, -) -> nostr::Event { - let content = serde_json::json!({ - "confidence": "committed_by_trusted_service_and_proof", - "cryptographic_proof_verified": true, - "customer_pubkey": BUYER_PUBLIC_KEY_HEX, - "decision_event_id": target_event_id.as_str(), - "event_set_root": hash32('c'), - "listing_event_id": listing_event_id.as_str(), - "order_id": raw_order_id, - "proof_generated": true, - "proof_mode": "core", - "proof_system": "sp1_core", - "public_values_hash": validation_receipt_public_values_hash_hex(br#"{"schema_version":1}"#), - "prover_backend": "local_execute", - "receipt_event_id": receipt_event_id.as_str(), - "receipt_kind": KIND_TRADE_VALIDATION_RECEIPT, - "reducer_output_root": hash32('4'), - "request_event_id": root_event_id.as_str(), - "request_hash": hash32('9'), - "sp1_execute_checked": true, - "sp1_execute_public_values_hash": validation_receipt_public_values_hash_hex(br#"{"schema_version":1}"#), - "status": "succeeded", - "validation_authority": "trusted_service_and_proof_verified", - "worker_pubkey": public_key_hex_for_secret(SERVICE_SECRET_KEY_HEX), - "worker_role": "non_authoritative_prover" - }) - .to_string(); - signed_raw_event( - SERVICE_SECRET_KEY_HEX, - created_at, - RadrootsNip01EventWireParts { - kind: KIND_TRADE_TRANSITION_PROOF_RESULT, - content, - tags: vec![ - vec!["e".to_owned(), root_event_id.as_str().to_owned()], - vec![ - "radroots:validation_receipt".to_owned(), - receipt_event_id.as_str().to_owned(), - ], - ], - }, - ) -} - -#[cfg(feature = "transport-nostr-runtime")] -fn signed_raw_worker_result_event( - raw_order_id: &str, - receipt_event_id: &RadrootsEventId, - listing_event_id: &RadrootsEventId, - root_event_id: &RadrootsEventId, - target_event_id: &RadrootsEventId, - created_at: u32, -) -> nostr::Event { - let content = serde_json::json!({ - "confidence": "committed_by_trusted_service", - "cryptographic_proof_verified": false, - "customer_pubkey": BUYER_PUBLIC_KEY_HEX, - "decision_event_id": target_event_id.as_str(), - "event_set_root": hash32('c'), - "listing_event_id": listing_event_id.as_str(), - "order_id": raw_order_id, - "proof_generated": false, - "proof_mode": "none", - "proof_system": "none", - "public_values_hash": validation_receipt_public_values_hash_hex(br#"{"schema_version":1}"#), - "prover_backend": "local_execute", - "receipt_event_id": receipt_event_id.as_str(), - "receipt_kind": KIND_TRADE_VALIDATION_RECEIPT, - "reducer_output_root": hash32('4'), - "request_event_id": root_event_id.as_str(), - "request_hash": hash32('9'), - "sp1_execute_checked": true, - "sp1_execute_public_values_hash": validation_receipt_public_values_hash_hex(br#"{"schema_version":1}"#), - "status": "succeeded", - "validation_authority": "trusted_rhi_service_key", - "worker_pubkey": public_key_hex_for_secret(SERVICE_SECRET_KEY_HEX), - "worker_role": "non_authoritative_prover" - }) - .to_string(); - signed_raw_event( - SERVICE_SECRET_KEY_HEX, - created_at, - RadrootsNip01EventWireParts { - kind: KIND_TRADE_TRANSITION_PROOF_RESULT, - content, - tags: vec![ - vec!["e".to_owned(), root_event_id.as_str().to_owned()], - vec![ - "radroots:validation_receipt".to_owned(), - receipt_event_id.as_str().to_owned(), - ], - ], - }, - ) -} - 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() @@ -4236,36 +2868,6 @@ fn signed_raw_order_request_event(raw_order_id: &str, created_at: u32) -> nostr: signed_raw_event(BUYER_SECRET_KEY_HEX, created_at, draft) } -#[cfg(any())] -fn request_event_ptr(event: &RadrootsEventEnvelope) -> RadrootsEventPtr { - RadrootsEventPtr { - id: event.id_str().to_owned(), - relays: Some(RELAY.to_owned()), - } -} - -#[cfg(any())] -fn order_event_ptr(event_id: &RadrootsEventId) -> RadrootsEventPtr { - RadrootsEventPtr { - id: event_id.as_str().to_owned(), - relays: Some(RELAY.to_owned()), - } -} - -#[cfg(any())] -async fn outbox_operation_kind(sdk: &RadrootsClient, operation_id: i64) -> String { - let paths = sdk.storage_paths().expect("paths"); - let outbox = RadrootsOutbox::open_file(&paths.outbox_path) - .await - .expect("outbox"); - outbox - .get_operation(operation_id) - .await - .expect("outbox operation") - .expect("outbox operation") - .operation_kind -} - fn signed_order_decision_event( raw_order_id: &str, root_event_id: &RadrootsEventId, @@ -4278,1689 +2880,110 @@ fn signed_order_decision_event( ) .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()]], - }, - ) -} - -#[cfg(any())] -#[tokio::test] -async fn order_request_evidence_ingest_stores_request_and_enables_decision_enqueue() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let request_event = signed_order_request_event("order-decision-ingested", 39); - let request_event_id = RadrootsEventId::parse(request_event.id_str()).expect("request id"); - let ingest_request = - TradeRequestEvidenceIngestRequest::new(signed_event_from_envelope(request_event.clone())) - .with_observed_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_039)); - - let ingest_receipt = sdk - .trades() - .ingest_request_evidence(ingest_request) - .await - .expect("ingest request evidence"); - - assert_eq!(ingest_receipt.order_id.as_str(), "order-decision-ingested"); - assert_eq!(ingest_receipt.listing_addr, listing_address()); - assert_eq!(ingest_receipt.buyer_pubkey.as_str(), BUYER_PUBLIC_KEY_HEX); - assert_eq!(ingest_receipt.seller_pubkey.as_str(), SELLER_PUBLIC_KEY_HEX); - assert_eq!(ingest_receipt.request_event_id, request_event_id); - assert_eq!(ingest_receipt.local_event_seq, 1); - assert!(ingest_receipt.inserted); - - let actor = seller_actor(); - let plan = sdk - .trades() - .prepare_decision(TradeDecisionPrepareRequest::new( - actor.clone(), - request_event_ptr(&request_event), - order_decision("order-decision-ingested"), - )) - .expect("prepare decision"); - let receipt = sdk - .trades() - .enqueue_prepared_decision_with_explicit_signer( - &actor, - plan, - TargetPolicy::try_nostr_relays([RELAY], NostrRelayUrlPolicy::Public) - .expect("transport targets"), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - &FixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue decision"); - - assert_eq!(receipt.local_event_seq, 2); - let duplicate_receipt = sdk - .trades() - .ingest_request_evidence(TradeRequestEvidenceIngestRequest::new( - request_event.clone(), - )) - .await - .expect("duplicate request evidence"); - assert_eq!(duplicate_receipt.local_event_seq, 1); - assert!(!duplicate_receipt.inserted); - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 2 - ); -} - -#[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::AgreedPendingRhi); - 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 - ); -} - -#[cfg(any())] -#[tokio::test] -async fn order_decision_prepare_accept_and_decline_are_side_effect_free() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let request_event_id = deterministic_event_id("order-decision-prepare-request"); - let request_event = RadrootsEventPtr { - id: request_event_id.as_str().to_owned(), - relays: Some(RELAY.to_owned()), - }; - let accepted_request = TradeDecisionPrepareRequest::new( - seller_actor(), - request_event.clone(), - order_decision("order-decision-prepare-accept"), - ); - - let accepted = sdk - .trades() - .prepare_decision(accepted_request) - .expect("accepted plan"); - - assert_eq!(accepted.order_id.as_str(), "order-decision-prepare-accept"); - assert_eq!(accepted.listing_addr, listing_address()); - assert_eq!(accepted.buyer_pubkey.as_str(), BUYER_PUBLIC_KEY_HEX); - assert_eq!(accepted.seller_pubkey.as_str(), SELLER_PUBLIC_KEY_HEX); - assert_eq!(accepted.request_event_id, request_event_id); - assert_eq!(accepted.frozen_draft.kind_u32(), KIND_ORDER_DECISION); - assert_eq!(accepted.created_at.unix_seconds(), 1_700_000_000); - assert_eq!( - accepted.expected_event_id, - accepted.frozen_draft.expected_event_id_str() - ); - - let mut declined_payload = order_decision("order-decision-prepare-decline"); - declined_payload.decision = RadrootsOrderDecisionOutcome::Declined { - reason: " out of stock ".to_owned(), - }; - let declined = sdk - .trades() - .prepare_decision(TradeDecisionPrepareRequest::new( - seller_actor(), - request_event, - declined_payload, - )) - .expect("declined plan"); - - assert_eq!(declined.order_id.as_str(), "order-decision-prepare-decline"); - assert_eq!(declined.frozen_draft.kind_u32(), KIND_ORDER_DECISION); - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 0 - ); - - let paths = sdk.storage_paths().expect("paths"); - let outbox = RadrootsOutbox::open_file(&paths.outbox_path) - .await - .expect("outbox"); - assert!( - outbox - .claim_next_ready_event("worker", "claim", 2_000, 1_700_000_000_000) - .await - .expect("claim") - .is_none() - ); -} - -#[cfg(any())] -#[tokio::test] -async fn order_decision_prepare_rejects_invalid_actor_evidence_and_payload() { - let (_tempdir, sdk, _store) = directory_sdk_and_store().await; - let request_event = RadrootsEventPtr { - id: deterministic_event_id("order-decision-invalid-request") - .as_str() - .to_owned(), - relays: Some(RELAY.to_owned()), - }; - - let non_seller = sdk - .trades() - .prepare_decision(TradeDecisionPrepareRequest::new( - non_seller_actor(), - request_event.clone(), - order_decision("order-decision-non-seller"), - )) - .expect_err("non seller"); - assert!(matches!( - non_seller, - RadrootsSdkError::UnauthorizedActor { .. } - )); - - let wrong_actor = sdk - .trades() - .prepare_decision(TradeDecisionPrepareRequest::new( - other_seller_actor(), - request_event.clone(), - order_decision("order-decision-wrong-seller"), - )) - .expect_err("wrong seller"); - assert!(matches!( - wrong_actor, - RadrootsSdkError::UnauthorizedActor { .. } - )); - - let invalid_evidence = sdk - .trades() - .prepare_decision(TradeDecisionPrepareRequest::new( - seller_actor(), - RadrootsEventPtr { - id: String::new(), - relays: Some(RELAY.to_owned()), - }, - order_decision("order-decision-invalid-evidence"), - )) - .expect_err("invalid evidence"); - assert!(matches!( - invalid_evidence, - RadrootsSdkError::InvalidRequest { .. } - )); - - let mut empty_commitments = order_decision("order-decision-empty-commitments"); - empty_commitments.decision = RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: Vec::new(), - }; - let commitment_error = sdk - .trades() - .prepare_decision(TradeDecisionPrepareRequest::new( - seller_actor(), - request_event.clone(), - empty_commitments, - )) - .expect_err("missing commitments"); - assert!(matches!( - commitment_error, - RadrootsSdkError::InvalidRequest { .. } - )); - - let mut missing_reason = order_decision("order-decision-missing-reason"); - missing_reason.decision = RadrootsOrderDecisionOutcome::Declined { - reason: " ".to_owned(), - }; - let reason_error = sdk - .trades() - .prepare_decision(TradeDecisionPrepareRequest::new( - seller_actor(), - request_event, - missing_reason, - )) - .expect_err("missing reason"); - assert!(matches!( - reason_error, - RadrootsSdkError::InvalidRequest { .. } - )); -} - -#[cfg(any())] -#[tokio::test] -async fn order_decision_runtime_dtos_serialize_deterministically() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_321); - let prepare_event_id = deterministic_event_id("order-decision-serialized-request"); - let prepare_request = TradeDecisionPrepareRequest::new( - seller_actor(), - RadrootsEventPtr { - id: prepare_event_id.as_str().to_owned(), - relays: Some(RELAY.to_owned()), - }, - order_decision("order-decision-serialized"), - ) - .with_created_at(created_at); - let prepare_json = serde_json::to_value(&prepare_request).expect("prepare request json"); - assert_struct_serialize_error_paths(&prepare_request, 4); - - assert_eq!( - prepare_json["actor"], - serde_json::json!({ - "pubkey": SELLER_PUBLIC_KEY_HEX, - "roles": ["seller"], - "account_id": null, - "source": "test" - }) - ); - assert_eq!( - prepare_json["request_event"], - serde_json::json!({ - "id": prepare_event_id.as_str(), - "relays": RELAY - }) - ); - assert_eq!( - prepare_json["decision"]["order_id"], - "order-decision-serialized" - ); - assert_eq!( - prepare_json["decision"]["seller_pubkey"], - SELLER_PUBLIC_KEY_HEX - ); - assert_eq!(prepare_json["created_at"], 1_700_000_321); - - let request_event = signed_order_request_event("order-decision-serialized-enqueue", 45); - store - .ingest_event(RadrootsEventIngest::new( - signed_event_from_envelope(request_event.clone()), - 4_500, - )) - .await - .expect("ingest request"); - let enqueue_request = TradeDecisionEnqueueRequest::new( - seller_actor(), - request_event_ptr(&request_event), - order_decision("order-decision-serialized-enqueue"), - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY, RELAY_B], NostrRelayUrlPolicy::Public) - .expect("transport targets") - .with_idempotency_key( - SdkIdempotencyKey::new("order-decision-serialized-idempotency").expect("idempotency"), - ) - .with_created_at(created_at); - let enqueue_json = serde_json::to_value(&enqueue_request).expect("enqueue request json"); - assert_struct_serialize_error_paths(&enqueue_request, 6); - - assert_eq!( - enqueue_json["target_policy"], - serde_json::json!({ - "kind": "explicit", - "targets": [ - { - "kind": "nostr", - "uri": RELAY, - "scope": null, - "label": null, - "fingerprint": "a1997ec4596596af6ffc65e6a30ab7cffa53ea71f524c1c86d64018b96d130af" - }, - { - "kind": "nostr", - "uri": RELAY_B, - "scope": null, - "label": null, - "fingerprint": "5136077cfe7eddcbfaddc5d7bf1f42cdbb8191f3691b86ccc3a81047851cef05" - } - ], - "canonical_targets": [ - "5136077cfe7eddcbfaddc5d7bf1f42cdbb8191f3691b86ccc3a81047851cef05", - "a1997ec4596596af6ffc65e6a30ab7cffa53ea71f524c1c86d64018b96d130af" - ] - }) - ); - assert_eq!( - enqueue_json["idempotency_key"], - serde_json::json!({ "value": "<redacted>", "len": 37 }) - ); - assert_eq!(enqueue_json["created_at"], 1_700_000_321); - assert!( - !enqueue_json - .to_string() - .contains("order-decision-serialized-idempotency") - ); - - let try_key_enqueue = TradeDecisionEnqueueRequest::new( - seller_actor(), - request_event_ptr(&request_event), - order_decision("order-decision-try-idempotency"), - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_idempotency_key("order-decision-try-key") - .expect("try idempotency key"); - assert_eq!( - serde_json::to_value(&try_key_enqueue).expect("try key request json")["idempotency_key"], - serde_json::json!({ "value": "<redacted>", "len": 22 }) - ); - - let receipt = sdk - .trades() - .enqueue_decision_with_explicit_signer( - enqueue_request, - &FixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue"); - assert_eq!(receipt.workflow.kind, TradeWorkflowKind::Decision); - assert_eq!( - receipt.workflow.operation_kind, - TRADE_DECISION_OPERATION_KIND - ); - assert_eq!( - receipt.workflow.expected_event_id, - receipt.expected_event_id - ); - assert_eq!(receipt.workflow.signed_event_id, receipt.signed_event_id); - assert_eq!(receipt.workflow.local_event_seq, receipt.local_event_seq); - assert_eq!( - receipt.workflow.outbox_operation_id, - receipt.outbox_operation_id - ); - assert_eq!(receipt.workflow.outbox_event_id, receipt.outbox_event_id); - assert_eq!(receipt.workflow.state, receipt.state); - assert_eq!( - receipt.workflow.idempotency_digest_prefix, - receipt.idempotency_digest_prefix - ); - assert!( - receipt - .workflow - .idempotency - .safe_to_retry_with_same_idempotency_key - ); - assert!(!receipt.workflow.retry.retryable_after_error); - assert!(receipt.workflow.retry.recovery_actions.is_empty()); - let receipt_json = serde_json::to_value(&receipt).expect("receipt json"); - - assert_eq!( - receipt_json, - serde_json::json!({ - "workflow": { - "kind": "decision", - "operation_kind": TRADE_DECISION_OPERATION_KIND, - "expected_event_id": receipt.workflow.expected_event_id.as_str(), - "signed_event_id": receipt.workflow.signed_event_id.as_str(), - "local_event_seq": 2, - "outbox_operation_id": 1, - "outbox_event_id": 1, - "state": "stored_and_queued", - "idempotency_digest_prefix": receipt.workflow.idempotency_digest_prefix.as_deref(), - "idempotency": { - "digest_prefix": receipt.workflow.idempotency.digest_prefix.as_deref(), - "replayed_existing_operation": false, - "safe_to_retry_with_same_idempotency_key": true - }, - "retry": { - "retryable_after_error": false, - "safe_to_retry_enqueue_with_same_idempotency_key": true, - "recovery_actions": [] - } - }, - "order_id": receipt.order_id.as_str(), - "locator": { - "trade_id": receipt.order_id.as_str(), - "root_event_id": request_event.id_str(), - "listing_addr": receipt.listing_addr.as_str(), - "buyer_pubkey": BUYER_PUBLIC_KEY_HEX, - "seller_pubkey": SELLER_PUBLIC_KEY_HEX - }, - "listing_addr": receipt.listing_addr.as_str(), - "buyer_pubkey": BUYER_PUBLIC_KEY_HEX, - "seller_pubkey": SELLER_PUBLIC_KEY_HEX, - "request_event_id": request_event.id_str(), - "expected_event_id": receipt.expected_event_id.as_str(), - "signed_event_id": receipt.signed_event_id.as_str(), - "local_event_seq": 2, - "outbox_operation_id": 1, - "outbox_event_id": 1, - "state": "stored_and_queued", - "idempotency_digest_prefix": receipt.idempotency_digest_prefix.as_deref() - }) - ); -} - -#[cfg(any())] -#[tokio::test] -async fn order_revision_and_cancellation_dtos_serialize_deterministically() { - let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_654); - let root_event_id = deterministic_event_id("order-dto-root"); - let previous_event_id = deterministic_event_id("order-dto-previous"); - let root_event = order_event_ptr(&root_event_id); - let previous_event = order_event_ptr(&previous_event_id); - let proposal = - order_revision_proposal("order-revision-dto", &root_event_id, &previous_event_id); - let revision_decision = order_revision_decision( - &proposal, - &previous_event_id, - RadrootsOrderRevisionOutcome::Declined { - reason: "not available".to_owned(), - }, - ); - let cancellation = order_cancellation("order-revision-dto"); - - let proposal_prepare = TradeRevisionProposalPrepareRequest::new( - seller_actor(), - root_event.clone(), - previous_event.clone(), - proposal.clone(), - ) - .with_created_at(created_at); - let proposal_prepare_json = - serde_json::to_value(&proposal_prepare).expect("proposal prepare json"); - assert_struct_serialize_error_paths(&proposal_prepare, 5); - assert_eq!( - proposal_prepare_json["actor"]["pubkey"], - SELLER_PUBLIC_KEY_HEX - ); - assert_eq!( - proposal_prepare_json["root_event"]["id"], - root_event_id.as_str() - ); - assert_eq!( - proposal_prepare_json["previous_event"]["id"], - previous_event_id.as_str() - ); - assert_eq!( - proposal_prepare_json["proposal"]["order_id"], - "order-revision-dto" - ); - assert_eq!( - proposal_prepare_json["proposal"]["reason"], - "increase quantity" - ); - assert_eq!(proposal_prepare_json["created_at"], 1_700_000_654); - - let proposal_enqueue = TradeRevisionProposalEnqueueRequest::new( - seller_actor(), - root_event.clone(), - previous_event.clone(), - proposal.clone(), - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY, RELAY_B], NostrRelayUrlPolicy::Public) - .expect("proposal relays") - .with_idempotency_key(SdkIdempotencyKey::new("order-revision-proposal-dto").expect("key")) - .with_created_at(created_at); - let proposal_enqueue_json = - serde_json::to_value(&proposal_enqueue).expect("proposal enqueue json"); - assert_struct_serialize_error_paths(&proposal_enqueue, 7); - assert_eq!( - proposal_enqueue_json["target_policy"], - serde_json::json!({ - "kind": "explicit", - "targets": [ - { - "kind": "nostr", - "uri": RELAY, - "scope": null, - "label": null, - "fingerprint": "a1997ec4596596af6ffc65e6a30ab7cffa53ea71f524c1c86d64018b96d130af" - }, - { - "kind": "nostr", - "uri": RELAY_B, - "scope": null, - "label": null, - "fingerprint": "5136077cfe7eddcbfaddc5d7bf1f42cdbb8191f3691b86ccc3a81047851cef05" - } - ], - "canonical_targets": [ - "5136077cfe7eddcbfaddc5d7bf1f42cdbb8191f3691b86ccc3a81047851cef05", - "a1997ec4596596af6ffc65e6a30ab7cffa53ea71f524c1c86d64018b96d130af" - ] - }) - ); - assert_eq!( - proposal_enqueue_json["idempotency_key"], - serde_json::json!({ "value": "<redacted>", "len": 27 }) - ); - assert!(!proposal_enqueue_json.to_string().contains("proposal-dto")); - - let proposal_try_key = TradeRevisionProposalEnqueueRequest::new( - seller_actor(), - root_event.clone(), - previous_event.clone(), - proposal.clone(), - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_idempotency_key("order-revision-proposal-try") - .expect("proposal try key"); - assert_eq!( - serde_json::to_value(&proposal_try_key).expect("proposal try json")["idempotency_key"], - serde_json::json!({ "value": "<redacted>", "len": 27 }) - ); - - let decision_prepare = TradeRevisionDecisionPrepareRequest::new( - buyer_actor(), - root_event.clone(), - previous_event.clone(), - revision_decision.clone(), - ) - .with_created_at(created_at); - let decision_prepare_json = - serde_json::to_value(&decision_prepare).expect("decision prepare json"); - assert_struct_serialize_error_paths(&decision_prepare, 5); - assert_eq!( - decision_prepare_json["actor"]["pubkey"], - BUYER_PUBLIC_KEY_HEX - ); - assert_eq!( - decision_prepare_json["decision"]["revision_id"], - proposal.revision_id.as_str() - ); - assert_eq!( - decision_prepare_json["decision"]["decision"], - serde_json::json!({ - "decision": "declined", - "reason": "not available" - }) - ); - assert_eq!(decision_prepare_json["created_at"], 1_700_000_654); - - let decision_enqueue = TradeRevisionDecisionEnqueueRequest::new( - buyer_actor(), - root_event.clone(), - previous_event.clone(), - revision_decision, - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY, RELAY_B], NostrRelayUrlPolicy::Public) - .expect("decision relays") - .with_idempotency_key( - SdkIdempotencyKey::new("order-revision-decision-dto").expect("decision idempotency"), - ) - .with_created_at(created_at); - let decision_enqueue_json = - serde_json::to_value(&decision_enqueue).expect("decision enqueue json"); - assert_struct_serialize_error_paths(&decision_enqueue, 7); - assert_eq!( - decision_enqueue_json["idempotency_key"], - serde_json::json!({ "value": "<redacted>", "len": 27 }) - ); - assert_eq!(decision_enqueue_json["created_at"], 1_700_000_654); - assert!(!decision_enqueue_json.to_string().contains("decision-dto")); - - let decision_try_key = TradeRevisionDecisionEnqueueRequest::new( - buyer_actor(), - root_event.clone(), - previous_event.clone(), - order_revision_decision( - &proposal, - &previous_event_id, - RadrootsOrderRevisionOutcome::Accepted, - ), - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_idempotency_key("order-revision-decision-try") - .expect("decision try key"); - assert_eq!( - serde_json::to_value(&decision_try_key).expect("decision try json")["idempotency_key"], - serde_json::json!({ "value": "<redacted>", "len": 27 }) - ); - - let cancellation_prepare = TradeCancellationPrepareRequest::new( - buyer_actor(), - root_event.clone(), - previous_event.clone(), - cancellation.clone(), - ) - .with_created_at(created_at); - let cancellation_prepare_json = - serde_json::to_value(&cancellation_prepare).expect("cancellation prepare json"); - assert_struct_serialize_error_paths(&cancellation_prepare, 5); - assert_eq!( - cancellation_prepare_json["cancellation"]["reason"], - "buyer changed pickup plan" - ); - assert_eq!(cancellation_prepare_json["created_at"], 1_700_000_654); - - let cancellation_enqueue = TradeCancellationEnqueueRequest::new( - buyer_actor(), - root_event.clone(), - previous_event.clone(), - cancellation, - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY, RELAY_B], NostrRelayUrlPolicy::Public) - .expect("cancellation relays") - .with_idempotency_key( - SdkIdempotencyKey::new("order-cancellation-dto").expect("cancellation idempotency"), - ) - .with_created_at(created_at); - let cancellation_enqueue_json = - serde_json::to_value(&cancellation_enqueue).expect("cancellation enqueue json"); - assert_struct_serialize_error_paths(&cancellation_enqueue, 7); - assert_eq!( - cancellation_enqueue_json["idempotency_key"], - serde_json::json!({ "value": "<redacted>", "len": 22 }) - ); - assert_eq!(cancellation_enqueue_json["created_at"], 1_700_000_654); - assert!( - !cancellation_enqueue_json - .to_string() - .contains("cancellation-dto") - ); - - let cancellation_try_key = TradeCancellationEnqueueRequest::new( - buyer_actor(), - root_event.clone(), - previous_event.clone(), - order_cancellation("order-revision-dto"), - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_idempotency_key("order-cancellation-try") - .expect("cancellation try key"); - assert_eq!( - serde_json::to_value(&cancellation_try_key).expect("cancellation try json")["idempotency_key"], - serde_json::json!({ "value": "<redacted>", "len": 22 }) - ); - - let event = signed_order_request_event("order-evidence-dto", 77); - let request_evidence = - TradeRequestEvidenceIngestRequest::new(signed_event_from_envelope(event.clone())) - .with_observed_at(created_at); - let request_evidence_json = - serde_json::to_value(&request_evidence).expect("request evidence json"); - assert_struct_serialize_error_paths(&request_evidence, 2); - assert_eq!(request_evidence_json["event"]["id"], event.id_str()); - assert_eq!(request_evidence_json["observed_at"], 1_700_000_654); - - let order_evidence = TradeEvidenceIngestRequest::new(signed_event_from_envelope(event.clone())) - .with_observed_at(created_at); - let order_evidence_json = serde_json::to_value(&order_evidence).expect("order evidence json"); - assert_struct_serialize_error_paths(&order_evidence, 2); - assert_eq!(order_evidence_json["event"]["id"], event.id_str()); - assert_eq!(order_evidence_json["observed_at"], 1_700_000_654); -} - -#[cfg(any())] -#[tokio::test] -async fn order_decision_enqueue_accept_stores_event_queues_outbox_and_updates_status() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let request_event = signed_order_request_event("order-decision-accept", 40); - let request_event_id = RadrootsEventId::parse(request_event.id_str()).expect("request id"); - store - .ingest_event(RadrootsEventIngest::new( - signed_event_from_envelope(request_event.clone()), - 4_000, - )) - .await - .expect("ingest request"); - let request = TradeDecisionEnqueueRequest::new( - seller_actor(), - request_event_ptr(&request_event), - order_decision("order-decision-accept"), - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY], NostrRelayUrlPolicy::Public) - .expect("transport targets") - .try_with_idempotency_key("order-decision-accept-idempotency") - .expect("idempotency"); - - let receipt = sdk - .trades() - .enqueue_decision_with_explicit_signer(request, &FixtureSigner::new(SELLER_SECRET_KEY_HEX)) - .await - .expect("enqueue"); - - assert_eq!(receipt.order_id.as_str(), "order-decision-accept"); - assert_eq!(receipt.listing_addr, listing_address()); - assert_eq!(receipt.buyer_pubkey.as_str(), BUYER_PUBLIC_KEY_HEX); - assert_eq!(receipt.seller_pubkey.as_str(), SELLER_PUBLIC_KEY_HEX); - assert_eq!(receipt.request_event_id, request_event_id); - assert_eq!(receipt.signed_event_id, receipt.expected_event_id); - assert_eq!(receipt.local_event_seq, 2); - assert_eq!(receipt.outbox_operation_id, 1); - assert_eq!(receipt.outbox_event_id, 1); - assert_eq!(receipt.state, SdkMutationState::StoredAndQueued); - assert!(receipt.idempotency_digest_prefix.is_some()); - - let stored_event = store - .get_event(receipt.signed_event_id.as_str()) - .await - .expect("event lookup") - .expect("stored event"); - assert_eq!(stored_event.kind, KIND_ORDER_DECISION); - assert_eq!( - stored_event.contract_id.as_deref(), - Some("radroots.order.decision.v1") - ); - - let paths = sdk.storage_paths().expect("paths"); - let outbox = RadrootsOutbox::open_file(&paths.outbox_path) - .await - .expect("outbox"); - let operation = outbox - .get_operation(receipt.outbox_operation_id) - .await - .expect("outbox operation") - .expect("outbox operation"); - assert_eq!(operation.operation_kind, TRADE_DECISION_OPERATION_KIND); - let outbox_event = outbox - .get_event(receipt.outbox_event_id) - .await - .expect("outbox event") - .expect("outbox event"); - assert_eq!(outbox_event.state, RadrootsOutboxEventState::Signed); - assert_eq!(outbox_event.draft.kind_u32(), KIND_ORDER_DECISION); - assert!(outbox_event.signed_event.is_some()); - - let status = sdk - .trades() - .status(status_request("order-decision-accept")) - .await - .expect("status"); - assert!(status.found); - assert_eq!(status.status, TradeStatusKind::AgreedPendingRhi); - assert_eq!(status.event_count, 2); - assert_eq!( - status - .request_event_id - .as_ref() - .map(RadrootsEventId::as_str), - Some(request_event.id_str()) - ); - assert_eq!( - status - .decision_event_id - .as_ref() - .map(RadrootsEventId::as_str), - Some(receipt.signed_event_id.as_str()) - ); - assert_eq!( - status - .agreement_event_id - .as_ref() - .map(RadrootsEventId::as_str), - Some(receipt.signed_event_id.as_str()) - ); - assert_eq!(status.pending_revision_event_id, None); - assert!(status.issues.is_empty()); -} - -#[cfg(any())] -#[tokio::test] -async fn order_decision_enqueue_decline_stores_event_and_status_sees_declined() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let request_event = signed_order_request_event("order-decision-decline", 41); - store - .ingest_event(RadrootsEventIngest::new( - signed_event_from_envelope(request_event.clone()), - 4_100, - )) - .await - .expect("ingest request"); - let mut decision = order_decision("order-decision-decline"); - decision.decision = RadrootsOrderDecisionOutcome::Declined { - reason: " unavailable ".to_owned(), - }; - let request = TradeDecisionEnqueueRequest::new( - seller_actor(), - request_event_ptr(&request_event), - decision, - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY], NostrRelayUrlPolicy::Public) - .expect("transport targets"); - - let receipt = sdk - .trades() - .enqueue_decision_with_explicit_signer(request, &FixtureSigner::new(SELLER_SECRET_KEY_HEX)) - .await - .expect("enqueue"); - - assert_eq!(receipt.state, SdkMutationState::StoredAndQueued); - let status = sdk - .trades() - .status(status_request("order-decision-decline")) - .await - .expect("status"); - assert_eq!(status.status, TradeStatusKind::Declined); - assert_eq!( - status - .decision_event_id - .as_ref() - .map(RadrootsEventId::as_str), - Some(receipt.signed_event_id.as_str()) - ); - assert!(status.issues.is_empty()); -} - -#[cfg(any())] -#[tokio::test] -async fn order_decision_enqueue_rejects_missing_request_evidence_before_mutation() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let missing_request = RadrootsEventPtr { - id: deterministic_event_id("missing-order-request") - .as_str() - .to_owned(), - relays: Some(RELAY.to_owned()), - }; - let request = TradeDecisionEnqueueRequest::new( - seller_actor(), - missing_request, - order_decision("order-decision-missing-request"), - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY], NostrRelayUrlPolicy::Public) - .expect("transport targets"); - - let error = sdk - .trades() - .enqueue_decision_with_explicit_signer(request, &FixtureSigner::new(SELLER_SECRET_KEY_HEX)) - .await - .expect_err("missing request evidence"); - - assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. })); - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 0 - ); - let paths = sdk.storage_paths().expect("paths"); - let outbox = RadrootsOutbox::open_file(&paths.outbox_path) - .await - .expect("outbox"); - assert!( - outbox - .claim_next_ready_event("worker", "claim", 2_000, 1_700_000_000_000) - .await - .expect("claim") - .is_none() - ); -} - -#[cfg(any())] -#[tokio::test] -async fn order_decision_enqueue_returns_sanitized_signer_errors_before_decision_mutation() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let request_event = signed_order_request_event("order-decision-wrong-signer", 42); - store - .ingest_event(RadrootsEventIngest::new( - signed_event_from_envelope(request_event.clone()), - 4_200, - )) - .await - .expect("ingest request"); - let request = TradeDecisionEnqueueRequest::new( - seller_actor(), - request_event_ptr(&request_event), - order_decision("order-decision-wrong-signer"), - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY], NostrRelayUrlPolicy::Public) - .expect("transport targets"); - - let error = sdk - .trades() - .enqueue_decision_with_explicit_signer(request, &FixtureSigner::new(BUYER_SECRET_KEY_HEX)) - .await - .expect_err("signer error"); - let message = error.to_string(); - - assert!(matches!( - error, - RadrootsSdkError::SignerPubkeyMismatch { .. } - )); - assert!(!message.contains("raw")); - assert!(!message.contains("ffff")); - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 1 - ); -} - -#[cfg(any())] -#[tokio::test] -async fn order_decision_enqueue_rejects_existing_decision_state_before_mutation() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let request_event = signed_order_request_event("order-decision-conflict", 43); - let request_event_id = RadrootsEventId::parse(request_event.id_str()).expect("request id"); - let decision_event = - signed_order_decision_event("order-decision-conflict", &request_event_id, 44); - for (event, observed_at_ms) in [ - (request_event.clone(), 4_300), - (decision_event.clone(), 4_400), - ] { - store - .ingest_event(RadrootsEventIngest::new( - signed_event_from_envelope(event), - observed_at_ms, - )) - .await - .expect("ingest"); - } - let mut decline = order_decision("order-decision-conflict"); - decline.decision = RadrootsOrderDecisionOutcome::Declined { - reason: "too late".to_owned(), - }; - let request = TradeDecisionEnqueueRequest::new( - seller_actor(), - request_event_ptr(&request_event), - decline, - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY], NostrRelayUrlPolicy::Public) - .expect("transport targets"); - - let error = sdk - .trades() - .enqueue_decision_with_explicit_signer(request, &FixtureSigner::new(SELLER_SECRET_KEY_HEX)) - .await - .expect_err("existing decision"); - - assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. })); - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 2 - ); - let status = sdk - .trades() - .status(status_request("order-decision-conflict")) - .await - .expect("status"); - assert_eq!(status.status, TradeStatusKind::AgreedPendingRhi); - assert_eq!( - status - .decision_event_id - .as_ref() - .map(RadrootsEventId::as_str), - Some(decision_event.id_str()) - ); -} - -#[cfg(any())] -#[tokio::test] -async fn order_revision_lifecycle_accepts_proposal_and_waits_for_rhi() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let request_event = signed_order_request_event("order-lifecycle-agreement", 50); - let request_event_id = RadrootsEventId::parse(request_event.id_str()).expect("request id"); - store - .ingest_event(RadrootsEventIngest::new( - signed_event_from_envelope(request_event.clone()), - 5_000, - )) - .await - .expect("ingest request"); - - let proposal = order_revision_proposal( - "order-lifecycle-agreement", - &request_event_id, - &request_event_id, - ); - let proposal_actor = seller_actor(); - let proposal_plan = sdk - .trades() - .prepare_revision_proposal(TradeRevisionProposalPrepareRequest::new( - proposal_actor.clone(), - request_event_ptr(&request_event), - request_event_ptr(&request_event), - proposal.clone(), - )) - .expect("prepare revision proposal"); - let proposal_receipt = sdk - .trades() - .enqueue_prepared_revision_proposal_with_explicit_signer( - &proposal_actor, - proposal_plan, - TargetPolicy::try_nostr_relays([RELAY], NostrRelayUrlPolicy::Public) - .expect("proposal transport targets"), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - Some( - SdkIdempotencyKey::new("order-lifecycle-revision-proposal") - .expect("proposal idempotency"), - ), - &FixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue revision proposal"); - assert_eq!( - proposal_receipt.signed_event_id, - proposal_receipt.expected_event_id - ); - assert_eq!( - outbox_operation_kind(&sdk, proposal_receipt.outbox_operation_id).await, - TRADE_REVISION_PROPOSAL_OPERATION_KIND - ); - let stored_proposal = store - .get_event(proposal_receipt.signed_event_id.as_str()) - .await - .expect("proposal event lookup") - .expect("proposal event"); - assert_eq!(stored_proposal.kind, KIND_ORDER_REVISION_PROPOSAL); - assert_eq!( - stored_proposal.contract_id.as_deref(), - Some("radroots.order.revision_proposal.v1") - ); - - let revision_decision = order_revision_decision( - &proposal, - &proposal_receipt.signed_event_id, - RadrootsOrderRevisionOutcome::Accepted, - ); - let revision_decision_actor = buyer_actor(); - let revision_decision_plan = sdk - .trades() - .prepare_revision_decision(TradeRevisionDecisionPrepareRequest::new( - revision_decision_actor.clone(), - request_event_ptr(&request_event), - order_event_ptr(&proposal_receipt.signed_event_id), - revision_decision, - )) - .expect("prepare revision decision"); - let revision_decision_receipt = sdk - .trades() - .enqueue_prepared_revision_decision_with_explicit_signer( - &revision_decision_actor, - revision_decision_plan, - TargetPolicy::try_nostr_relays([RELAY], NostrRelayUrlPolicy::Public) - .expect("revision decision transport targets"), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - &FixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue revision decision"); - assert_eq!( - outbox_operation_kind(&sdk, revision_decision_receipt.outbox_operation_id).await, - TRADE_REVISION_DECISION_OPERATION_KIND - ); - assert_eq!( - store - .get_event(revision_decision_receipt.signed_event_id.as_str()) - .await - .expect("revision decision lookup") - .expect("revision decision") - .kind, - KIND_ORDER_REVISION_DECISION - ); - - let status = sdk - .trades() - .status(status_request("order-lifecycle-agreement")) - .await - .expect("status"); - assert_eq!(status.status, TradeStatusKind::AgreedPendingRhi); - assert_eq!(status.event_count, 3); - assert_eq!( - status - .agreement_event_id - .as_ref() - .map(RadrootsEventId::as_str), - Some(revision_decision_receipt.signed_event_id.as_str()) - ); - assert!(status.decision_event_id.is_none()); - assert!(status.cancellation_event_id.is_none()); - assert_eq!(status.pending_revision_event_id, None); - assert_eq!(status.listing_addr, Some(listing_address())); - assert_eq!( - status.buyer_pubkey.as_ref().map(ToString::to_string), - Some(BUYER_PUBLIC_KEY_HEX.to_owned()) - ); - assert_eq!( - status.seller_pubkey.as_ref().map(ToString::to_string), - Some(SELLER_PUBLIC_KEY_HEX.to_owned()) - ); - assert_eq!(status.economics, Some(revision_economics())); - assert!(!status.lifecycle_terminal); - assert_eq!( - status.next_action, - TradeStatusNextActionKind::AwaitRhiValidation - ); - assert!(status.evidence.has_request); - assert!(!status.evidence.has_decision); - assert!(status.evidence.has_agreement); - assert!(!status.evidence.has_pending_revision); - assert!(!status.evidence.has_cancellation); - assert!(!status.evidence.has_issues); - assert!(!status.eligibility.can_decide); - assert!(!status.eligibility.can_propose_revision); - assert!(!status.eligibility.can_decide_revision); - assert!(!status.eligibility.can_cancel); - assert!(status.issues.is_empty()); -} - -#[cfg(any())] -#[tokio::test] -async fn order_revision_proposal_status_exposes_pending_and_blocks_follow_on_lifecycle() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let request_event = signed_order_request_event("order-lifecycle-pending-revision", 55); - let request_event_id = RadrootsEventId::parse(request_event.id_str()).expect("request id"); - store - .ingest_event(RadrootsEventIngest::new( - signed_event_from_envelope(request_event.clone()), - 5_500, - )) - .await - .expect("ingest request"); - let proposal = order_revision_proposal( - "order-lifecycle-pending-revision", - &request_event_id, - &request_event_id, - ); - let proposal_receipt = sdk - .trades() - .enqueue_revision_proposal_with_explicit_signer( - TradeRevisionProposalEnqueueRequest::new( - seller_actor(), - request_event_ptr(&request_event), - request_event_ptr(&request_event), - proposal, - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY], NostrRelayUrlPolicy::Public) - .expect("proposal transport targets"), - &FixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue revision proposal"); - - let status = sdk - .trades() - .status(status_request("order-lifecycle-pending-revision")) - .await - .expect("status"); - assert_eq!(status.status, TradeStatusKind::RevisionProposed); - assert_eq!(status.event_count, 2); - assert!(status.agreement_event_id.is_none()); - assert_eq!( - status - .pending_revision_event_id - .as_ref() - .map(RadrootsEventId::as_str), - Some(proposal_receipt.signed_event_id.as_str()) - ); - assert_eq!( - status.last_event_id.as_ref().map(RadrootsEventId::as_str), - Some(proposal_receipt.signed_event_id.as_str()) - ); - assert!(status.issues.is_empty()); - assert!(!status.eligibility.can_decide); - assert!(!status.eligibility.can_propose_revision); - assert!(status.eligibility.can_decide_revision); - assert!(!status.eligibility.can_cancel); - - let decision_error = sdk - .trades() - .enqueue_decision_with_explicit_signer( - TradeDecisionEnqueueRequest::new( - seller_actor(), - request_event_ptr(&request_event), - order_decision("order-lifecycle-pending-revision"), - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY], NostrRelayUrlPolicy::Public) - .expect("decision transport targets"), - &FixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await - .expect_err("pending revision blocks direct decision"); - assert!(matches!( - decision_error, - RadrootsSdkError::InvalidRequest { .. } - )); +} - let blocked_proposal = order_revision_proposal( - "order-lifecycle-pending-revision", - &request_event_id, - &proposal_receipt.signed_event_id, - ); - let proposal_error = sdk - .trades() - .enqueue_revision_proposal_with_explicit_signer( - TradeRevisionProposalEnqueueRequest::new( - seller_actor(), - request_event_ptr(&request_event), - order_event_ptr(&proposal_receipt.signed_event_id), - blocked_proposal, - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY], NostrRelayUrlPolicy::Public) - .expect("blocked proposal transport targets"), - &FixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await - .expect_err("pending revision blocks new proposal"); - assert!(matches!( - proposal_error, - RadrootsSdkError::InvalidRequest { .. } - )); - assert_eq!( - store - .status_summary() - .await - .expect("event store status") - .total_events, - 2 - ); +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()]], + }, + ) } -#[cfg(any())] #[tokio::test] -async fn order_declined_revision_finalizes_declined_negotiation() { +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-lifecycle-declined-revision", 56); + let request_event = signed_order_request_event("order-evidence-ingest", 39); let request_event_id = RadrootsEventId::parse(request_event.id_str()).expect("request id"); - store - .ingest_event(RadrootsEventIngest::new( - signed_event_from_envelope(request_event.clone()), - 5_600, - )) - .await - .expect("ingest request"); - let proposal = order_revision_proposal( - "order-lifecycle-declined-revision", - &request_event_id, - &request_event_id, - ); - let proposal_receipt = sdk - .trades() - .enqueue_revision_proposal_with_explicit_signer( - TradeRevisionProposalEnqueueRequest::new( - seller_actor(), - request_event_ptr(&request_event), - request_event_ptr(&request_event), - proposal.clone(), - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY], NostrRelayUrlPolicy::Public) - .expect("proposal transport targets"), - &FixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue revision proposal"); - let declined_revision = order_revision_decision( - &proposal, - &proposal_receipt.signed_event_id, - RadrootsOrderRevisionOutcome::Declined { - reason: "keep original order".to_owned(), - }, - ); - let declined_revision_receipt = sdk + let decision_event = + signed_order_decision_event("order-evidence-ingest", &request_event_id, 40); + + let request_receipt = sdk .trades() - .enqueue_revision_decision_with_explicit_signer( - TradeRevisionDecisionEnqueueRequest::new( - buyer_actor(), - request_event_ptr(&request_event), - order_event_ptr(&proposal_receipt.signed_event_id), - declined_revision, - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY], NostrRelayUrlPolicy::Public) - .expect("declined revision transport targets"), - &FixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) + .ingest_evidence(TradeEvidenceIngestRequest::new(signed_event_from_envelope( + request_event.clone(), + ))) .await - .expect("enqueue declined revision"); + .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 status = sdk + let decision_receipt = sdk .trades() - .status(status_request("order-lifecycle-declined-revision")) + .ingest_evidence(TradeEvidenceIngestRequest::new(signed_event_from_envelope( + decision_event.clone(), + ))) .await - .expect("status"); - assert_eq!(status.status, TradeStatusKind::Declined); - assert_eq!(status.event_count, 3); - assert!(status.agreement_event_id.is_none()); - assert_eq!( - status - .pending_revision_event_id - .as_ref() - .map(RadrootsEventId::as_str), - Some(proposal_receipt.signed_event_id.as_str()) - ); - assert_eq!( - status.last_event_id.as_ref().map(RadrootsEventId::as_str), - Some(declined_revision_receipt.signed_event_id.as_str()) - ); - assert!(status.lifecycle_terminal); - assert_eq!(status.next_action, TradeStatusNextActionKind::Terminal); - assert!(!status.eligibility.can_decide); - assert!(!status.eligibility.can_propose_revision); - assert!(!status.eligibility.can_decide_revision); - assert!(!status.eligibility.can_cancel); + .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 second_decision = order_revision_decision( - &proposal, - &proposal_receipt.signed_event_id, - RadrootsOrderRevisionOutcome::Accepted, - ); - let second_decision_error = sdk + let duplicate_receipt = sdk .trades() - .enqueue_revision_decision_with_explicit_signer( - TradeRevisionDecisionEnqueueRequest::new( - buyer_actor(), - request_event_ptr(&request_event), - order_event_ptr(&proposal_receipt.signed_event_id), - second_decision, - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY], NostrRelayUrlPolicy::Public) - .expect("second decision transport targets"), - &FixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) + .ingest_evidence(TradeEvidenceIngestRequest::new(signed_event_from_envelope( + decision_event, + ))) .await - .expect_err("second revision decision after decline"); - assert!(matches!( - second_decision_error, - RadrootsSdkError::InvalidRequest { .. } - )); + .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, - 3 + 2 ); -} - -#[cfg(any())] -#[tokio::test] -async fn order_cancel_lifecycle_enqueue_updates_status() { - let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let request_event = signed_order_request_event("order-lifecycle-cancel", 60); - let request_event_id = RadrootsEventId::parse(request_event.id_str()).expect("request id"); - store - .ingest_event(RadrootsEventIngest::new( - signed_event_from_envelope(request_event.clone()), - 6_000, - )) - .await - .expect("ingest request"); - let cancellation_actor = buyer_actor(); - let cancellation_plan = sdk - .trades() - .prepare_cancellation(TradeCancellationPrepareRequest::new( - cancellation_actor.clone(), - request_event_ptr(&request_event), - request_event_ptr(&request_event), - order_cancellation("order-lifecycle-cancel"), - )) - .expect("prepare cancellation"); - let cancellation = sdk - .trades() - .enqueue_prepared_cancellation_with_explicit_signer( - &cancellation_actor, - cancellation_plan, - TargetPolicy::try_nostr_relays([RELAY], NostrRelayUrlPolicy::Public) - .expect("cancellation transport targets"), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - Some( - SdkIdempotencyKey::new("order-lifecycle-cancel").expect("cancellation idempotency"), - ), - &FixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue cancellation"); - assert_eq!(cancellation.root_event_id, request_event_id); - assert_eq!(cancellation.previous_event_id, request_event_id); - assert_eq!( - outbox_operation_kind(&sdk, cancellation.outbox_operation_id).await, - TRADE_CANCELLATION_OPERATION_KIND - ); - assert_eq!( - store - .get_event(cancellation.signed_event_id.as_str()) - .await - .expect("cancellation lookup") - .expect("cancellation") - .kind, - KIND_ORDER_CANCELLATION - ); - let replay = sdk - .trades() - .enqueue_cancellation_with_explicit_signer( - TradeCancellationEnqueueRequest::new( - buyer_actor(), - request_event_ptr(&request_event), - request_event_ptr(&request_event), - order_cancellation("order-lifecycle-cancel"), - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY], NostrRelayUrlPolicy::Public) - .expect("replay transport targets") - .try_with_idempotency_key("order-lifecycle-cancel") - .expect("replay idempotency"), - &FixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) - .await - .expect("replay cancellation"); - assert_eq!(replay.state, SdkMutationState::AlreadyQueued); - assert_eq!(replay.signed_event_id, cancellation.signed_event_id); - assert_eq!(replay.outbox_event_id, cancellation.outbox_event_id); let status = sdk .trades() - .status(status_request("order-lifecycle-cancel")) + .status(status_request("order-evidence-ingest")) .await .expect("status"); - assert_eq!(status.status, TradeStatusKind::Cancelled); + assert_eq!(status.status, TradeStatusKind::AgreedPendingValidation); + assert_eq!(status.event_count, 2); assert_eq!( status - .cancellation_event_id + .decision_event_id .as_ref() .map(RadrootsEventId::as_str), - Some(cancellation.signed_event_id.as_str()) + Some(decision_receipt.event_id.as_str()) ); - assert!(status.lifecycle_terminal); - assert_eq!(status.next_action, TradeStatusNextActionKind::Terminal); - assert!(status.evidence.has_request); - assert!(!status.evidence.has_decision); - assert!(status.evidence.has_cancellation); - assert!(!status.evidence.has_issues); - assert!(!status.eligibility.can_cancel); - assert!(status.issues.is_empty()); } -#[cfg(any())] #[tokio::test] -async fn order_lifecycle_enqueue_rejects_invalid_state_before_mutation() { +async fn order_evidence_ingest_rejects_non_order_events() { let (_tempdir, sdk, store) = directory_sdk_and_store().await; - let request_event = signed_order_request_event("order-lifecycle-invalid", 70); - let request_event_id = RadrootsEventId::parse(request_event.id_str()).expect("request id"); - let missing = sdk + let error = sdk .trades() - .enqueue_revision_proposal_with_explicit_signer( - TradeRevisionProposalEnqueueRequest::new( - seller_actor(), - request_event_ptr(&request_event), - request_event_ptr(&request_event), - order_revision_proposal( - "order-lifecycle-invalid", - &request_event_id, - &request_event_id, - ), - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY], NostrRelayUrlPolicy::Public) - .expect("missing transport targets"), - &FixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) + .ingest_evidence(TradeEvidenceIngestRequest::new(signed_event_from_envelope( + signed_non_order_event(41), + ))) .await - .expect_err("missing local evidence"); - assert!(matches!(missing, RadrootsSdkError::InvalidRequest { .. })); + .expect_err("non order event"); + + assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. })); assert_eq!( store .status_summary() @@ -5969,92 +2992,30 @@ async fn order_lifecycle_enqueue_rejects_invalid_state_before_mutation() { .total_events, 0 ); +} - store - .ingest_event(RadrootsEventIngest::new( - signed_event_from_envelope(request_event.clone()), - 7_000, - )) - .await - .expect("ingest request"); - let decision_receipt = sdk - .trades() - .enqueue_decision_with_explicit_signer( - TradeDecisionEnqueueRequest::new( - seller_actor(), - request_event_ptr(&request_event), - order_decision("order-lifecycle-invalid"), - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY], NostrRelayUrlPolicy::Public) - .expect("decision transport targets"), - &FixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue decision"); - let revision_without_proposal = order_revision_decision( - &order_revision_proposal( - "order-lifecycle-invalid", - &request_event_id, - &decision_receipt.signed_event_id, - ), - &decision_receipt.signed_event_id, - RadrootsOrderRevisionOutcome::Accepted, - ); - let revision_error = sdk - .trades() - .enqueue_revision_decision_with_explicit_signer( - TradeRevisionDecisionEnqueueRequest::new( - buyer_actor(), - request_event_ptr(&request_event), - order_event_ptr(&decision_receipt.signed_event_id), - revision_without_proposal, - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY], NostrRelayUrlPolicy::Public) - .expect("revision decision transport targets"), - &FixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) - .await - .expect_err("revision decision without proposal"); - assert!(matches!( - revision_error, - RadrootsSdkError::InvalidRequest { .. } - )); +#[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 cancellation_error = sdk + let error = sdk .trades() - .enqueue_cancellation_with_explicit_signer( - TradeCancellationEnqueueRequest::new( - buyer_actor(), - request_event_ptr(&request_event), - order_event_ptr(&decision_receipt.signed_event_id), - order_cancellation("order-lifecycle-invalid"), - TargetPolicy::default_profile(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets([RELAY], NostrRelayUrlPolicy::Public) - .expect("cancellation transport targets"), - &FixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) + .ingest_request_evidence(TradeRequestEvidenceIngestRequest::new( + signed_event_from_envelope(decision_event), + )) .await - .expect_err("cancellation after accepted agreement"); - assert!(matches!( - cancellation_error, - RadrootsSdkError::InvalidRequest { .. } - )); + .expect_err("non request event"); + + assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. })); assert_eq!( store .status_summary() .await .expect("event store status") .total_events, - 2 + 0 ); } @@ -6085,8 +3046,6 @@ async fn order_status_returns_not_found_for_missing_local_order() { assert!(!receipt.evidence.has_issues); assert!(!receipt.eligibility.can_decide); assert!(!receipt.eligibility.can_cancel); - assert!(!receipt.eligibility.can_propose_revision); - assert!(!receipt.eligibility.can_decide_revision); assert!(receipt.issues.is_empty()); } @@ -6253,8 +3212,6 @@ async fn order_status_contract_dtos_serialize_deterministically() { 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); - assert_eq!(receipt_json["eligibility"]["can_propose_revision"], false); - assert_eq!(receipt_json["eligibility"]["can_decide_revision"], false); let issue = SdkTradeStatusIssue { kind: SdkTradeStatusIssueKind::DecisionPayloadInvalid, @@ -6409,116 +3366,6 @@ fn order_status_issue_mapping_preserves_kind_codes_and_event_ids() { "conflicting_decisions" ), single_issue!( - RevisionProposalPayloadInvalid, - RevisionProposalPayloadInvalid, - "revision_proposal_payload_invalid" - ), - single_issue!( - RevisionProposalOrderIdMismatch, - RevisionProposalOrderIdMismatch, - "revision_proposal_order_id_mismatch" - ), - single_issue!( - RevisionProposalAuthorMismatch, - RevisionProposalAuthorMismatch, - "revision_proposal_author_mismatch" - ), - single_issue!( - RevisionProposalCounterpartyMismatch, - RevisionProposalCounterpartyMismatch, - "revision_proposal_counterparty_mismatch" - ), - single_issue!( - RevisionProposalBuyerMismatch, - RevisionProposalBuyerMismatch, - "revision_proposal_buyer_mismatch" - ), - single_issue!( - RevisionProposalSellerMismatch, - RevisionProposalSellerMismatch, - "revision_proposal_seller_mismatch" - ), - single_issue!( - RevisionProposalListingAddressInvalid, - RevisionProposalListingAddressInvalid, - "revision_proposal_listing_address_invalid" - ), - single_issue!( - RevisionProposalListingMismatch, - RevisionProposalListingMismatch, - "revision_proposal_listing_mismatch" - ), - single_issue!( - RevisionProposalRootMismatch, - RevisionProposalRootMismatch, - "revision_proposal_root_mismatch" - ), - single_issue!( - RevisionProposalPreviousMismatch, - RevisionProposalPreviousMismatch, - "revision_proposal_previous_mismatch" - ), - single_issue!( - RevisionDecisionWithoutProposal, - RevisionDecisionWithoutProposal, - "revision_decision_without_proposal" - ), - single_issue!( - RevisionDecisionPayloadInvalid, - RevisionDecisionPayloadInvalid, - "revision_decision_payload_invalid" - ), - single_issue!( - RevisionDecisionOrderIdMismatch, - RevisionDecisionOrderIdMismatch, - "revision_decision_order_id_mismatch" - ), - single_issue!( - RevisionDecisionAuthorMismatch, - RevisionDecisionAuthorMismatch, - "revision_decision_author_mismatch" - ), - single_issue!( - RevisionDecisionCounterpartyMismatch, - RevisionDecisionCounterpartyMismatch, - "revision_decision_counterparty_mismatch" - ), - single_issue!( - RevisionDecisionBuyerMismatch, - RevisionDecisionBuyerMismatch, - "revision_decision_buyer_mismatch" - ), - single_issue!( - RevisionDecisionSellerMismatch, - RevisionDecisionSellerMismatch, - "revision_decision_seller_mismatch" - ), - single_issue!( - RevisionDecisionListingAddressInvalid, - RevisionDecisionListingAddressInvalid, - "revision_decision_listing_address_invalid" - ), - single_issue!( - RevisionDecisionListingMismatch, - RevisionDecisionListingMismatch, - "revision_decision_listing_mismatch" - ), - single_issue!( - RevisionDecisionRootMismatch, - RevisionDecisionRootMismatch, - "revision_decision_root_mismatch" - ), - single_issue!( - RevisionDecisionPreviousMismatch, - RevisionDecisionPreviousMismatch, - "revision_decision_previous_mismatch" - ), - single_issue!( - RevisionDecisionRevisionIdMismatch, - RevisionDecisionRevisionIdMismatch, - "revision_decision_revision_id_mismatch" - ), - single_issue!( CancellationWithoutCancellableOrder, CancellationWithoutCancellableOrder, "cancellation_without_cancellable_order" @@ -6684,7 +3531,7 @@ async fn order_status_projects_local_request_and_decision_events() { .collect::<Vec<_>>(), vec![request_event.id_str(), decision_event.id_str()] ); - assert_eq!(receipt.status, TradeStatusKind::AgreedPendingRhi); + assert_eq!(receipt.status, TradeStatusKind::AgreedPendingValidation); assert_eq!( receipt .request_event_id @@ -6717,17 +3564,14 @@ async fn order_status_projects_local_request_and_decision_events() { assert!(!receipt.lifecycle_terminal); assert_eq!( receipt.next_action, - TradeStatusNextActionKind::AwaitRhiValidation + 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_pending_revision); assert!(!receipt.evidence.has_issues); assert!(!receipt.eligibility.can_decide); - assert!(!receipt.eligibility.can_propose_revision); - assert!(!receipt.eligibility.can_decide_revision); assert!(!receipt.eligibility.can_cancel); } diff --git a/crates/sdk/tests/runtime_foundation.rs b/crates/sdk/tests/runtime_foundation.rs @@ -9,9 +9,9 @@ use radroots_sdk::{ SDK_TRANSPORT_TARGET_MAX_COUNT, SdkBackupState, SdkBackupVerification, SdkEventStoreStorageStatus, SdkIdempotencyKey, SdkOutboxStorageStatus, SdkPrivateStoreStorageStatus, SdkRestoreState, SdkSqliteStoreStatus, - SdkSqliteWalCheckpointReceipt, SdkSqliteWalStatus, SdkStorageKind, StorageCheckpointReceipt, - StorageCheckpointRequest, StorageStatusReceipt, StorageStatusRequest, TargetPolicy, TargetSet, - TransportProfile, + SdkSqliteWalCheckpointReceipt, SdkSqliteWalStatus, SdkStorageKind, SdkStudioStoreStorageStatus, + StorageCheckpointReceipt, StorageCheckpointRequest, StorageStatusReceipt, StorageStatusRequest, + TargetPolicy, TargetSet, TransportProfile, }; use radroots_trade::identity::RadrootsTradeLocator; use sqlx::Row; @@ -40,7 +40,6 @@ async fn sdk_builder_defaults_to_memory_storage_and_no_relays() { let _geonames = sdk.geonames(); let _trades = sdk.trades(); let _sync = sdk.sync(); - let _dvm = sdk.dvm(); } #[tokio::test] @@ -156,48 +155,63 @@ async fn sdk_directory_storage_creates_deterministic_sqlite_files() { let paths = sdk.storage_paths().expect("paths"); assert_eq!( - paths.event_store_path, - tempdir - .path() - .join("sdk-runtime") - .join("event_store.sqlite") + paths.runtime_path, + tempdir.path().join("sdk-runtime").join("runtime.sqlite") ); assert_eq!( - paths.outbox_path, - tempdir.path().join("sdk-runtime").join("outbox.sqlite") + paths.private_path, + tempdir.path().join("sdk-runtime").join("private.sqlite") ); assert_eq!( - paths.private_store_path, - tempdir.path().join("sdk-runtime").join("private.sqlite") + paths.studio_path, + tempdir.path().join("sdk-runtime").join("studio.sqlite") ); - assert!(paths.event_store_path.exists()); - assert!(paths.outbox_path.exists()); - assert!(paths.private_store_path.exists()); - let event_tables = sqlite_table_names(&paths.event_store_path).await; - assert!(event_tables.iter().any(|name| name == "event_envelopes")); + assert!(paths.runtime_path.exists()); + assert!(paths.private_path.exists()); + assert!(paths.studio_path.exists()); + let runtime_tables = sqlite_table_names(&paths.runtime_path).await; + assert!(runtime_tables.iter().any(|name| name == "event_envelopes")); assert!( - event_tables + runtime_tables .iter() .any(|name| name == "event_envelope_tags") ); - assert!(event_tables.iter().any(|name| name == "listing_projection")); - assert!(event_tables.iter().any(|name| name == "trade_projection")); - assert!(event_tables.iter().any(|name| name == "listing_search_fts")); - assert!(!event_tables.iter().any(|name| name == "nostr_event")); - assert!(!event_tables.iter().any(|name| name == "nostr_event_tag")); + assert!( + runtime_tables + .iter() + .any(|name| name == "listing_projection") + ); + assert!(runtime_tables.iter().any(|name| name == "trade_projection")); + assert!( + runtime_tables + .iter() + .any(|name| name == "listing_search_fts") + ); + assert!( + runtime_tables + .iter() + .any(|name| name == "outbox_operations") + ); + assert!( + runtime_tables + .iter() + .any(|name| name == "sdk_runtime_operation_journal") + ); + 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.event_store_path).await, + sqlite_trade_projection_primary_key(&paths.runtime_path).await, vec!["order_id", "root_event_id", "projection_version"] ); - let outbox_tables = sqlite_table_names(&paths.outbox_path).await; - assert!(outbox_tables.iter().any(|name| name == "outbox_operations")); - assert!(!outbox_tables.iter().any(|name| name == "outbox_operation")); - let private_tables = sqlite_table_names(&paths.private_store_path).await; + 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") ); + let studio_tables = sqlite_table_names(&paths.studio_path).await; + assert!(studio_tables.iter().any(|name| name == "sdk_studio_state")); } #[tokio::test] @@ -326,25 +340,6 @@ fn sdk_timestamp_rejects_values_outside_nostr_created_at_range() { } #[test] -fn sdk_partial_local_mutation_error_is_sanitized() { - let event_id = "a".repeat(64); - let error = RadrootsSdkError::partial_outbox_enqueue_mutation( - event_id, - LISTING_PUBLISH_OPERATION_KIND, - "abcdef123456", - ); - let message = error.to_string(); - - assert!(message.contains(LISTING_PUBLISH_OPERATION_KIND)); - assert!(message.contains("abcdef123456")); - assert!(message.contains("stored=true")); - assert!(message.contains("queued=false")); - assert!(!message.contains("sig")); - assert!(!message.contains("raw")); - assert!(!message.contains("idempotency-key")); -} - -#[test] fn sdk_error_contract_methods_cover_all_variants() { let cases = vec![ ( @@ -455,9 +450,9 @@ fn sdk_error_contract_methods_cover_all_variants() { ( RadrootsSdkError::TradeAmbiguous { operation: "trade.accept".to_owned(), - locator: RadrootsTradeLocator::from_order_id( + locator: Box::new(RadrootsTradeLocator::from_order_id( RadrootsOrderId::parse("trade-error").expect("order id"), - ), + )), candidates: vec![RadrootsTradeLocator::from_order_id( RadrootsOrderId::parse("trade-error").expect("order id"), )], @@ -536,10 +531,10 @@ fn sdk_error_contract_methods_cover_all_variants() { vec![RadrootsSdkRecoveryAction::FixRequest], ), ( - RadrootsSdkError::ListingDraft { - message: "draft".to_owned(), + RadrootsSdkError::ListingEdit { + message: "edit".to_owned(), }, - "listing_draft", + "listing_edit", RadrootsSdkErrorClass::Request, false, vec![RadrootsSdkRecoveryAction::FixRequest], @@ -590,17 +585,6 @@ fn sdk_error_contract_methods_cover_all_variants() { true, vec![RadrootsSdkRecoveryAction::InspectLocalStores], ), - ( - RadrootsSdkError::partial_outbox_enqueue_mutation( - "a".repeat(64), - LISTING_PUBLISH_OPERATION_KIND, - "abcdef123456", - ), - "partial_local_mutation", - RadrootsSdkErrorClass::LocalMutation, - true, - vec![RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey], - ), ]; for (error, code, class, retryable, recovery_actions) in cases { @@ -702,14 +686,14 @@ fn relay_target_set_validates_normalizes_preserves_order_and_caps() { #[test] fn idempotency_key_validation_is_bounded_and_debug_redacted() { - let key = SdkIdempotencyKey::new("idem-a").expect("key"); - assert_eq!(key.as_str(), "idem-a"); + let key = SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-00000000022d").expect("key"); + assert_eq!(key.as_str(), "01890f0e-6c00-7000-8000-00000000022d"); let debug = format!("{key:?}"); assert!(debug.contains("<redacted>")); - assert!(!debug.contains("idem-a")); + assert!(!debug.contains("01890f0e-6c00-7000-8000-00000000022d")); assert_eq!( serde_json::to_value(&key).expect("key json"), - serde_json::json!({ "value": "<redacted>", "len": 6 }) + serde_json::json!({ "value": "<redacted>", "len": 36 }) ); assert!(matches!( @@ -722,7 +706,11 @@ fn idempotency_key_validation_is_bounded_and_debug_redacted() { RadrootsSdkError::InvalidRequest { ref message } if message == "idempotency key must not include boundary whitespace" )); - assert!(!untrimmed.to_string().contains("idem-a")); + assert!( + !untrimmed + .to_string() + .contains("01890f0e-6c00-7000-8000-00000000022d") + ); assert!(matches!( SdkIdempotencyKey::new("idem\nbad"), Err(RadrootsSdkError::InvalidRequest { .. }) @@ -789,6 +777,10 @@ fn storage_backup_and_integrity_contract_dtos_serialize() { store: private_store, farm_private_locations: 4, }, + studio_store: SdkStudioStoreStorageStatus { + store: store.clone(), + studio_state_records: 5, + }, }) .expect("status receipt"), serde_json::json!({ @@ -849,6 +841,20 @@ fn storage_backup_and_integrity_contract_dtos_serialize() { "integrity_result": "ok" }, "farm_private_locations": 4 + }, + "studio_store": { + "store": { + "schema_version": 1, + "journal_mode": "wal", + "foreign_keys_enabled": true, + "busy_timeout_ms": 5000, + "wal_status": { + "wal_enabled": true + }, + "integrity_ok": true, + "integrity_result": "ok" + }, + "studio_state_records": 5 } }) ); @@ -862,7 +868,8 @@ fn storage_backup_and_integrity_contract_dtos_serialize() { paths: None, event_store: checkpoint.clone(), outbox: checkpoint.clone(), - private_store: checkpoint, + private_store: checkpoint.clone(), + studio_store: checkpoint, }) .expect("checkpoint receipt"), serde_json::json!({ @@ -888,6 +895,13 @@ fn storage_backup_and_integrity_contract_dtos_serialize() { "log_frame_count": 8, "checkpointed_frame_count": 8, "checkpoint_complete": true + }, + "studio_store": { + "wal_enabled": true, + "busy": 0, + "log_frame_count": 8, + "checkpointed_frame_count": 8, + "checkpoint_complete": true } }) ); @@ -926,18 +940,22 @@ fn storage_backup_and_integrity_contract_dtos_serialize() { event_store_ok: true, outbox_ok: true, private_store_ok: true, + studio_store_ok: true, event_store_events: 2, outbox_events: 3, private_farm_locations: 4, + studio_state_records: 5, }) .expect("backup verification"), serde_json::json!({ "event_store_ok": true, "outbox_ok": true, "private_store_ok": true, + "studio_store_ok": true, "event_store_events": 2, "outbox_events": 3, - "private_farm_locations": 4 + "private_farm_locations": 4, + "studio_state_records": 5 }) ); assert_eq!( diff --git a/crates/sdk/tests/source_boundary.rs b/crates/sdk/tests/source_boundary.rs @@ -74,7 +74,11 @@ const FORBIDDEN_SDK_README_CONCEPTS: &[ForbiddenSdkConcept] = &[ }, ForbiddenSdkConcept { pattern: "sdk.trade_validation()", - reason: "SDK docs must use sdk.dvm() for validation receipt ingestion", + 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", @@ -249,7 +253,6 @@ const REQUIRED_SDK_README_CONCEPTS: &[&str] = &[ "sdk.trades().status(...)", "sdk.trades().resync()", "sdk.trades().validation_receipts()", - "sdk.dvm()", "event-envelope timestamp", ]; @@ -295,8 +298,6 @@ const REQUIRED_TRADE_RUNTIME_EXPORTS: &[&str] = &[ "TradeValidationReceiptTags", "TradeValidationReceiptVerifyRequest", "TradeValidationTrustDecision", - "TradeValidationReceiptWorkerEvidence", - "TradeValidationReceiptWorkerEvidenceSelection", "SdkTradeStatusIssue", "SdkTradeStatusIssueKind", "SdkTradeStatusSource", @@ -305,8 +306,6 @@ const REQUIRED_TRADE_RUNTIME_EXPORTS: &[&str] = &[ const REQUIRED_TRADE_SIGNER_EXPORTS: &[&str] = &[ "TRADE_CANCELLATION_OPERATION_KIND", "TRADE_DECISION_OPERATION_KIND", - "TRADE_REVISION_DECISION_OPERATION_KIND", - "TRADE_REVISION_PROPOSAL_OPERATION_KIND", "TRADE_SUBMIT_OPERATION_KIND", "TradeAcceptRequest", "TradeCancelRequest", @@ -318,12 +317,6 @@ const REQUIRED_TRADE_SIGNER_EXPORTS: &[&str] = &[ "TradeEvidenceMode", "TradeMutationOutcome", "TradeProposeRequest", - "TradeRevisionDecisionPlan", - "TradeRevisionDecisionReceipt", - "TradeRevisionDecisionRequest", - "TradeRevisionProposalPlan", - "TradeRevisionProposalRequest", - "TradeRevisionProposalReceipt", "TradeSubmitPlan", "TradeSubmitReceipt", "TradeWorkflowEnqueueReceipt", @@ -333,20 +326,6 @@ const REQUIRED_TRADE_SIGNER_EXPORTS: &[&str] = &[ "TradeWorkflowRetryAdvice", ]; -const REQUIRED_DVM_RUNTIME_EXPORTS: &[&str] = &[ - "DVM_TRADE_TRANSITION_PROOF_REQUEST_CONTRACT_ID", - "DVM_TRADE_TRANSITION_PROOF_REQUEST_OPERATION_KIND", - "DvmProofMode", - "DvmTradeTransitionProofEnqueueRequest", - "DvmTradeTransitionProofPlan", - "DvmTradeTransitionProofPrepareRequest", - "DvmTradeTransitionProofReceipt", - "DvmTradeTransitionProofRequestPayload", - "DvmValidationReceiptIngestReceipt", - "DvmValidationReceiptIngestRequest", - "RadrootsTradeInventoryBinWitnessDto", -]; - const REQUIRED_IDENTITY_MODEL_EXPORTS: &[&str] = &[ "DEFAULT_IDENTITY_PATH", "IdentityError", @@ -401,15 +380,6 @@ const REQUIRED_TRADES_CLIENT_METHODS: &[&str] = &[ "pub async fn status(", ]; -const REQUIRED_DVM_CLIENT_METHODS: &[&str] = &[ - "pub fn prepare_trade_transition_proof_request(", - "pub async fn enqueue_trade_transition_proof_request_with_explicit_signer(", - "pub async fn ingest_validation_receipt(", -]; - -const REQUIRED_DVM_CLIENT_CONFIGURED_SIGNER_METHODS: &[&str] = - &["pub async fn enqueue_trade_transition_proof_request("]; - const FORBIDDEN_TRADES_CLIENT_PUBLIC_METHODS: &[&str] = &[ "pub fn prepare_submit(", "pub async fn enqueue_submit(", @@ -438,18 +408,13 @@ const FORBIDDEN_TRADES_CLIENT_PUBLIC_METHODS: &[&str] = &[ "pub async fn enqueue_prepared_cancellation_with_explicit_signer(", ]; -const REQUIRED_TRADE_BUYER_CLIENT_METHODS: &[&str] = &[ - "pub async fn propose_trade(", - "pub async fn cancel_trade(", - "pub async fn accept_revision(", - "pub async fn decline_revision(", -]; +const REQUIRED_TRADE_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(", - "pub async fn propose_revision(", ]; const REQUIRED_TRADE_RESYNC_CLIENT_METHODS: &[&str] = &["pub async fn resync("]; @@ -461,6 +426,7 @@ const REQUIRED_TRADE_VALIDATION_RECEIPTS_CLIENT_METHODS: &[&str] = &[ ]; const FORBIDDEN_PRODUCT_CLIENT_HANDLES: &[&str] = &[ + "DvmClient", "TradeStatusClient", "TradeValidationClient", "pub struct TradeStatusClient", @@ -1020,15 +986,12 @@ fn private_protocol_helper_modules_are_runtime_gated_by_lib() { for helper in [ "build_order_request_draft", "build_order_decision_draft", - "build_order_revision_proposal_draft", - "build_order_revision_decision_draft", "build_order_cancellation_draft", ] { - let helper_gate = - format!("#[cfg(any(feature = \"signer-adapters\", test))]\npub fn {helper}("); + 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 and unit tests" + "src/order.rs must keep `{helper}` available only for signer adapters" ); } } @@ -1265,8 +1228,6 @@ fn order_runtime_mutation_requests_require_evidence_mode() { "TradeAcceptRequest", "TradeDeclineRequest", "TradeCancelRequest", - "TradeRevisionProposalRequest", - "TradeRevisionDecisionRequest", ] { let struct_block = struct_block(source.as_str(), request); assert!( @@ -1278,8 +1239,6 @@ fn order_runtime_mutation_requests_require_evidence_mode() { "impl TradeAcceptRequest", "impl TradeDeclineRequest", "impl TradeCancelRequest", - "impl TradeRevisionProposalRequest", - "impl TradeRevisionDecisionRequest", ] { let impl_source = impl_block(source.as_str(), constructor); assert!( @@ -1290,34 +1249,28 @@ fn order_runtime_mutation_requests_require_evidence_mode() { } #[test] -fn dvm_runtime_public_exports_are_explicit() { - let source = read_source( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/lib.rs") - .as_path(), - ); +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!( - source.contains("mod dvm_runtime;"), - "src/lib.rs must keep dvm_runtime as an internal implementation module" - ); - assert!( - source.contains("pub use crate::dvm_runtime::{"), - "src/lib.rs must explicitly re-export approved DVM runtime types" + !manifest_dir.join("src/dvm_runtime.rs").exists(), + "src/dvm_runtime.rs must not exist after V1 validation receipt consolidation" ); - assert!( - !source.contains("pub mod dvm_runtime;"), - "src/lib.rs must not expose the dvm_runtime module path" - ); - assert!( - !source.contains("pub use crate::dvm_runtime::*;"), - "src/lib.rs must not wildcard-export the DVM runtime" - ); - - for export in REQUIRED_DVM_RUNTIME_EXPORTS { + 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(export), - "src/lib.rs must explicitly expose DVM SDK runtime export `{export}`" + !source.contains(forbidden), + "src/lib.rs must not expose retired DVM runtime surface `{forbidden}`" ); } } @@ -1489,7 +1442,7 @@ fn trade_product_facade_feature_gates_are_explicit() { seller_impl.contains("pub async fn inbox("), "TradeSellerClient must expose seller inbox in the runtime impl" ); - for method in ["accept_trade", "decline_trade", "propose_revision"] { + for method in ["accept_trade", "decline_trade"] { let gated_method = format!("#[cfg(feature = \"signer-adapters\")]\n pub async fn {method}("); assert!( @@ -1528,29 +1481,24 @@ fn trade_propose_request_stays_product_shaped() { } #[test] -fn dvm_client_surface_is_inventory_guarded() { - let source = read_source( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/dvm_runtime.rs") - .as_path(), - ); +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!( - source.contains("impl<'sdk> DvmClient<'sdk> {"), - "src/dvm_runtime.rs must own DvmClient runtime methods" + !manifest_dir.join("src/dvm_runtime.rs").exists(), + "src/dvm_runtime.rs must not exist after V1 validation receipt consolidation" ); - - for method in REQUIRED_DVM_CLIENT_METHODS { - assert!( - source.contains(method), - "DvmClient must expose inventory-guarded method `{method}`" - ); - } - - for method in REQUIRED_DVM_CLIENT_CONFIGURED_SIGNER_METHODS { + for forbidden in [ + "DvmClient", + "prepare_trade_transition_proof_request", + "enqueue_trade_transition_proof_request", + "ingest_validation_receipt", + ] { assert!( - source.contains(method), - "DvmClient must expose configured-signer method `{method}`" + !lib_source.contains(forbidden) && !clients_source.contains(forbidden), + "SDK product client surface must not expose retired DVM client concept `{forbidden}`" ); } } @@ -1582,7 +1530,6 @@ fn product_clients_remain_thin_sdk_handles() { ); for client in [ - "DvmClient", "FarmsClient", "ListingsClient", "MarketClient", diff --git a/crates/sdk/tests/sync_runtime.rs b/crates/sdk/tests/sync_runtime.rs @@ -550,7 +550,7 @@ async fn enqueue_scoped_duplicate_listing(sdk: &RadrootsClient, d_tag: &str, tit let signed_event = signer .sign_frozen_draft(&plan.frozen_draft) .expect("signed listing"); - let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path) + let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path) .await .expect("outbox"); outbox @@ -570,7 +570,7 @@ async fn enqueue_scoped_duplicate_listing(sdk: &RadrootsClient, d_tag: &str, tit async fn assert_local_import_observation(sdk: &RadrootsClient, outbox_event_id: i64) { let paths = sdk.storage_paths().expect("paths"); - let outbox = RadrootsOutbox::open_file(&paths.outbox_path) + let outbox = RadrootsOutbox::open_file(&paths.runtime_path) .await .expect("outbox"); let stored_event = outbox @@ -578,7 +578,7 @@ async fn assert_local_import_observation(sdk: &RadrootsClient, outbox_event_id: .await .expect("outbox event") .expect("outbox event"); - let event_store = RadrootsEventStore::open_file(&paths.event_store_path) + let event_store = RadrootsEventStore::open_file(&paths.runtime_path) .await .expect("event store"); let observations = event_store @@ -659,6 +659,22 @@ fn rewrite_backup_manifest(source: &Path, mutate: impl FnOnce(&mut serde_json::V .expect("write manifest"); } +fn sync_fixture_idempotency_key(d_tag: &str, title: &str, relays: &[&str]) -> String { + let mut suffix = 0xcbf29ce484222325u64; + for byte in d_tag + .bytes() + .chain(title.bytes()) + .chain(relays.iter().flat_map(|relay| relay.bytes())) + { + suffix ^= u64::from(byte); + suffix = suffix.wrapping_mul(0x100000001b3); + } + format!( + "01890f0e-6c00-7000-8000-{:012x}", + suffix & 0x0000_ffff_ffff_ffff + ) +} + async fn enqueue_listing_with_policy( sdk: &RadrootsClient, d_tag: &str, @@ -674,7 +690,9 @@ async fn enqueue_listing_with_policy( TargetPolicy::default_profile(), ) .try_with_nostr_targets(relays, url_policy) - .expect("relay targets"), + .expect("relay targets") + .try_with_idempotency_key(sync_fixture_idempotency_key(d_tag, title, relays)) + .expect("idempotency key"), &FixtureSigner::new(SELLER), ) .await @@ -934,9 +952,9 @@ async fn sdk_directory_backup_creates_verified_canonical_store_copy() { assert_eq!( integrity.checked_paths, vec![ - source_paths.event_store_path.clone(), - source_paths.outbox_path.clone(), - source_paths.private_store_path.clone() + source_paths.runtime_path.clone(), + source_paths.private_path.clone(), + source_paths.studio_path.clone() ] ); assert!(integrity.event_store_ok); @@ -948,35 +966,29 @@ async fn sdk_directory_backup_creates_verified_canonical_store_copy() { .backup(BackupRequest::new(backup_destination.clone())) .await .expect("backup"); - let event_store_path = backup - .event_store_path - .as_ref() - .expect("event store backup"); - let outbox_path = backup.outbox_path.as_ref().expect("outbox backup"); - let private_store_path = backup - .private_store_path - .as_ref() - .expect("private store backup"); + let runtime_path = backup.runtime_path.as_ref().expect("runtime backup"); + let private_store_path = backup.private_path.as_ref().expect("private store backup"); + let studio_store_path = backup.studio_path.as_ref().expect("studio store backup"); let manifest_path = backup.manifest_path.as_ref().expect("manifest"); - assert!(event_store_path.exists()); - assert!(outbox_path.exists()); + assert!(runtime_path.exists()); assert!(private_store_path.exists()); + assert!(studio_store_path.exists()); assert!(manifest_path.exists()); assert_eq!( backup.manifest.manifest_kind, SdkBackupManifestKind::StorageBackup ); assert_eq!( - backup.manifest.backup_paths.event_store_path, - PathBuf::from("event_store.sqlite") + backup.manifest.backup_paths.runtime_path, + PathBuf::from("runtime.sqlite") ); assert_eq!( - backup.manifest.backup_paths.outbox_path, - PathBuf::from("outbox.sqlite") + backup.manifest.backup_paths.private_path, + PathBuf::from("private.sqlite") ); assert_eq!( - backup.manifest.backup_paths.private_store_path, - PathBuf::from("private.sqlite") + backup.manifest.backup_paths.studio_path, + PathBuf::from("studio.sqlite") ); assert_eq!(backup.manifest.created_at_ms, 1_700_000_000_000); assert_eq!(backup.manifest.source_status.event_store.total_events, 1); @@ -992,10 +1004,12 @@ async fn sdk_directory_backup_creates_verified_canonical_store_copy() { assert!(backup.manifest.backup_verification.event_store_ok); assert!(backup.manifest.backup_verification.outbox_ok); assert!(backup.manifest.backup_verification.private_store_ok); + assert!(backup.manifest.backup_verification.studio_store_ok); assert_eq!( backup.manifest.backup_verification.private_farm_locations, 0 ); + assert_eq!(backup.manifest.backup_verification.studio_state_records, 0); let restore_archive = RadrootsClient::inspect_restore_archive(backup_destination.clone()) .await @@ -1006,24 +1020,26 @@ async fn sdk_directory_backup_creates_verified_canonical_store_copy() { backup.manifest.backup_verification ); assert_eq!( - restore_archive.event_store_path, - event_store_path.canonicalize().expect("event canonical") + restore_archive.runtime_path, + runtime_path.canonicalize().expect("runtime canonical") ); assert_eq!( - restore_archive.outbox_path, - outbox_path.canonicalize().expect("outbox canonical") - ); - assert_eq!( - restore_archive.private_store_path, + restore_archive.private_path, private_store_path .canonicalize() .expect("private store canonical") ); + assert_eq!( + restore_archive.studio_path, + studio_store_path + .canonicalize() + .expect("studio store canonical") + ); - let backup_event_store = RadrootsEventStore::open_file(event_store_path) + let backup_event_store = RadrootsEventStore::open_file(runtime_path) .await .expect("backup event store"); - let backup_outbox = RadrootsOutbox::open_file(outbox_path) + let backup_outbox = RadrootsOutbox::open_file(runtime_path) .await .expect("backup outbox"); assert_eq!( @@ -1092,21 +1108,15 @@ async fn runtime_backup_rejects_empty_destination_and_overwrites_file_destinatio .await .expect("overwrite file backup"); assert!(destination.is_dir()); + assert!(receipt.runtime_path.as_ref().expect("runtime").exists()); assert!( receipt - .event_store_path - .as_ref() - .expect("event store") - .exists() - ); - assert!(receipt.outbox_path.as_ref().expect("outbox").exists()); - assert!( - receipt - .private_store_path + .private_path .as_ref() .expect("private store") .exists() ); + assert!(receipt.studio_path.as_ref().expect("studio store").exists()); } #[cfg(unix)] @@ -1246,7 +1256,7 @@ async fn runtime_restore_rejects_manifest_contract_edges() { let empty_path_source = backup_source(&sdk, tempdir.path(), "backup-empty-path").await; rewrite_backup_manifest(&empty_path_source, |manifest| { - manifest["backup_paths"]["event_store_path"] = serde_json::json!(""); + manifest["backup_paths"]["runtime_path"] = serde_json::json!(""); }); let empty_path_error = RadrootsClient::inspect_restore_archive(empty_path_source) .await @@ -1282,7 +1292,7 @@ async fn sdk_restore_archive_rejects_traversal_backup_paths() { let mut manifest: serde_json::Value = serde_json::from_slice(&std::fs::read(&manifest_path).expect("read manifest")) .expect("manifest json"); - manifest["backup_paths"]["event_store_path"] = serde_json::json!("../event_store.sqlite"); + manifest["backup_paths"]["runtime_path"] = serde_json::json!("../runtime.sqlite"); std::fs::write( &manifest_path, serde_json::to_vec_pretty(&manifest).expect("manifest bytes"), @@ -1303,7 +1313,7 @@ async fn sdk_restore_archive_rejects_corrupt_store() { sdk.backup(BackupRequest::new(source.clone())) .await .expect("backup"); - std::fs::write(source.join("event_store.sqlite"), b"not sqlite").expect("corrupt store"); + std::fs::write(source.join("runtime.sqlite"), b"not sqlite").expect("corrupt store"); let error = RadrootsClient::inspect_restore_archive(source) .await @@ -1323,10 +1333,10 @@ async fn sdk_restore_archive_rejects_symlink_store_member() { sdk.backup(BackupRequest::new(source.clone())) .await .expect("backup"); - let event_store_path = source.join("event_store.sqlite"); - let target = tempdir.path().join("sdk").join("event_store.sqlite"); - std::fs::remove_file(&event_store_path).expect("remove backup event store"); - std::os::unix::fs::symlink(target, &event_store_path).expect("symlink"); + let runtime_path = source.join("runtime.sqlite"); + let target = tempdir.path().join("sdk").join("runtime.sqlite"); + std::fs::remove_file(&runtime_path).expect("remove backup runtime store"); + std::os::unix::fs::symlink(target, &runtime_path).expect("symlink"); let error = RadrootsClient::inspect_restore_archive(source) .await @@ -1385,8 +1395,8 @@ async fn sdk_restore_dry_run_validates_destination_without_writing() { .destination_paths .as_ref() .expect("destination paths") - .event_store_path, - destination.join("event_store.sqlite") + .runtime_path, + destination.join("runtime.sqlite") ); assert!(!destination.exists()); assert_eq!(receipt.restored_paths, None); @@ -1402,7 +1412,7 @@ async fn sdk_restore_dry_run_rejects_existing_destination_without_overwrite() { .await .expect("backup"); std::fs::create_dir(&destination).expect("destination"); - std::fs::write(destination.join("event_store.sqlite"), b"existing").expect("existing file"); + std::fs::write(destination.join("runtime.sqlite"), b"existing").expect("existing file"); let error = RadrootsClient::restore( RestoreRequest::new(source) @@ -1413,7 +1423,7 @@ async fn sdk_restore_dry_run_rejects_existing_destination_without_overwrite() { .expect_err("existing destination"); assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. })); - assert!(destination.join("event_store.sqlite").exists()); + assert!(destination.join("runtime.sqlite").exists()); } #[tokio::test] @@ -1426,7 +1436,7 @@ async fn sdk_restore_dry_run_overwrite_keeps_existing_destination_untouched() { .await .expect("backup"); std::fs::create_dir(&destination).expect("destination"); - std::fs::write(destination.join("event_store.sqlite"), b"existing").expect("existing file"); + std::fs::write(destination.join("runtime.sqlite"), b"existing").expect("existing file"); let receipt = RadrootsClient::restore( RestoreRequest::new(source) @@ -1439,7 +1449,7 @@ async fn sdk_restore_dry_run_overwrite_keeps_existing_destination_untouched() { assert_eq!(receipt.state, SdkRestoreState::DryRun); assert_eq!( - std::fs::read(destination.join("event_store.sqlite")).expect("existing file"), + std::fs::read(destination.join("runtime.sqlite")).expect("existing file"), b"existing" ); } @@ -1474,7 +1484,7 @@ async fn sdk_restore_dry_run_rejects_corrupt_source_without_destination_writes() sdk.backup(BackupRequest::new(source.clone())) .await .expect("backup"); - std::fs::write(source.join("event_store.sqlite"), b"not sqlite").expect("corrupt store"); + std::fs::write(source.join("runtime.sqlite"), b"not sqlite").expect("corrupt store"); let error = RadrootsClient::restore( RestoreRequest::new(source) @@ -1549,7 +1559,7 @@ async fn runtime_restore_handles_existing_file_destinations_by_overwrite_policy( .restored_paths .as_ref() .expect("restored paths") - .event_store_path + .runtime_path .exists() ); } @@ -1576,8 +1586,9 @@ async fn sdk_restore_to_empty_destination_succeeds() { receipt.restored_paths.as_ref(), receipt.destination_paths.as_ref() ); - assert!(destination.join("event_store.sqlite").exists()); - assert!(destination.join("outbox.sqlite").exists()); + assert!(destination.join("runtime.sqlite").exists()); + assert!(destination.join("private.sqlite").exists()); + assert!(destination.join("studio.sqlite").exists()); let restored_sdk = RadrootsClient::builder() .directory_storage(destination) .build() @@ -1666,7 +1677,7 @@ async fn sdk_restore_corrupt_backup_leaves_destination_unchanged() { sdk.backup(BackupRequest::new(source.clone())) .await .expect("backup"); - std::fs::write(source.join("event_store.sqlite"), b"not sqlite").expect("corrupt store"); + std::fs::write(source.join("runtime.sqlite"), b"not sqlite").expect("corrupt store"); std::fs::create_dir(&destination).expect("destination"); std::fs::write(destination.join("sentinel"), b"keep").expect("sentinel"); @@ -1744,13 +1755,15 @@ async fn product_push_outbox_uses_radrootsd_proxy_transport_with_daemon_resolved actor(), listing(LISTING_A_D_TAG, "Proxy Coffee"), TargetPolicy::default_profile(), - ), + ) + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000250") + .expect("idempotency key"), &FixtureSigner::new(SELLER), ) .await .expect("enqueue"); let pre_push_outbox = - RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path) + RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path) .await .expect("pre-push outbox"); let pre_push_stored = pre_push_outbox @@ -1853,12 +1866,14 @@ async fn product_push_outbox_radrootsd_proxy_recovers_expired_publishing_claim_b actor(), listing(LISTING_A_D_TAG, "Recovered Proxy Coffee"), TargetPolicy::default_profile(), - ), + ) + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000251") + .expect("idempotency key"), &FixtureSigner::new(SELLER), ) .await .expect("enqueue"); - let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path) + let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path) .await .expect("outbox"); let stale_claim = outbox @@ -1954,12 +1969,14 @@ async fn product_push_outbox_radrootsd_proxy_idempotency_is_attempt_scoped() { actor(), listing(LISTING_A_D_TAG, "Retry Coffee"), TargetPolicy::default_profile(), - ), + ) + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000252") + .expect("idempotency key"), &FixtureSigner::new(SELLER), ) .await .expect("enqueue"); - let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path) + let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path) .await .expect("outbox"); let plans = outbox @@ -2059,7 +2076,9 @@ async fn product_push_outbox_radrootsd_proxy_error_and_terminal_paths_update_out actor(), listing(LISTING_A_D_TAG, "Proxy Error Coffee"), TargetPolicy::default_profile(), - ), + ) + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000253") + .expect("idempotency key"), &FixtureSigner::new(SELLER), ) .await @@ -2123,7 +2142,9 @@ async fn product_push_outbox_radrootsd_proxy_error_and_terminal_paths_update_out actor(), listing(LISTING_B_D_TAG, "Terminal Coffee"), TargetPolicy::default_profile(), - ), + ) + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000254") + .expect("idempotency key"), &FixtureSigner::new(SELLER), ) .await @@ -2185,7 +2206,7 @@ fn push_outbox_contract_dtos_serialize_deterministically() { retryable_events: 0, terminal_events: 0, events: vec![PushOutboxEventReceipt { - event_id: RadrootsEventId::parse(&"a".repeat(64)).expect("event id"), + event_id: RadrootsEventId::parse("a".repeat(64)).expect("event id"), outbox_event_id: 7, final_state: PushOutboxEventState::Published, attempted_count: 2, @@ -2305,7 +2326,9 @@ async fn sync_runtime_product_push_outbox_reticulum_preview_reports_zero_attempt actor(), listing(LISTING_A_D_TAG, "Reticulum Preview Coffee"), TargetPolicy::default_profile(), - ), + ) + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000255") + .expect("idempotency key"), &FixtureSigner::new(SELLER), ) .await @@ -2495,7 +2518,7 @@ async fn push_outbox_with_transport_uses_queued_targets_without_builder_relays() ); assert_eq!(adapter.captured_raw_events().len(), 1); - let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path) + let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path) .await .expect("outbox"); let stored = outbox @@ -2550,7 +2573,7 @@ async fn push_outbox_with_transport_preserves_scoped_duplicate_target_metadata() && target.target_label.as_deref() == Some("Farm B") })); - let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path) + let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path) .await .expect("outbox"); let stored = outbox @@ -2623,7 +2646,7 @@ async fn push_outbox_adapter_transport_failure_preserves_scoped_target_metadata( && target.target_label.as_deref() == Some("Farm B") })); - let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path) + let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path) .await .expect("outbox"); let stored = outbox @@ -2664,7 +2687,7 @@ async fn push_outbox_with_transport_recovers_expired_publishing_claim_before_sel .expect("sdk"); let outbox_event_id = enqueue_listing(&sdk, LISTING_A_D_TAG, "Recovered Coffee", &[RELAY_A]).await; - let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path) + let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path) .await .expect("outbox"); let stale_claim = outbox @@ -2753,13 +2776,13 @@ async fn push_outbox_with_transport_scopes_duplicate_endpoint_sibling_plans() { ) .try_with_nostr_targets([RELAY_A], NostrRelayUrlPolicy::Public) .expect("first targets") - .try_with_idempotency_key("sdk-duplicate-endpoint-plan") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000022e") .expect("first idempotency"), &FixtureSigner::new(SELLER), ) .await .expect("first enqueue"); - let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path) + let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path) .await .expect("outbox"); let stored_event = outbox @@ -2787,7 +2810,7 @@ async fn push_outbox_with_transport_scopes_duplicate_endpoint_sibling_plans() { 1_700_000_000_000, 1_700_000_000_000, ) - .with_idempotency_key("sdk-duplicate-endpoint-plan"), + .with_idempotency_key("01890f0e-6c00-7000-8000-00000000022e"), ) .await .expect("second plan"); @@ -2933,7 +2956,7 @@ async fn push_outbox_with_transport_can_publish_targeted_ready_event() { assert_eq!(receipt.events[0].outbox_event_id, targeted_outbox_event_id); assert_eq!(adapter.captured_raw_events().len(), 1); - let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path) + let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path) .await .expect("outbox"); let older = outbox @@ -3178,7 +3201,7 @@ async fn push_outbox_continues_after_adapter_transport_failure_and_releases_clai }) ); - let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path) + let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path) .await .expect("outbox"); for outbox_event_id in [first_outbox_event_id, second_outbox_event_id] { @@ -3246,7 +3269,7 @@ async fn push_outbox_returns_fatal_error_for_malformed_signed_event_data() { enqueue_listing(&sdk, LISTING_A_D_TAG, "Corrupt Coffee", &[RELAY_A]).await; let safe_outbox_event_id = enqueue_listing(&sdk, LISTING_B_D_TAG, "Safe Coffee", &[RELAY_B]).await; - let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path) + let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path) .await .expect("outbox"); let changed = @@ -3289,7 +3312,7 @@ async fn push_outbox_does_not_claim_unsigned_outbox_work() { listing(LISTING_C_D_TAG, "Unsigned"), )) .expect("prepared"); - let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path) + let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path) .await .expect("outbox"); let unsigned = outbox diff --git a/crates/sdk/tests/trade_product_publish_runtime.rs b/crates/sdk/tests/trade_product_publish_runtime.rs @@ -216,11 +216,7 @@ fn trade_propose_request( TradeProposeRequest::new( buyer_actor(), listing_event_ptr(), - order.order_id, - order.listing_addr, - order.seller_pubkey, - order.items, - order.economics, + order, explicit_trade_relays(), publish_mode, satisfaction_policy, @@ -255,11 +251,11 @@ async fn trade_product_propose_enqueue_and_publish_uses_ack_policy() { .buyer() .propose_trade( trade_propose_request( - "trade-product-publish", + "01890f0e-6c00-7000-8000-000000000223", PublishMode::EnqueueAndPublish, SatisfactionPolicy::AnyAccepted, ) - .try_with_idempotency_key("trade-product-publish") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000223") .expect("idempotency"), ) .await @@ -270,7 +266,10 @@ async fn trade_product_propose_enqueue_and_publish_uses_ack_policy() { TradeMutationOutcome::Enqueued { .. } => panic!("expected published outcome"), }; - assert_eq!(receipt.order_id.as_str(), "trade-product-publish"); + 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); diff --git a/crates/sdk/tests/unit/actor_json_tests.rs b/crates/sdk/tests/unit/actor_json_tests.rs @@ -2,10 +2,7 @@ use super::{SdkActorContextJson, actor_role_code, actor_source_code}; use radroots_authority::{RadrootsActorContext, RadrootsActorSource}; use radroots_event::contract::RadrootsActorRole; -#[path = "../support/serializer_failure.rs"] -mod serializer_failure; - -use serializer_failure::assert_struct_serialize_error_paths; +use crate::serializer_failure::assert_struct_serialize_error_paths; const PUBKEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; diff --git a/crates/sdk/tests/unit/dvm_runtime_tests.rs b/crates/sdk/tests/unit/dvm_runtime_tests.rs @@ -1,390 +0,0 @@ -use super::{ - DvmProofMode, DvmTradeTransitionProofEnqueueRequest, DvmTradeTransitionProofPrepareRequest, - DvmValidationReceiptIngestRequest, RadrootsTradeInventoryBinWitnessDto, - dvm_trade_transition_proof_plan, sdk_timestamp_ms, -}; -use crate::{ - NostrRelayUrlPolicy, RadrootsSdkError, RadrootsSdkTimestamp, SdkIdempotencyKey, - SyncProjectionRefreshRequest, TargetPolicy, -}; -use radroots_authority::RadrootsActorContext; -use radroots_event::{ - contract::RadrootsActorRole, - draft::RadrootsSignedEvent, - ids::{RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsPublicKey}, - kinds::KIND_TRADE_TRANSITION_PROOF_REQUEST, - wire::RadrootsNip01EventWire, -}; -use radroots_trade::validation_receipt::RadrootsValidationReceiptProofSystem; - -const SERVICE: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; -const BUYER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; -const SELLER: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; -const WORKER: &str = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; -const RELAY: &str = "wss://relay.radroots.test"; - -#[test] -fn trade_transition_proof_plan_builds_microstandard_wire_payload() { - let request = proof_request(service_actor()) - .with_inventory_sequence(7) - .with_previous_state_root(hash32('3')); - let plan = dvm_trade_transition_proof_plan( - request, - RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000), - ) - .expect("plan"); - let payload: serde_json::Value = - serde_json::from_str(plan.frozen_draft.content()).expect("payload json"); - - assert_eq!( - plan.frozen_draft.contract_id(), - super::DVM_TRADE_TRANSITION_PROOF_REQUEST_CONTRACT_ID - ); - assert_eq!( - plan.frozen_draft.kind_u32(), - KIND_TRADE_TRANSITION_PROOF_REQUEST - ); - assert_eq!(plan.frozen_draft.expected_pubkey_str(), SERVICE); - assert_eq!(plan.worker_pubkey.as_str(), WORKER); - assert_eq!(plan.proof_mode, DvmProofMode::None); - assert_eq!( - plan.expected_receipt_proof_system, - RadrootsValidationReceiptProofSystem::None - ); - assert_eq!(payload["proof_mode"], "none"); - assert_eq!(payload["witness_version"], 1); - assert_eq!(payload["proof_target"], "trade.order_acceptance.v1"); - assert_eq!(payload["radroots_protocol_version"], "radroots.trade.v1"); - assert_eq!(payload["inventory_sequence"], 7); - assert_eq!(payload["previous_state_root"], hash32('3')); - assert_eq!(payload["listing_event_id"], event_id('1').as_str()); - let tags = plan.frozen_draft.tags_as_vec(); - assert_eq!(tags[0], vec!["a", listing_addr().as_str()]); - assert_eq!( - tags[1], - vec![ - "i", - event_id('3').as_str(), - "event", - "radroots:order_decision_event" - ] - ); - assert_eq!(tags[2], vec!["p", WORKER]); -} - -#[test] -fn trade_transition_proof_plan_rejects_non_service_actor_before_mutation() { - let error = dvm_trade_transition_proof_plan( - proof_request(buyer_actor()), - RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000), - ) - .expect_err("role error"); - - assert!(matches!(error, RadrootsSdkError::UnauthorizedActor { .. })); -} - -#[test] -fn trade_transition_proof_plan_rejects_inventory_and_sp1_identity_edges() { - let empty_bins = DvmTradeTransitionProofPrepareRequest::new( - service_actor(), - worker_pubkey(), - listing_addr(), - event_id('1'), - event_id('2'), - event_id('3'), - Vec::new(), - ); - assert!(matches!( - dvm_trade_transition_proof_plan( - empty_bins, - RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000), - ), - Err(RadrootsSdkError::InvalidRequest { .. }) - )); - - let over_reserved = DvmTradeTransitionProofPrepareRequest::new( - service_actor(), - worker_pubkey(), - listing_addr(), - event_id('1'), - event_id('2'), - event_id('3'), - vec![RadrootsTradeInventoryBinWitnessDto { - bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"), - listing_capacity: 2, - previous_reserved: 3, - }], - ); - assert!(matches!( - dvm_trade_transition_proof_plan( - over_reserved, - RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000), - ), - Err(RadrootsSdkError::InvalidRequest { .. }) - )); - - let invalid_previous_state_root = - proof_request(service_actor()).with_previous_state_root("not-a-hash"); - assert!(matches!( - dvm_trade_transition_proof_plan( - invalid_previous_state_root, - RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000), - ), - Err(RadrootsSdkError::InvalidRequest { .. }) - )); - - let missing_sp1_identity = proof_request(service_actor()).with_proof_mode(DvmProofMode::Core); - assert!(matches!( - dvm_trade_transition_proof_plan( - missing_sp1_identity, - RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000), - ), - Err(RadrootsSdkError::InvalidRequest { .. }) - )); - - let none_with_sp1_identity = - proof_request(service_actor()).with_sp1_identity(hash32('a'), hash32('b')); - assert!(matches!( - dvm_trade_transition_proof_plan( - none_with_sp1_identity, - RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000), - ), - Err(RadrootsSdkError::InvalidRequest { .. }) - )); - - let invalid_hash = proof_request(service_actor()) - .with_proof_mode(DvmProofMode::Core) - .with_sp1_identity("0xABC", hash32('b')); - assert!(matches!( - dvm_trade_transition_proof_plan( - invalid_hash, - RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000), - ), - Err(RadrootsSdkError::InvalidRequest { .. }) - )); - - let invalid_previous_state = proof_request(service_actor()).with_previous_state_root("0xABC"); - assert!(matches!( - dvm_trade_transition_proof_plan( - invalid_previous_state, - RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000), - ), - Err(RadrootsSdkError::InvalidRequest { .. }) - )); -} - -#[test] -fn proof_modes_map_to_expected_receipt_proof_systems_and_labels() { - let cases = [ - ( - DvmProofMode::None, - "none", - RadrootsValidationReceiptProofSystem::None, - ), - ( - DvmProofMode::Core, - "core", - RadrootsValidationReceiptProofSystem::Sp1Core, - ), - ( - DvmProofMode::Compressed, - "compressed", - RadrootsValidationReceiptProofSystem::Sp1Compressed, - ), - ( - DvmProofMode::Groth16, - "groth16", - RadrootsValidationReceiptProofSystem::Sp1Groth16, - ), - ( - DvmProofMode::Plonk, - "plonk", - RadrootsValidationReceiptProofSystem::Sp1Plonk, - ), - ]; - - for (mode, label, proof_system) in cases { - assert_eq!(mode.as_str(), label); - assert_eq!(mode.proof_system(), proof_system); - } - - let request = proof_request(service_actor()) - .with_proof_mode(DvmProofMode::Compressed) - .with_sp1_identity(hash32('a'), hash32('b')); - let plan = dvm_trade_transition_proof_plan( - request, - RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000), - ) - .expect("sp1 plan"); - - assert_eq!(plan.payload.proof_mode, DvmProofMode::Compressed); - assert_eq!( - plan.expected_receipt_proof_system, - RadrootsValidationReceiptProofSystem::Sp1Compressed - ); -} - -#[test] -fn enqueue_request_builders_and_ingest_request_builders_are_deterministic() { - let prepare = proof_request(service_actor()) - .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_010)); - let idempotency = SdkIdempotencyKey::new("dvm-proof-request").expect("idempotency"); - let idempotency_len = idempotency.as_str().len(); - let request = DvmTradeTransitionProofEnqueueRequest::from_prepare( - prepare.clone(), - TargetPolicy::default_profile(), - ) - .try_with_nostr_targets([RELAY], NostrRelayUrlPolicy::Public) - .expect("relays") - .with_idempotency_key(idempotency) - .with_inventory_sequence(11) - .with_previous_state_root(hash32('1')); - let serialized = serde_json::to_value(&request).expect("request json"); - - assert_eq!(request.prepare.inventory_sequence, 11); - assert_eq!(request.prepare.previous_state_root, Some(hash32('1'))); - assert_eq!(serialized["proof_mode"], "none"); - assert_eq!(serialized["target_policy"]["kind"], "explicit"); - assert_eq!(serialized["idempotency_key"]["value"], "<redacted>"); - assert_eq!(serialized["idempotency_key"]["len"], idempotency_len); - - let request = DvmTradeTransitionProofEnqueueRequest::new( - service_actor(), - worker_pubkey(), - listing_addr(), - event_id('1'), - event_id('2'), - event_id('3'), - inventory_bins(), - TargetPolicy::default_profile(), - ) - .try_with_idempotency_key("dvm-proof-request-2") - .expect("idempotency") - .with_proof_mode(DvmProofMode::Core) - .with_sp1_identity(hash32('a'), hash32('b')) - .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_011)); - assert_eq!(request.prepare.proof_mode, DvmProofMode::Core); - assert_eq!(request.prepare.sp1_program_hash, Some(hash32('a'))); - assert_eq!(request.prepare.sp1_verifying_key_hash, Some(hash32('b'))); - assert!(matches!( - DvmTradeTransitionProofEnqueueRequest::from_prepare( - prepare, - TargetPolicy::default_profile(), - ) - .try_with_nostr_targets(["ws://relay.example.com"], NostrRelayUrlPolicy::Public), - Err(RadrootsSdkError::InvalidRelayUrl { .. }) - )); - assert!(matches!( - DvmTradeTransitionProofEnqueueRequest::from_prepare( - proof_request(service_actor()), - TargetPolicy::default_profile(), - ) - .try_with_idempotency_key(""), - Err(RadrootsSdkError::InvalidRequest { .. }) - )); - - let ingest = DvmValidationReceiptIngestRequest::new(dummy_event()) - .with_projection_refresh(SyncProjectionRefreshRequest::new().with_limit(5)); - assert_eq!(ingest.projection_refresh.limit, 5); -} - -#[test] -fn dvm_timestamp_edges_return_structured_errors() { - let error = dvm_trade_transition_proof_plan( - proof_request(service_actor()), - RadrootsSdkTimestamp::from_unix_seconds(u64::from(u32::MAX) + 1), - ) - .expect_err("nostr timestamp range"); - assert!(matches!( - error, - RadrootsSdkError::TimestampOutOfRange { value } if value == u64::from(u32::MAX) + 1 - )); - - let error = sdk_timestamp_ms(RadrootsSdkTimestamp::from_unix_seconds(u64::MAX)) - .expect_err("millisecond timestamp range"); - assert!(matches!( - error, - RadrootsSdkError::TimestampOutOfRange { value } if value == u64::MAX - )); -} - -#[tokio::test] -async fn dvm_client_prepare_reports_default_clock_errors() { - let sdk = crate::RadrootsClient::builder() - .clock(crate::RadrootsSdkClock::BeforeUnixEpoch) - .build() - .await - .expect("sdk"); - - let error = sdk - .dvm() - .prepare_trade_transition_proof_request(proof_request(service_actor())) - .expect_err("clock error"); - - assert!(matches!(error, RadrootsSdkError::ClockBeforeUnixEpoch)); -} - -fn proof_request(actor: RadrootsActorContext) -> DvmTradeTransitionProofPrepareRequest { - DvmTradeTransitionProofPrepareRequest::new( - actor, - worker_pubkey(), - listing_addr(), - event_id('1'), - event_id('2'), - event_id('3'), - vec![RadrootsTradeInventoryBinWitnessDto { - bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"), - listing_capacity: 5, - previous_reserved: 1, - }], - ) -} - -fn inventory_bins() -> Vec<RadrootsTradeInventoryBinWitnessDto> { - vec![RadrootsTradeInventoryBinWitnessDto { - bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"), - listing_capacity: 5, - previous_reserved: 1, - }] -} - -fn service_actor() -> RadrootsActorContext { - RadrootsActorContext::test(SERVICE, [RadrootsActorRole::Service]).expect("service actor") -} - -fn buyer_actor() -> RadrootsActorContext { - RadrootsActorContext::test(BUYER, [RadrootsActorRole::Buyer]).expect("buyer actor") -} - -fn worker_pubkey() -> RadrootsPublicKey { - RadrootsPublicKey::parse(WORKER).expect("worker pubkey") -} - -fn listing_addr() -> RadrootsListingAddress { - RadrootsListingAddress::parse(format!("30402:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")) - .expect("listing addr") -} - -fn event_id(ch: char) -> RadrootsEventId { - RadrootsEventId::parse(ch.to_string().repeat(64)).expect("event id") -} - -fn hash32(ch: char) -> String { - format!("0x{}", ch.to_string().repeat(64)) -} - -fn dummy_event() -> RadrootsSignedEvent { - let mut wire = RadrootsNip01EventWire { - id: String::new(), - pubkey: event_id('a').into_string(), - created_at: 1, - kind: 3440, - tags: Vec::new(), - content: "{}".to_owned(), - sig: "b".repeat(128), - extra: Default::default(), - }; - wire.id = wire.computed_event_id().expect("event id").into_string(); - let raw_json = serde_json::to_string(&wire).expect("raw event json"); - RadrootsSignedEvent::from_wire_verified_id(wire, raw_json).expect("signed event") -} diff --git a/crates/sdk/tests/unit/error_tests.rs b/crates/sdk/tests/unit/error_tests.rs @@ -1,7 +1,4 @@ -use super::{ - RadrootsSdkError, RadrootsSdkGeoNamesErrorKind, RadrootsSdkPartialLocalMutationError, - RadrootsSdkPartialLocalMutationFailure, RadrootsSdkRecoveryAction, redacted_relay_url, -}; +use super::{RadrootsSdkError, RadrootsSdkGeoNamesErrorKind, redacted_relay_url}; use crate::privacy::{PrivacyPreflightStatus, ProductSensitivityField}; use crate::transport::ReticulumPreviewBehavior; use radroots_authority::RadrootsAuthorityError; @@ -9,64 +6,6 @@ use radroots_event::contract::RadrootsActorRole; use radroots_geocoder::{GeoNamesAssetFetcher, GeoNamesBlockingHttpFetcher, GeocoderError}; #[test] -fn partial_local_mutation_constructor_preserves_supplied_error() { - let error = RadrootsSdkPartialLocalMutationError { - event_id: None, - operation_kind: "listing.publish.v1".to_owned(), - idempotency_digest_prefix: None, - stored: true, - queued: false, - recovery: RadrootsSdkRecoveryAction::RetryOutboxEnqueue, - failure: RadrootsSdkPartialLocalMutationFailure::OutboxEnqueue, - }; - - assert!(matches!( - RadrootsSdkError::partial_local_mutation(error), - RadrootsSdkError::PartialLocalMutation(RadrootsSdkPartialLocalMutationError { - stored: true, - queued: false, - recovery: RadrootsSdkRecoveryAction::RetryOutboxEnqueue, - failure: RadrootsSdkPartialLocalMutationFailure::OutboxEnqueue, - .. - }) - )); - - assert!(matches!( - RadrootsSdkError::partial_outbox_enqueue_mutation( - "a".repeat(64), - "listing.publish.v1", - "digest-prefix", - ), - RadrootsSdkError::PartialLocalMutation(RadrootsSdkPartialLocalMutationError { - event_id: Some(_), - operation_kind, - idempotency_digest_prefix: Some(_), - stored: true, - queued: false, - recovery: RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey, - failure: RadrootsSdkPartialLocalMutationFailure::OutboxEnqueue, - }) if operation_kind == "listing.publish.v1" - )); - - assert!(matches!( - RadrootsSdkError::partial_outbox_idempotency_conflict_mutation( - "a".repeat(64), - "listing.publish.v1", - "digest-prefix", - ), - RadrootsSdkError::PartialLocalMutation(RadrootsSdkPartialLocalMutationError { - event_id: Some(_), - operation_kind, - idempotency_digest_prefix: Some(_), - stored: true, - queued: false, - recovery: RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey, - failure: RadrootsSdkPartialLocalMutationFailure::OutboxIdempotencyConflict, - }) if operation_kind == "listing.publish.v1" - )); -} - -#[test] fn authority_error_conversion_redacts_pubkey_mismatches_and_falls_back() { let actor_error = RadrootsSdkError::from(RadrootsAuthorityError::ActorPubkeyMismatch { expected_pubkey: "a".repeat(64), @@ -90,7 +29,7 @@ fn authority_error_conversion_redacts_pubkey_mismatches_and_falls_back() { #[test] fn listing_and_store_errors_convert_to_sdk_error_classes() { let draft = RadrootsSdkError::from( - radroots_trade::listing::RadrootsListingDraftError::ActorRoleUnsatisfied { + radroots_trade::listing::RadrootsListingEditError::ActorRoleUnsatisfied { required_role: RadrootsActorRole::Seller, }, ); @@ -101,14 +40,14 @@ fn listing_and_store_errors_convert_to_sdk_error_classes() { )); let draft_fallback = RadrootsSdkError::from( - radroots_trade::listing::RadrootsListingDraftError::InvalidFarmPubkey( + radroots_trade::listing::RadrootsListingEditError::InvalidFarmPubkey( radroots_event::ids::RadrootsIdParseError::InvalidCharacter, ), ); assert!(matches!( draft_fallback, - RadrootsSdkError::ListingDraft { ref message } - if message.contains("invalid listing draft farm pubkey") + RadrootsSdkError::ListingEdit { ref message } + if message.contains("invalid listing edit farm pubkey") )); let mutation = RadrootsSdkError::from( @@ -389,8 +328,8 @@ fn sdk_error_contract_methods_cover_representative_classes_and_details() { RadrootsSdkError::InvalidRequest { message: "invalid".to_owned(), }, - RadrootsSdkError::ListingDraft { - message: "draft".to_owned(), + RadrootsSdkError::ListingEdit { + message: "edit".to_owned(), }, RadrootsSdkError::ListingMutation { message: "mutation".to_owned(), @@ -431,11 +370,6 @@ fn sdk_error_contract_methods_cover_representative_classes_and_details() { RadrootsSdkError::Projection { message: "projection".to_owned(), }, - RadrootsSdkError::partial_outbox_idempotency_conflict_mutation( - "a".repeat(64), - "listing.publish.v1", - "digest-prefix", - ), ]; for error in errors { diff --git a/crates/sdk/tests/unit/farms_runtime_tests.rs b/crates/sdk/tests/unit/farms_runtime_tests.rs @@ -3,13 +3,8 @@ use crate::{RadrootsSdkLocalKeySigner, RadrootsSdkSignerProvider}; use radroots_nostr::prelude::RadrootsNostrKeys; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; -#[path = "../support/fixture_signer.rs"] -mod fixture_signer; -#[path = "../support/serializer_failure.rs"] -mod serializer_failure; - -use fixture_signer::FixtureSigner; -use serializer_failure::assert_struct_serialize_error_paths; +use crate::fixture_signer::FixtureSigner; +use crate::serializer_failure::assert_struct_serialize_error_paths; const FARMER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const FARM_A_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; @@ -140,8 +135,7 @@ fn farm_publish_plan_rejects_invalid_draft_tags() { farm, RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000), ) - .err() - .expect("invalid farm plan"); + .expect_err("invalid farm plan"); assert!(matches!( error, RadrootsSdkError::InvalidRequest { message } if message.contains("draft encode failed") @@ -171,24 +165,30 @@ fn farm_runtime_request_builders_and_serializers_cover_success_paths() { ) .try_with_nostr_targets([RELAY_A, RELAY_B], NostrRelayUrlPolicy::Public) .expect("relay targets") - .with_idempotency_key(SdkIdempotencyKey::new("farm-unit-key").expect("key")) + .with_idempotency_key( + SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000233").expect("key"), + ) .with_created_at(created_at); assert_struct_serialize_error_paths(&enqueue, 5); let enqueue_json = serde_json::to_value(&enqueue).expect("enqueue json"); assert_eq!(enqueue_json["target_policy"]["kind"], "explicit"); assert_eq!(enqueue_json["created_at"], 1_700_000_321); - assert!(!enqueue_json.to_string().contains("farm-unit-key")); + assert!( + !enqueue_json + .to_string() + .contains("01890f0e-6c00-7000-8000-000000000233") + ); let try_key = FarmEnqueuePublishRequest::new( farmer_actor(), farm(FARM_C_D_TAG, "Try Key Farm"), TargetPolicy::default_profile(), ) - .try_with_idempotency_key("farm-unit-try-key") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000234") .expect("try key"); assert_eq!( serde_json::to_value(&try_key).expect("try key json")["idempotency_key"]["len"], - "farm-unit-try-key".len() + "01890f0e-6c00-7000-8000-000000000234".len() ); let private_upsert = FarmPrivateLocationUpsertRequest::new( @@ -375,8 +375,7 @@ fn farm_request_builders_reject_invalid_options_and_timestamp_bounds() { farm(FARM_C_D_TAG, "Future Farm"), RadrootsSdkTimestamp::from_unix_seconds(u64::MAX), ) - .err() - .expect("timestamp error"); + .expect_err("timestamp error"); assert!(matches!( timestamp_error, RadrootsSdkError::TimestampOutOfRange { .. } @@ -559,7 +558,7 @@ async fn farm_client_enqueue_methods_cover_source_attached_workflow_paths() { TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) .expect("transport targets"), ) - .try_with_idempotency_key("farm-source-attached-enqueue") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000235") .expect("idempotency"), &signer, ) @@ -582,7 +581,10 @@ async fn farm_client_enqueue_methods_cover_source_attached_workflow_paths() { plan, TargetPolicy::try_nostr_relays([RELAY_B], NostrRelayUrlPolicy::Public) .expect("prepared transport targets"), - None, + Some( + SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000236") + .expect("prepared idempotency"), + ), &signer, ) .await @@ -615,7 +617,7 @@ async fn farm_configured_local_signer_enqueues_publish_without_explicit_signer() TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) .expect("transport targets"), ) - .try_with_idempotency_key("farm-configured-local") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000236") .expect("idempotency"), ) .await diff --git a/crates/sdk/tests/unit/idempotency_tests.rs b/crates/sdk/tests/unit/idempotency_tests.rs @@ -2,10 +2,7 @@ use super::{SdkIdempotencyKey, SdkTradeIdempotencyRecord}; use crate::RadrootsSdkError; use radroots_event::ids::{RadrootsEventId, RadrootsPublicKey}; -#[path = "../support/serializer_failure.rs"] -mod serializer_failure; - -use serializer_failure::assert_struct_serialize_error_paths; +use crate::serializer_failure::assert_struct_serialize_error_paths; #[test] fn empty_key_is_rejected_before_redacted_storage() { @@ -32,17 +29,21 @@ fn empty_key_is_rejected_before_redacted_storage() { } #[test] -fn derived_key_is_deterministic_and_consumable() { - let first = SdkIdempotencyKey::derive("listing.publish.v1", "event-a", "pubkey-a"); - let second = SdkIdempotencyKey::derive("listing.publish.v1", "event-a", "pubkey-a"); +fn explicit_uuid_v7_key_is_accepted_and_other_shapes_are_rejected() { + let key = SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000001").expect("key"); - assert_eq!(first.as_str(), second.as_str()); - assert!(first.into_string().starts_with("listing.publish.v1:")); + assert_eq!(key.as_str(), "01890f0e-6c00-7000-8000-000000000001"); + assert_eq!(key.into_string(), "01890f0e-6c00-7000-8000-000000000001"); + assert!(matches!( + SdkIdempotencyKey::new("01890f0e-6c00-6000-8000-000000000001"), + Err(RadrootsSdkError::InvalidRequest { ref message }) + if message == "idempotency key must be a UUIDv7" + )); } #[test] fn idempotency_key_reports_serializer_failures() { - let key = SdkIdempotencyKey::new("idempotent").expect("key"); + let key = SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000002").expect("key"); assert_struct_serialize_error_paths(&key, 2); } @@ -50,7 +51,8 @@ fn idempotency_key_reports_serializer_failures() { #[test] fn trade_idempotency_record_binds_payload_and_reports_conflicts() { let record = SdkTradeIdempotencyRecord { - idempotency_key: SdkIdempotencyKey::new("trade-idempotent").expect("key"), + idempotency_key: SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000003") + .expect("key"), operation_kind: "trade.submit.v1".to_owned(), actor_pubkey: RadrootsPublicKey::parse( "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", diff --git a/crates/sdk/tests/unit/listings_runtime_tests.rs b/crates/sdk/tests/unit/listings_runtime_tests.rs @@ -13,13 +13,8 @@ use radroots_event::{ }; use radroots_nostr::prelude::RadrootsNostrKeys; -#[path = "../support/fixture_signer.rs"] -mod fixture_signer; -#[path = "../support/serializer_failure.rs"] -mod serializer_failure; - -use fixture_signer::FixtureSigner; -use serializer_failure::assert_struct_serialize_error_paths; +use crate::fixture_signer::FixtureSigner; +use crate::serializer_failure::assert_struct_serialize_error_paths; const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; @@ -103,29 +98,35 @@ fn listing_runtime_request_builders_and_serializers_cover_success_paths() { let enqueue = ListingEnqueuePublishRequest::from_document( actor(), - RadrootsListingDraftDocumentV1::new(listing(LISTING_B_D_TAG, "Queued Greens")), + RadrootsListingEditDocumentV1::new(listing(LISTING_B_D_TAG, "Queued Greens")), TargetPolicy::default_profile(), ) .try_with_nostr_targets([RELAY_A, RELAY_B], NostrRelayUrlPolicy::Public) .expect("relay targets") - .with_idempotency_key(SdkIdempotencyKey::new("listing-unit-key").expect("key")) + .with_idempotency_key( + SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-00000000022f").expect("key"), + ) .with_created_at(created_at); assert_struct_serialize_error_paths(&enqueue, 5); let enqueue_json = serde_json::to_value(&enqueue).expect("enqueue json"); assert_eq!(enqueue_json["target_policy"]["kind"], "explicit"); assert_eq!(enqueue_json["created_at"], 1_700_000_321); - assert!(!enqueue_json.to_string().contains("listing-unit-key")); + assert!( + !enqueue_json + .to_string() + .contains("01890f0e-6c00-7000-8000-00000000022f") + ); let try_key = ListingEnqueuePublishRequest::new( actor(), listing(LISTING_C_D_TAG, "Try Key Greens"), TargetPolicy::default_profile(), ) - .try_with_idempotency_key("listing-unit-try-key") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000230") .expect("try key"); assert_eq!( serde_json::to_value(&try_key).expect("try key json")["idempotency_key"]["len"], - "listing-unit-try-key".len() + "01890f0e-6c00-7000-8000-000000000230".len() ); } @@ -141,11 +142,10 @@ fn listing_request_builders_reject_invalid_options_and_timestamp_bounds() { let timestamp_error = listing_publish_plan( &actor(), - RadrootsListingDraftDocumentV1::new(listing(LISTING_B_D_TAG, "Future Greens")), + RadrootsListingEditDocumentV1::new(listing(LISTING_B_D_TAG, "Future Greens")), RadrootsSdkTimestamp::from_unix_seconds(u64::MAX), ) - .err() - .expect("timestamp error"); + .expect_err("timestamp error"); assert!(matches!( timestamp_error, RadrootsSdkError::TimestampOutOfRange { .. } @@ -159,11 +159,10 @@ fn listing_request_builders_reject_invalid_options_and_timestamp_bounds() { }); let mutation_error = listing_publish_plan( &actor(), - RadrootsListingDraftDocumentV1::new(invalid_resource_area_listing), + RadrootsListingEditDocumentV1::new(invalid_resource_area_listing), RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000), ) - .err() - .expect("mutation error"); + .expect_err("mutation error"); assert!(matches!( mutation_error, RadrootsSdkError::ListingMutation { message } if message.contains("failed to encode") @@ -267,7 +266,7 @@ async fn listing_client_enqueue_methods_cover_source_attached_workflow_paths() { TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) .expect("transport targets"), ) - .try_with_idempotency_key("listing-source-attached-enqueue") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000231") .expect("idempotency"), &signer, ) @@ -280,7 +279,7 @@ async fn listing_client_enqueue_methods_cover_source_attached_workflow_paths() { .listings() .prepare_publish(ListingPreparePublishRequest::from_document( actor.clone(), - RadrootsListingDraftDocumentV1::new(listing(LISTING_B_D_TAG, "Prepared Greens")), + RadrootsListingEditDocumentV1::new(listing(LISTING_B_D_TAG, "Prepared Greens")), )) .expect("prepared listing"); let prepared = sdk @@ -290,7 +289,10 @@ async fn listing_client_enqueue_methods_cover_source_attached_workflow_paths() { plan, TargetPolicy::try_nostr_relays([RELAY_B], NostrRelayUrlPolicy::Public) .expect("prepared transport targets"), - None, + Some( + SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000232") + .expect("prepared idempotency"), + ), &signer, ) .await @@ -323,7 +325,7 @@ async fn listing_configured_local_signer_enqueues_publish_without_explicit_signe TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) .expect("transport targets"), ) - .try_with_idempotency_key("listing-configured-local") + .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000232") .expect("idempotency"), ) .await diff --git a/crates/sdk/tests/unit/orders_runtime_tests.rs b/crates/sdk/tests/unit/orders_runtime_tests.rs @@ -1,4238 +1,29 @@ use super::*; -use crate::{RadrootsClient, RadrootsSdkLocalKeySigner, RadrootsSdkSignerProvider}; -use radroots_authority::{RadrootsSignerError, RadrootsSignerIdentity}; -use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, -}; -use radroots_event::{ - draft::RadrootsSignedEvent, - envelope::RadrootsEventEnvelopeParts, - kinds::KIND_LISTING, - order::{ - RadrootsOrderDecisionOutcome, RadrootsOrderEconomicItem, RadrootsOrderEconomicLine, - RadrootsOrderInventoryCommitment, RadrootsOrderItem, RadrootsOrderPricingBasis, - RadrootsOrderRevisionOutcome, - }, -}; -use radroots_event_store::RadrootsEventStoreError; -use radroots_nostr::prelude::{ - RadrootsNostrKeys, RadrootsNostrSecretKey, radroots_nostr_sign_frozen_draft, -}; -use radroots_trade::{ - identity::RadrootsTradeLocator, - order::{RadrootsOrderEventDecodeError, RadrootsOrderIssue}, - projection::RadrootsTradeProjectionError, - workflow::RadrootsTradeWorkflowState, -}; - -#[path = "../support/serializer_failure.rs"] -mod serializer_failure; - -use serializer_failure::assert_struct_serialize_error_paths; - -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 RELAY: &str = "wss://relay.radroots.test"; - -fn hex_64(character: char) -> String { - core::iter::repeat_n(character, 64).collect() -} - -fn hex_128(character: char) -> String { - core::iter::repeat_n(character, 128).collect() -} - -fn event_id(character: char) -> RadrootsEventId { - RadrootsEventId::parse(hex_64(character)).expect("event id") -} - -fn pubkey(character: char) -> RadrootsPublicKey { - RadrootsPublicKey::parse(hex_64(character)).expect("pubkey") -} - -fn order_id() -> RadrootsOrderId { - RadrootsOrderId::parse("order-test-1").expect("order id") -} - -fn listing_addr(seller_pubkey: &RadrootsPublicKey) -> RadrootsListingAddress { - RadrootsListingAddress::parse(format!( - "30402:{}:AAAAAAAAAAAAAAAAAAAAAg", - seller_pubkey.as_str() - )) - .expect("listing address") -} - -fn projection( - order_id: &RadrootsOrderId, - listing_addr: &RadrootsListingAddress, - buyer_pubkey: &RadrootsPublicKey, - seller_pubkey: &RadrootsPublicKey, - root_event_id: &RadrootsEventId, - previous_event_id: &RadrootsEventId, -) -> RadrootsOrderProjection { - RadrootsOrderProjection { - order_id: order_id.clone(), - status: RadrootsTradeWorkflowState::Requested, - request_event_id: Some(root_event_id.clone()), - decision_event_id: None, - cancellation_event_id: None, - validation_receipt_event_id: None, - lifecycle_terminal: false, - economics: None, - agreement_event_id: None, - pending_revision_event_id: None, - pending_inventory_reservations: Vec::new(), - committed_inventory_reservations: Vec::new(), - listing_addr: Some(listing_addr.clone()), - buyer_pubkey: Some(buyer_pubkey.clone()), - seller_pubkey: Some(seller_pubkey.clone()), - last_event_id: Some(previous_event_id.clone()), - issues: Vec::new(), - } -} - -fn refs<'a>( - 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, -) -> OrderLifecycleReferences<'a> { - OrderLifecycleReferences { - operation: "order test", - order_id, - listing_addr, - buyer_pubkey, - seller_pubkey, - root_event_id, - previous_event_id, - } -} - -fn ptr(id: String) -> RadrootsEventPtr { - RadrootsEventPtr { id, relays: None } -} - -fn nostr_event(id: String, kind: u32) -> RadrootsEventEnvelope { - RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { - id, - author: hex_64('c'), - created_at: 1_700_000_000, - kind, - tags: Vec::new(), - content: "{}".to_owned(), - sig: hex_128('f'), - }) - .expect("event envelope") -} - -fn actor(pubkey: &RadrootsPublicKey, role: RadrootsActorRole) -> RadrootsActorContext { - RadrootsActorContext::test(pubkey.as_str(), [role]).expect("actor") -} - -fn buyer_actor() -> RadrootsActorContext { - actor(&pubkey('c'), RadrootsActorRole::Buyer) -} - -fn seller_actor() -> RadrootsActorContext { - actor(&pubkey('d'), RadrootsActorRole::Seller) -} - -fn decimal(value: &str) -> RadrootsCoreDecimal { - value.parse().expect("decimal") -} - -fn usd(value: &str) -> RadrootsCoreMoney { - RadrootsCoreMoney::new(decimal(value), RadrootsCoreCurrency::USD) -} - -fn economics(bin_count: u32, total: &str) -> 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, - quantity_amount: decimal("1"), - quantity_unit: RadrootsCoreUnit::Each, - unit_price_amount: decimal("5"), - unit_price_currency: RadrootsCoreCurrency::USD, - line_subtotal: usd(total), - }], - discounts: Vec::<RadrootsOrderEconomicLine>::new(), - adjustments: Vec::<RadrootsOrderEconomicLine>::new(), - subtotal: usd(total), - discount_total: usd("0"), - adjustment_total: usd("0"), - total: usd(total), - } -} - -fn order_request_payload() -> RadrootsOrderRequest { - let buyer_pubkey = pubkey('c'); - let seller_pubkey = pubkey('d'); - RadrootsOrderRequest { - order_id: order_id(), - listing_addr: listing_addr(&seller_pubkey), - buyer_pubkey, - seller_pubkey, - items: vec![RadrootsOrderItem { - bin_id: "bin-1".parse().expect("bin id"), - bin_count: 2, - }], - economics: economics(2, "10"), - } -} - -fn order_decision_payload() -> RadrootsOrderDecision { - let buyer_pubkey = pubkey('c'); - let seller_pubkey = pubkey('d'); - RadrootsOrderDecision { - order_id: order_id(), - listing_addr: listing_addr(&seller_pubkey), - buyer_pubkey, - seller_pubkey, - decision: RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: "bin-1".parse().expect("bin id"), - bin_count: 2, - }], - }, - } -} - -fn revision_proposal_payload( - root_event_id: &RadrootsEventId, - previous_event_id: &RadrootsEventId, -) -> RadrootsOrderRevisionProposal { - let buyer_pubkey = pubkey('c'); - let seller_pubkey = pubkey('d'); - RadrootsOrderRevisionProposal { - revision_id: "revision-order-test-1".parse().expect("revision id"), - order_id: order_id(), - listing_addr: listing_addr(&seller_pubkey), - buyer_pubkey, - seller_pubkey, - root_event_id: root_event_id.clone(), - prev_event_id: previous_event_id.clone(), - items: vec![RadrootsOrderItem { - bin_id: "bin-1".parse().expect("bin id"), - bin_count: 3, - }], - economics: economics(3, "15"), - reason: "increase quantity".to_owned(), - } -} - -fn revision_decision_payload( - proposal: &RadrootsOrderRevisionProposal, - previous_event_id: &RadrootsEventId, - decision: RadrootsOrderRevisionOutcome, -) -> RadrootsOrderRevisionDecision { - RadrootsOrderRevisionDecision { - revision_id: proposal.revision_id.clone(), - order_id: proposal.order_id.clone(), - listing_addr: proposal.listing_addr.clone(), - buyer_pubkey: proposal.buyer_pubkey.clone(), - seller_pubkey: proposal.seller_pubkey.clone(), - root_event_id: proposal.root_event_id.clone(), - prev_event_id: previous_event_id.clone(), - decision, - } -} - -fn cancellation_payload() -> RadrootsOrderCancellation { - let buyer_pubkey = pubkey('c'); - let seller_pubkey = pubkey('d'); - RadrootsOrderCancellation { - order_id: order_id(), - listing_addr: listing_addr(&seller_pubkey), - buyer_pubkey, - seller_pubkey, - reason: "buyer changed pickup plan".to_owned(), - } -} - -#[derive(Clone)] -struct OrderFixtureSigner { - identity: RadrootsSignerIdentity, - keys: RadrootsNostrKeys, -} - -impl OrderFixtureSigner { - fn new(secret_key_hex: &str) -> Self { - let keys = keys_from_secret(secret_key_hex); - let pubkey = keys.public_key().to_hex(); - Self { - identity: RadrootsSignerIdentity::new(pubkey).expect("identity"), - keys, - } - } -} - -fn keys_from_secret(secret_key_hex: &str) -> RadrootsNostrKeys { - let secret_key = RadrootsNostrSecretKey::from_hex(secret_key_hex).expect("secret key"); - RadrootsNostrKeys::new(secret_key) -} - -impl RadrootsEventSigner for OrderFixtureSigner { - fn pubkey(&self) -> &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 fixture_buyer_actor() -> RadrootsActorContext { - RadrootsActorContext::test(BUYER_PUBLIC_KEY_HEX, [RadrootsActorRole::Buyer]).expect("actor") -} - -fn fixture_seller_actor() -> RadrootsActorContext { - RadrootsActorContext::test(SELLER_PUBLIC_KEY_HEX, [RadrootsActorRole::Seller]).expect("actor") -} - -fn fixture_listing_addr() -> RadrootsListingAddress { - RadrootsListingAddress::parse(format!( - "{KIND_LISTING}:{SELLER_PUBLIC_KEY_HEX}:AAAAAAAAAAAAAAAAAAAAAg" - )) - .expect("listing address") -} - -fn fixture_event_ptr(character: char) -> RadrootsEventPtr { - RadrootsEventPtr { - id: hex_64(character), - relays: Some(RELAY.to_owned()), - } -} - -fn fixture_order_event_ptr(event_id: &RadrootsEventId) -> RadrootsEventPtr { - RadrootsEventPtr { - id: event_id.as_str().to_owned(), - relays: Some(RELAY.to_owned()), - } -} - -fn fixture_order_id(raw: &str) -> RadrootsOrderId { - RadrootsOrderId::parse(raw).expect("order id") -} - -fn fixture_order_request(raw_order_id: &str) -> RadrootsOrderRequest { - RadrootsOrderRequest { - order_id: fixture_order_id(raw_order_id), - listing_addr: fixture_listing_addr(), - 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(2, "10"), - } -} - -fn fixture_order_decision(raw_order_id: &str) -> RadrootsOrderDecision { - RadrootsOrderDecision { - order_id: fixture_order_id(raw_order_id), - listing_addr: fixture_listing_addr(), - 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, - }], - }, - } -} - -fn fixture_revision_proposal( - raw_order_id: &str, - root_event_id: &RadrootsEventId, - previous_event_id: &RadrootsEventId, -) -> RadrootsOrderRevisionProposal { - RadrootsOrderRevisionProposal { - revision_id: format!("revision-{raw_order_id}") - .parse() - .expect("revision id"), - order_id: fixture_order_id(raw_order_id), - listing_addr: fixture_listing_addr(), - buyer_pubkey: BUYER_PUBLIC_KEY_HEX.parse().expect("buyer pubkey"), - seller_pubkey: SELLER_PUBLIC_KEY_HEX.parse().expect("seller pubkey"), - root_event_id: root_event_id.clone(), - prev_event_id: previous_event_id.clone(), - items: vec![RadrootsOrderItem { - bin_id: "bin-1".parse().expect("bin id"), - bin_count: 3, - }], - economics: economics(3, "15"), - reason: "increase quantity".to_owned(), - } -} - -fn fixture_revision_decision( - proposal: &RadrootsOrderRevisionProposal, - previous_event_id: &RadrootsEventId, -) -> RadrootsOrderRevisionDecision { - RadrootsOrderRevisionDecision { - revision_id: proposal.revision_id.clone(), - order_id: proposal.order_id.clone(), - listing_addr: proposal.listing_addr.clone(), - buyer_pubkey: proposal.buyer_pubkey.clone(), - seller_pubkey: proposal.seller_pubkey.clone(), - root_event_id: proposal.root_event_id.clone(), - prev_event_id: previous_event_id.clone(), - decision: RadrootsOrderRevisionOutcome::Accepted, - } -} - -fn fixture_cancellation(raw_order_id: &str) -> RadrootsOrderCancellation { - RadrootsOrderCancellation { - order_id: fixture_order_id(raw_order_id), - listing_addr: fixture_listing_addr(), - buyer_pubkey: BUYER_PUBLIC_KEY_HEX.parse().expect("buyer pubkey"), - seller_pubkey: SELLER_PUBLIC_KEY_HEX.parse().expect("seller pubkey"), - reason: "buyer changed pickup plan".to_owned(), - } -} - -fn fixture_target_policy() -> TargetPolicy { - TargetPolicy::try_nostr_relays([RELAY], NostrRelayUrlPolicy::Public).expect("transport targets") -} - -async fn prepared_order_sdk() -> RadrootsClient { - RadrootsClient::builder() - .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)) - .build() - .await - .expect("sdk") -} - -async fn configured_order_sdk(secret_key_hex: &str) -> RadrootsClient { - RadrootsClient::builder() - .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)) - .signer_provider(RadrootsSdkSignerProvider::LocalKey( - RadrootsSdkLocalKeySigner::new(keys_from_secret(secret_key_hex)).expect("signer"), - )) - .build() - .await - .expect("sdk") -} - -#[tokio::test] -async fn order_configured_local_signer_enqueues_submit_without_explicit_signer() { - let sdk = configured_order_sdk(BUYER_SECRET_KEY_HEX).await; - - let receipt = sdk - .trades() - .enqueue_submit(TradeSubmitEnqueueRequest::new( - fixture_buyer_actor(), - fixture_event_ptr('a'), - fixture_order_request("order-configured-local-1"), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - )) - .await - .expect("enqueue submit"); - - assert_eq!(receipt.signed_event_id, receipt.expected_event_id); - assert_eq!(receipt.state, SdkMutationState::StoredAndQueued); -} - -#[tokio::test] -async fn order_configured_local_signer_enqueues_lifecycle_wrappers_without_explicit_signers() { - let seller_sdk = configured_order_sdk(SELLER_SECRET_KEY_HEX).await; - let decision_submit = enqueue_fixture_submit(&seller_sdk, "order-configured-decision").await; - let decision = seller_sdk - .trades() - .enqueue_decision(TradeDecisionEnqueueRequest::new( - fixture_seller_actor(), - fixture_order_event_ptr(&decision_submit.signed_event_id), - fixture_order_decision("order-configured-decision"), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - )) - .await - .expect("configured decision"); - assert_eq!(decision.request_event_id, decision_submit.signed_event_id); - assert_eq!(decision.state, SdkMutationState::StoredAndQueued); - - let proposal_submit = enqueue_fixture_submit(&seller_sdk, "order-configured-proposal").await; - let proposal_payload = fixture_revision_proposal( - "order-configured-proposal", - &proposal_submit.signed_event_id, - &proposal_submit.signed_event_id, - ); - let proposal = seller_sdk - .trades() - .enqueue_revision_proposal(TradeRevisionProposalEnqueueRequest::new( - fixture_seller_actor(), - fixture_order_event_ptr(&proposal_submit.signed_event_id), - fixture_order_event_ptr(&proposal_submit.signed_event_id), - proposal_payload, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - )) - .await - .expect("configured revision proposal"); - assert_eq!(proposal.root_event_id, proposal_submit.signed_event_id); - assert_eq!(proposal.state, SdkMutationState::StoredAndQueued); - - let buyer_sdk = configured_order_sdk(BUYER_SECRET_KEY_HEX).await; - let revision_submit = - enqueue_fixture_submit(&buyer_sdk, "order-configured-revision-decision").await; - let revision_proposal_payload = fixture_revision_proposal( - "order-configured-revision-decision", - &revision_submit.signed_event_id, - &revision_submit.signed_event_id, - ); - let revision_proposal = buyer_sdk - .trades() - .enqueue_revision_proposal_with_explicit_signer( - TradeRevisionProposalEnqueueRequest::new( - fixture_seller_actor(), - fixture_order_event_ptr(&revision_submit.signed_event_id), - fixture_order_event_ptr(&revision_submit.signed_event_id), - revision_proposal_payload.clone(), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ), - &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await - .expect("explicit revision proposal"); - let revision_decision_payload = fixture_revision_decision( - &revision_proposal_payload, - &revision_proposal.signed_event_id, - ); - let revision_decision = buyer_sdk - .trades() - .enqueue_revision_decision(TradeRevisionDecisionEnqueueRequest::new( - fixture_buyer_actor(), - fixture_order_event_ptr(&revision_submit.signed_event_id), - fixture_order_event_ptr(&revision_proposal.signed_event_id), - revision_decision_payload, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - )) - .await - .expect("configured revision decision"); - assert_eq!( - revision_decision.root_event_id, - revision_submit.signed_event_id - ); - assert_eq!( - revision_decision.previous_event_id, - revision_proposal.signed_event_id - ); - assert_eq!(revision_decision.state, SdkMutationState::StoredAndQueued); - - let cancel_submit = enqueue_fixture_submit(&buyer_sdk, "order-configured-cancel").await; - let cancellation = buyer_sdk - .trades() - .enqueue_cancellation(TradeCancellationEnqueueRequest::new( - fixture_buyer_actor(), - fixture_order_event_ptr(&cancel_submit.signed_event_id), - fixture_order_event_ptr(&cancel_submit.signed_event_id), - fixture_cancellation("order-configured-cancel"), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - )) - .await - .expect("configured cancellation"); - assert_eq!(cancellation.root_event_id, cancel_submit.signed_event_id); - assert_eq!( - cancellation.previous_event_id, - cancel_submit.signed_event_id - ); - assert_eq!(cancellation.state, SdkMutationState::StoredAndQueued); -} - -async fn enqueue_fixture_submit(sdk: &RadrootsClient, raw_order_id: &str) -> TradeSubmitReceipt { - let buyer = fixture_buyer_actor(); - let plan = sdk - .trades() - .prepare_submit(TradeSubmitPrepareRequest::new( - buyer.clone(), - fixture_event_ptr('a'), - fixture_order_request(raw_order_id), - )) - .expect("submit plan"); - sdk.trades() - .enqueue_prepared_submit_with_explicit_signer( - &buyer, - plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue submit") -} - -fn event_from_parts( - parts: RadrootsNip01EventWireParts, - contract_id: &str, - expected_pubkey: &RadrootsPublicKey, -) -> RadrootsEventEnvelope { - let frozen = RadrootsEventDraft::new( - contract_id, - parts.kind, - 1_700_000_000, - parts.tags, - parts.content, - expected_pubkey.as_str(), - ) - .expect("frozen draft"); - RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { - id: frozen.expected_event_id_str().to_owned(), - author: expected_pubkey.as_str().to_owned(), - created_at: frozen.created_at_u64(), - kind: frozen.kind_u32(), - tags: frozen.tags_as_vec(), - content: frozen.content().to_owned(), - sig: hex_128('f'), - }) - .expect("event envelope") -} - -fn request_event() -> RadrootsEventEnvelope { - let listing_event = ptr(event_id('a').as_str().to_owned()); - let request = order_request_payload(); - event_from_parts( - order::build_order_request_draft(&listing_event, &request) - .expect("request draft") - .into_wire_parts(), - TRADE_SUBMIT_CONTRACT_ID, - &request.buyer_pubkey, - ) -} - -fn event_parts_from(event: &RadrootsEventEnvelope) -> RadrootsEventEnvelopeParts { - RadrootsEventEnvelopeParts { - id: event.id_str().to_owned(), - author: event.author_str().to_owned(), - created_at: event.created_at_u64(), - kind: event.kind_u32(), - tags: event.tags_as_vec(), - content: event.content().to_owned(), - sig: event.sig_str().to_owned(), - } -} - -fn event_with_content(event: &RadrootsEventEnvelope, content: String) -> RadrootsEventEnvelope { - RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { - content, - ..event_parts_from(event) - }) - .expect("event envelope") -} - -fn signed_event_from(event: RadrootsEventEnvelope) -> RadrootsSignedEvent { - let mut wire = event.to_nip01_wire(); - wire.id = wire.computed_event_id().expect("event id").into_string(); - let raw_json = serde_json::to_string(&wire).expect("raw event json"); - RadrootsSignedEvent::from_wire_verified_id(wire, raw_json).expect("signed event") -} - -fn request_signed_event() -> RadrootsSignedEvent { - signed_event_from(request_event()) -} - -fn order_request_evidence_error( - result: Result<TradeRequestEvidence, RadrootsSdkError>, -) -> RadrootsSdkError { - result.err().expect("expected order request evidence error") -} - -fn invalid_request_message(error: RadrootsSdkError) -> String { - match error { - RadrootsSdkError::InvalidRequest { message } => message, - other => panic!("expected invalid request error, got {other:?}"), - } -} - -fn parsed_order_evidence_error( - result: Result<ParsedOrderEvidence, RadrootsSdkError>, -) -> RadrootsSdkError { - result.err().expect("expected parse error") -} - -fn projection_message(error: RadrootsSdkError) -> String { - match error { - RadrootsSdkError::Projection { message } => message, - other => panic!("expected projection error, got {other:?}"), - } -} - -fn assert_error_display<T: core::fmt::Debug>(result: Result<T, RadrootsSdkError>, expected: &str) { - assert!(result.unwrap_err().to_string().contains(expected)); -} - -fn assert_outbox_preflight_error(error: RadrootsSdkError) { - assert!(matches!(error, RadrootsSdkError::Outbox { .. })); -} - -async fn local_event_count(sdk: &RadrootsClient) -> i64 { - sdk._event_store - .status_summary() - .await - .expect("event store summary") - .total_events -} - -#[test] -fn workflow_plan_builders_cover_success_and_actor_mismatch_paths() { - let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000); - let buyer_actor = buyer_actor(); - let seller_actor = seller_actor(); - let listing_event_id = event_id('a'); - let submit_plan = order_submit_plan( - &buyer_actor, - ptr(listing_event_id.as_str().to_owned()), - order_request_payload(), - created_at, - ) - .expect("submit plan"); - assert_eq!(submit_plan.workflow.kind, TradeWorkflowKind::Submit); - assert_eq!(submit_plan.listing_event_id, listing_event_id); - - let request_event = ptr(submit_plan.expected_event_id.as_str().to_owned()); - let decision_plan = order_decision_plan( - &seller_actor, - request_event.clone(), - order_decision_payload(), - created_at, - ) - .expect("decision plan"); - assert_eq!(decision_plan.workflow.kind, TradeWorkflowKind::Decision); - - let proposal = revision_proposal_payload( - &submit_plan.expected_event_id, - &decision_plan.expected_event_id, - ); - let proposal_plan = order_revision_proposal_plan( - &seller_actor, - request_event.clone(), - ptr(decision_plan.expected_event_id.as_str().to_owned()), - proposal.clone(), - created_at, - ) - .expect("revision proposal plan"); - assert_eq!( - proposal_plan.workflow.kind, - TradeWorkflowKind::RevisionProposal - ); - - let revision_decision = revision_decision_payload( - &proposal, - &proposal_plan.expected_event_id, - RadrootsOrderRevisionOutcome::Accepted, - ); - let revision_decision_plan = order_revision_decision_plan( - &buyer_actor, - request_event.clone(), - ptr(proposal_plan.expected_event_id.as_str().to_owned()), - revision_decision, - created_at, - ) - .expect("revision decision plan"); - assert_eq!( - revision_decision_plan.workflow.kind, - TradeWorkflowKind::RevisionDecision - ); - - let cancellation_plan = order_cancellation_plan( - &buyer_actor, - request_event, - ptr(decision_plan.expected_event_id.as_str().to_owned()), - cancellation_payload(), - created_at, - ) - .expect("cancellation plan"); - assert_eq!( - cancellation_plan.workflow.kind, - TradeWorkflowKind::Cancellation - ); - - let mut wrong_submit = order_request_payload(); - wrong_submit.buyer_pubkey = pubkey('e'); - assert!(matches!( - order_submit_plan( - &buyer_actor, - ptr(listing_event_id.as_str().to_owned()), - wrong_submit, - created_at, - ), - Err(RadrootsSdkError::UnauthorizedActor { .. }) - )); - - let mut wrong_proposal = proposal; - wrong_proposal.seller_pubkey = pubkey('e'); - assert!(matches!( - order_revision_proposal_plan( - &seller_actor, - ptr(submit_plan.expected_event_id.as_str().to_owned()), - ptr(decision_plan.expected_event_id.as_str().to_owned()), - wrong_proposal, - created_at, - ), - Err(RadrootsSdkError::UnauthorizedActor { .. }) - )); - - let mut wrong_proposal_refs = revision_proposal_payload( - &submit_plan.expected_event_id, - &decision_plan.expected_event_id, - ); - wrong_proposal_refs.prev_event_id = event_id('f'); - assert!( - invalid_request_message( - order_revision_proposal_plan( - &seller_actor, - ptr(submit_plan.expected_event_id.as_str().to_owned()), - ptr(decision_plan.expected_event_id.as_str().to_owned()), - wrong_proposal_refs, - created_at, - ) - .unwrap_err() - ) - .contains("prev_event_id") - ); - - let mut wrong_revision_decision = revision_decision_payload( - &revision_proposal_payload( - &submit_plan.expected_event_id, - &decision_plan.expected_event_id, - ), - &proposal_plan.expected_event_id, - RadrootsOrderRevisionOutcome::Declined { - reason: "not workable".to_owned(), - }, - ); - wrong_revision_decision.buyer_pubkey = pubkey('e'); - assert!(matches!( - order_revision_decision_plan( - &buyer_actor, - ptr(submit_plan.expected_event_id.as_str().to_owned()), - ptr(proposal_plan.expected_event_id.as_str().to_owned()), - wrong_revision_decision, - created_at, - ), - Err(RadrootsSdkError::UnauthorizedActor { .. }) - )); - - let mut wrong_revision_decision_refs = revision_decision_payload( - &revision_proposal_payload( - &submit_plan.expected_event_id, - &decision_plan.expected_event_id, - ), - &proposal_plan.expected_event_id, - RadrootsOrderRevisionOutcome::Accepted, - ); - wrong_revision_decision_refs.root_event_id = event_id('f'); - assert!( - invalid_request_message( - order_revision_decision_plan( - &buyer_actor, - ptr(submit_plan.expected_event_id.as_str().to_owned()), - ptr(proposal_plan.expected_event_id.as_str().to_owned()), - wrong_revision_decision_refs, - created_at, - ) - .unwrap_err() - ) - .contains("root_event_id") - ); - assert!(matches!( - order_revision_decision_plan( - &buyer_actor, - ptr("not-hex".to_owned()), - ptr(proposal_plan.expected_event_id.as_str().to_owned()), - revision_decision_payload( - &revision_proposal_payload( - &submit_plan.expected_event_id, - &decision_plan.expected_event_id, - ), - &proposal_plan.expected_event_id, - RadrootsOrderRevisionOutcome::Accepted, - ), - created_at, - ), - Err(RadrootsSdkError::InvalidRequest { .. }) - )); - assert!(matches!( - order_revision_decision_plan( - &buyer_actor, - ptr(submit_plan.expected_event_id.as_str().to_owned()), - ptr("not-hex".to_owned()), - revision_decision_payload( - &revision_proposal_payload( - &submit_plan.expected_event_id, - &decision_plan.expected_event_id, - ), - &proposal_plan.expected_event_id, - RadrootsOrderRevisionOutcome::Accepted, - ), - created_at, - ), - Err(RadrootsSdkError::InvalidRequest { .. }) - )); - - let mut wrong_cancellation = cancellation_payload(); - wrong_cancellation.buyer_pubkey = pubkey('e'); - assert!(matches!( - order_cancellation_plan( - &buyer_actor, - ptr(submit_plan.expected_event_id.as_str().to_owned()), - ptr(decision_plan.expected_event_id.as_str().to_owned()), - wrong_cancellation, - created_at, - ), - Err(RadrootsSdkError::UnauthorizedActor { .. }) - )); - assert!(matches!( - order_cancellation_plan( - &buyer_actor, - ptr("not-hex".to_owned()), - ptr(decision_plan.expected_event_id.as_str().to_owned()), - cancellation_payload(), - created_at, - ), - Err(RadrootsSdkError::InvalidRequest { .. }) - )); - assert!(matches!( - order_cancellation_plan( - &buyer_actor, - ptr(submit_plan.expected_event_id.as_str().to_owned()), - ptr("not-hex".to_owned()), - cancellation_payload(), - created_at, - ), - Err(RadrootsSdkError::InvalidRequest { .. }) - )); - - assert!(matches!( - sdk_timestamp_ms(RadrootsSdkTimestamp::from_unix_seconds(u64::MAX)), - Err(RadrootsSdkError::TimestampOutOfRange { .. }) - )); - assert!(matches!( - sdk_timestamp_ms(RadrootsSdkTimestamp::from_unix_seconds( - (i64::MAX as u64 / 1_000) + 1 - )), - Err(RadrootsSdkError::TimestampOutOfRange { .. }) - )); - - let mut invalid_decision = order_decision_payload(); - invalid_decision.decision = RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: Vec::new(), - }; - assert!( - invalid_request_message( - validate_order_payload(&invalid_decision, "order decision").unwrap_err() - ) - .contains("payload is invalid") - ); - assert!(matches!( - order_decision_plan( - &seller_actor, - ptr(submit_plan.expected_event_id.as_str().to_owned()), - invalid_decision, - created_at, - ), - Err(RadrootsSdkError::InvalidRequest { .. }) - )); - - let mut invalid_proposal = revision_proposal_payload( - &submit_plan.expected_event_id, - &decision_plan.expected_event_id, - ); - invalid_proposal.reason.clear(); - assert!( - invalid_request_message( - order_revision_proposal_plan( - &seller_actor, - ptr(submit_plan.expected_event_id.as_str().to_owned()), - ptr(decision_plan.expected_event_id.as_str().to_owned()), - invalid_proposal, - created_at, - ) - .unwrap_err() - ) - .contains("payload is invalid") - ); - - let invalid_revision_decision = revision_decision_payload( - &revision_proposal_payload( - &submit_plan.expected_event_id, - &decision_plan.expected_event_id, - ), - &proposal_plan.expected_event_id, - RadrootsOrderRevisionOutcome::Declined { - reason: " ".to_owned(), - }, - ); - assert!( - invalid_request_message( - order_revision_decision_plan( - &buyer_actor, - ptr(submit_plan.expected_event_id.as_str().to_owned()), - ptr(proposal_plan.expected_event_id.as_str().to_owned()), - invalid_revision_decision, - created_at, - ) - .unwrap_err() - ) - .contains("payload is invalid") - ); - - let mut invalid_cancellation = cancellation_payload(); - invalid_cancellation.reason = " ".to_owned(); - assert!( - invalid_request_message( - order_cancellation_plan( - &buyer_actor, - ptr(submit_plan.expected_event_id.as_str().to_owned()), - ptr(decision_plan.expected_event_id.as_str().to_owned()), - invalid_cancellation, - created_at, - ) - .unwrap_err() - ) - .contains("payload is invalid") - ); - - let validation_error = validate_order_payload( - &RadrootsOrderCancellation { - reason: String::new(), - ..cancellation_payload() - }, - "order cancellation", - ) - .err() - .expect("validation error"); - assert!(invalid_request_message(validation_error).contains("payload is invalid")); - - let valid_submit_draft = order::build_order_request_draft( - &ptr(event_id('d').as_str().to_owned()), - &order_request_payload(), - ) - .expect("valid submit draft"); - let (frozen_draft, expected_event_id) = freeze_order_workflow_draft( - valid_submit_draft.into_wire_parts(), - TRADE_SUBMIT_CONTRACT_ID, - pubkey('c').as_str(), - 1_700_000_000, - "order test", - ); - assert_eq!(expected_event_id, frozen_draft.expected_event_id_str()); -} - -#[test] -fn workflow_plan_builders_cover_timestamp_reference_and_role_errors() { - let out_of_range = RadrootsSdkTimestamp::from_unix_seconds(u64::MAX); - let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000); - let buyer_actor = buyer_actor(); - let seller_actor = seller_actor(); - let root_event_id = event_id('a'); - let previous_event_id = event_id('b'); - - assert!(matches!( - order_submit_plan( - &buyer_actor, - ptr(root_event_id.as_str().to_owned()), - order_request_payload(), - out_of_range, - ), - Err(RadrootsSdkError::TimestampOutOfRange { .. }) - )); - assert!(matches!( - order_decision_plan( - &seller_actor, - ptr(root_event_id.as_str().to_owned()), - order_decision_payload(), - out_of_range, - ), - Err(RadrootsSdkError::TimestampOutOfRange { .. }) - )); - assert!(matches!( - order_revision_proposal_plan( - &seller_actor, - ptr(root_event_id.as_str().to_owned()), - ptr(previous_event_id.as_str().to_owned()), - revision_proposal_payload(&root_event_id, &previous_event_id), - out_of_range, - ), - Err(RadrootsSdkError::TimestampOutOfRange { .. }) - )); - assert!(matches!( - order_revision_decision_plan( - &buyer_actor, - ptr(root_event_id.as_str().to_owned()), - ptr(previous_event_id.as_str().to_owned()), - revision_decision_payload( - &revision_proposal_payload(&root_event_id, &previous_event_id), - &previous_event_id, - RadrootsOrderRevisionOutcome::Accepted, - ), - out_of_range, - ), - Err(RadrootsSdkError::TimestampOutOfRange { .. }) - )); - assert!(matches!( - order_cancellation_plan( - &buyer_actor, - ptr(root_event_id.as_str().to_owned()), - ptr(previous_event_id.as_str().to_owned()), - cancellation_payload(), - out_of_range, - ), - Err(RadrootsSdkError::TimestampOutOfRange { .. }) - )); - - assert!(matches!( - order_decision_plan( - &seller_actor, - ptr("not-hex".to_owned()), - order_decision_payload(), - created_at, - ), - Err(RadrootsSdkError::InvalidRequest { .. }) - )); - assert!(matches!( - order_revision_proposal_plan( - &buyer_actor, - ptr(root_event_id.as_str().to_owned()), - ptr(previous_event_id.as_str().to_owned()), - revision_proposal_payload(&root_event_id, &previous_event_id), - created_at, - ), - Err(RadrootsSdkError::UnauthorizedActor { .. }) - )); - assert!(matches!( - order_revision_proposal_plan( - &seller_actor, - ptr("not-hex".to_owned()), - ptr(previous_event_id.as_str().to_owned()), - revision_proposal_payload(&root_event_id, &previous_event_id), - created_at, - ), - Err(RadrootsSdkError::InvalidRequest { .. }) - )); - assert!(matches!( - order_revision_proposal_plan( - &seller_actor, - ptr(root_event_id.as_str().to_owned()), - ptr("not-hex".to_owned()), - revision_proposal_payload(&root_event_id, &previous_event_id), - created_at, - ), - Err(RadrootsSdkError::InvalidRequest { .. }) - )); - assert!(matches!( - order_revision_decision_plan( - &seller_actor, - ptr(root_event_id.as_str().to_owned()), - ptr(previous_event_id.as_str().to_owned()), - revision_decision_payload( - &revision_proposal_payload(&root_event_id, &previous_event_id), - &previous_event_id, - RadrootsOrderRevisionOutcome::Accepted, - ), - created_at, - ), - Err(RadrootsSdkError::UnauthorizedActor { .. }) - )); - assert!(matches!( - order_cancellation_plan( - &seller_actor, - ptr(root_event_id.as_str().to_owned()), - ptr(previous_event_id.as_str().to_owned()), - cancellation_payload(), - created_at, - ), - Err(RadrootsSdkError::UnauthorizedActor { .. }) - )); -} - -#[test] -fn order_evidence_parses_all_lifecycle_event_kinds() { - let request_event = request_event(); - let request_evidence = parse_order_evidence(&request_event).expect("request evidence"); - assert_eq!(request_evidence.event_kind, KIND_ORDER_REQUEST); - - let root_event_id = request_evidence.event_id.clone(); - let decision = order_decision_payload(); - let decision_event = event_from_parts( - order::build_order_decision_draft(&root_event_id, &root_event_id, &decision) - .expect("decision draft") - .into_wire_parts(), - TRADE_DECISION_CONTRACT_ID, - &decision.seller_pubkey, - ); - assert_eq!( - parse_order_evidence(&decision_event) - .expect("decision evidence") - .event_kind, - KIND_ORDER_DECISION - ); - - let decision_event_id = - RadrootsEventId::parse(decision_event.id_str()).expect("decision event id"); - let proposal = revision_proposal_payload(&root_event_id, &decision_event_id); - let proposal_event = event_from_parts( - order::build_order_revision_proposal_draft(&root_event_id, &decision_event_id, &proposal) - .expect("proposal draft") - .into_wire_parts(), - TRADE_REVISION_PROPOSAL_CONTRACT_ID, - &proposal.seller_pubkey, - ); - assert_eq!( - parse_order_evidence(&proposal_event) - .expect("proposal evidence") - .event_kind, - KIND_ORDER_REVISION_PROPOSAL - ); - - let proposal_event_id = - RadrootsEventId::parse(proposal_event.id_str()).expect("proposal event id"); - let revision_decision = revision_decision_payload( - &proposal, - &proposal_event_id, - RadrootsOrderRevisionOutcome::Accepted, - ); - let revision_decision_event = event_from_parts( - order::build_order_revision_decision_draft( - &root_event_id, - &proposal_event_id, - &revision_decision, - ) - .expect("revision decision draft") - .into_wire_parts(), - TRADE_REVISION_DECISION_CONTRACT_ID, - &revision_decision.buyer_pubkey, - ); - assert_eq!( - parse_order_evidence(&revision_decision_event) - .expect("revision decision evidence") - .event_kind, - KIND_ORDER_REVISION_DECISION - ); - - let cancellation = cancellation_payload(); - let cancellation_event = event_from_parts( - order::build_order_cancellation_draft(&root_event_id, &decision_event_id, &cancellation) - .expect("cancellation draft") - .into_wire_parts(), - TRADE_CANCELLATION_CONTRACT_ID, - &cancellation.buyer_pubkey, - ); - assert_eq!( - parse_order_evidence(&cancellation_event) - .expect("cancellation evidence") - .event_kind, - KIND_ORDER_CANCELLATION - ); -} - -#[test] -fn order_request_evidence_parses_and_rejects_malformed_envelopes() { - let event = request_event(); - let evidence = parse_order_request_evidence(&event).expect("request evidence"); - assert_eq!(evidence.order_id, order_id()); - assert_eq!(evidence.buyer_pubkey, pubkey('c')); - assert_eq!(evidence.seller_pubkey, pubkey('d')); - - let invalid_id = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { - id: "not-hex".to_owned(), - ..event_parts_from(&event) - }) - .expect_err("invalid id"); - assert!( - invalid_id - .to_string() - .contains("event envelope id is invalid") - ); - - let invalid_author = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { - author: "not-hex".to_owned(), - ..event_parts_from(&event) - }) - .expect_err("invalid author"); - assert!( - invalid_author - .to_string() - .contains("event envelope author is invalid") - ); - - let request = order_request_payload(); - let author_mismatch = event_from_parts( - order::build_order_request_draft(&ptr(event_id('a').as_str().to_owned()), &request) - .expect("request draft") - .into_wire_parts(), - TRADE_SUBMIT_CONTRACT_ID, - &pubkey('d'), - ); - assert!( - invalid_request_message(order_request_evidence_error(parse_order_request_evidence( - &author_mismatch, - ))) - .contains("decode failed") - ); - - let decode_failure = event_with_content(&event, "{}".to_owned()); - assert!( - invalid_request_message(order_request_evidence_error(parse_order_request_evidence( - &decode_failure, - ))) - .contains("decode failed") - ); - - let mut envelope = - serde_json::from_str::<serde_json::Value>(event.content()).expect("request envelope"); - envelope["order_id"] = serde_json::Value::String("other-order".to_owned()); - let order_mismatch = event_with_content( - &event, - serde_json::to_string(&envelope).expect("mismatched envelope"), - ); - assert!( - invalid_request_message(order_request_evidence_error(parse_order_request_evidence( - &order_mismatch, - ))) - .contains("decode failed") - ); - - let mut envelope = - serde_json::from_str::<serde_json::Value>(event.content()).expect("request envelope"); - envelope["listing_addr"] = serde_json::Value::String(format!("30402:{}:other", hex_64('d'))); - let listing_mismatch = event_with_content( - &event, - serde_json::to_string(&envelope).expect("mismatched envelope"), - ); - assert!( - invalid_request_message(order_request_evidence_error(parse_order_request_evidence( - &listing_mismatch, - ))) - .contains("decode failed") - ); -} - -#[test] -fn decision_request_evidence_preflight_covers_all_rejection_branches() { - let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000); - let request_event_id = event_id('a'); - let plan = order_decision_plan( - &seller_actor(), - ptr(request_event_id.as_str().to_owned()), - order_decision_payload(), - created_at, - ) - .expect("decision plan"); - let mut projection = projection( - &plan.order_id, - &plan.listing_addr, - &plan.buyer_pubkey, - &plan.seller_pubkey, - &request_event_id, - &request_event_id, - ); - assert!(require_decision_request_evidence(&plan, &projection).is_ok()); - - projection.request_event_id = Some(event_id('b')); - assert!( - invalid_request_message(require_decision_request_evidence(&plan, &projection).unwrap_err()) - .contains("does not match local request") - ); - - projection.request_event_id = Some(request_event_id.clone()); - projection.status = RadrootsTradeWorkflowState::AgreedPendingRhi; - assert!( - invalid_request_message(require_decision_request_evidence(&plan, &projection).unwrap_err()) - .contains("requires requested local state") - ); - - projection.status = RadrootsTradeWorkflowState::Requested; - projection.pending_revision_event_id = Some(event_id('c')); - assert!( - invalid_request_message(require_decision_request_evidence(&plan, &projection).unwrap_err()) - .contains("cannot follow pending revision") - ); - - projection.pending_revision_event_id = None; - projection.issues = vec![RadrootsOrderIssue::MissingRequest]; - assert!( - invalid_request_message(require_decision_request_evidence(&plan, &projection).unwrap_err()) - .contains("reducer issue") - ); - - projection.issues.clear(); - projection.seller_pubkey = None; - assert!( - invalid_request_message(require_decision_request_evidence(&plan, &projection).unwrap_err()) - .contains("missing seller_pubkey") - ); - - projection.seller_pubkey = Some(plan.seller_pubkey.clone()); - projection.listing_addr = Some(listing_addr(&pubkey('e'))); - assert!( - invalid_request_message(require_decision_request_evidence(&plan, &projection).unwrap_err()) - .contains("listing_addr") - ); - - projection.listing_addr = Some(plan.listing_addr.clone()); - projection.buyer_pubkey = Some(pubkey('e')); - assert!( - invalid_request_message(require_decision_request_evidence(&plan, &projection).unwrap_err()) - .contains("buyer_pubkey") - ); -} - -#[test] -fn order_status_next_action_covers_revision_and_fallback_terminal_paths() { - let order_id = order_id(); - let root_event_id = event_id('a'); - let previous_event_id = event_id('b'); - let buyer_pubkey = pubkey('c'); - let seller_pubkey = pubkey('d'); - let listing_addr = listing_addr(&seller_pubkey); - let mut projection = projection( - &order_id, - &listing_addr, - &buyer_pubkey, - &seller_pubkey, - &root_event_id, - &previous_event_id, - ); - projection.status = RadrootsTradeWorkflowState::RevisionProposed; - projection.pending_revision_event_id = Some(previous_event_id.clone()); - - let eligibility = TradeStatusEligibility::from_projection(&projection); - assert!(eligibility.can_decide_revision); - assert_eq!( - TradeStatusNextActionKind::from_projection(&projection, &eligibility), - TradeStatusNextActionKind::DecideRevision - ); - - let receipt = TradeStatusReceipt::from_projection( - RadrootsTradeLocator::from_order_id(order_id.clone()) - .with_root_event_id(root_event_id.clone()), - Some(root_event_id.clone()), - Vec::new(), - projection.clone(), - 2, - 10, - vec![root_event_id.clone(), previous_event_id.clone()], - ); - assert_eq!( - receipt.next_action, - TradeStatusNextActionKind::DecideRevision - ); - assert!(receipt.evidence.has_pending_revision); - - projection.pending_revision_event_id = None; - projection.status = RadrootsTradeWorkflowState::Requested; - let fallback = TradeStatusNextActionKind::from_projection( - &projection, - &TradeStatusEligibility { - can_decide: false, - can_propose_revision: true, - can_decide_revision: false, - can_cancel: true, - }, - ); - assert_eq!(fallback, TradeStatusNextActionKind::Terminal); - - projection.lifecycle_terminal = true; - let terminal_eligibility = TradeStatusEligibility::from_projection(&projection); - assert_eq!( - TradeStatusNextActionKind::from_projection(&projection, &terminal_eligibility), - TradeStatusNextActionKind::Terminal - ); - - projection.lifecycle_terminal = false; - projection.issues = vec![RadrootsOrderIssue::ForkedLifecycle { - event_ids: vec![event_id('e')], - }]; - let issue_eligibility = TradeStatusEligibility::from_projection(&projection); - assert_eq!( - TradeStatusNextActionKind::from_projection(&projection, &issue_eligibility), - TradeStatusNextActionKind::InspectEvidenceIssues - ); -} - -#[test] -fn order_issue_mapping_covers_every_trade_issue_variant() { - let one = event_id('a'); - let two = event_id('b'); - let many = vec![one.clone(), two.clone()]; - - macro_rules! single { - ($issue:ident, $kind:ident) => { - ( - RadrootsOrderIssue::$issue { - event_id: one.clone(), - }, - SdkTradeStatusIssueKind::$kind, - 1, - ) - }; - } - - let cases = vec![ - ( - RadrootsOrderIssue::MissingRequest, - SdkTradeStatusIssueKind::MissingRequest, - 0, - ), - ( - RadrootsOrderIssue::MultipleRequests { - event_ids: many.clone(), - }, - SdkTradeStatusIssueKind::MultipleRequests, - 2, - ), - single!(RequestPayloadInvalid, RequestPayloadInvalid), - single!(RequestOrderIdMismatch, RequestOrderIdMismatch), - single!(RequestAuthorMismatch, RequestAuthorMismatch), - single!(RequestListingAddressInvalid, RequestListingAddressInvalid), - single!(RequestSellerListingMismatch, RequestSellerListingMismatch), - single!(DecisionPayloadInvalid, DecisionPayloadInvalid), - single!(DecisionOrderIdMismatch, DecisionOrderIdMismatch), - single!(DecisionAuthorMismatch, DecisionAuthorMismatch), - single!(DecisionCounterpartyMismatch, DecisionCounterpartyMismatch), - single!(DecisionBuyerMismatch, DecisionBuyerMismatch), - single!(DecisionSellerMismatch, DecisionSellerMismatch), - single!(DecisionListingAddressInvalid, DecisionListingAddressInvalid), - single!(DecisionListingMismatch, DecisionListingMismatch), - single!(DecisionRootMismatch, DecisionRootMismatch), - single!(DecisionPreviousMismatch, DecisionPreviousMismatch), - single!( - DecisionMissingInventoryCommitments, - DecisionMissingInventoryCommitments - ), - single!( - DecisionInventoryCommitmentMismatch, - DecisionInventoryCommitmentMismatch - ), - single!(DecisionMissingReason, DecisionMissingReason), - ( - RadrootsOrderIssue::ConflictingDecisions { - event_ids: many.clone(), - }, - SdkTradeStatusIssueKind::ConflictingDecisions, - 2, - ), - single!( - RevisionProposalPayloadInvalid, - RevisionProposalPayloadInvalid - ), - single!( - RevisionProposalOrderIdMismatch, - RevisionProposalOrderIdMismatch - ), - single!( - RevisionProposalAuthorMismatch, - RevisionProposalAuthorMismatch - ), - single!( - RevisionProposalCounterpartyMismatch, - RevisionProposalCounterpartyMismatch - ), - single!(RevisionProposalBuyerMismatch, RevisionProposalBuyerMismatch), - single!( - RevisionProposalSellerMismatch, - RevisionProposalSellerMismatch - ), - single!( - RevisionProposalListingAddressInvalid, - RevisionProposalListingAddressInvalid - ), - single!( - RevisionProposalListingMismatch, - RevisionProposalListingMismatch - ), - single!(RevisionProposalRootMismatch, RevisionProposalRootMismatch), - single!( - RevisionProposalPreviousMismatch, - RevisionProposalPreviousMismatch - ), - single!( - RevisionDecisionWithoutProposal, - RevisionDecisionWithoutProposal - ), - single!( - RevisionDecisionPayloadInvalid, - RevisionDecisionPayloadInvalid - ), - single!( - RevisionDecisionOrderIdMismatch, - RevisionDecisionOrderIdMismatch - ), - single!( - RevisionDecisionAuthorMismatch, - RevisionDecisionAuthorMismatch - ), - single!( - RevisionDecisionCounterpartyMismatch, - RevisionDecisionCounterpartyMismatch - ), - single!(RevisionDecisionBuyerMismatch, RevisionDecisionBuyerMismatch), - single!( - RevisionDecisionSellerMismatch, - RevisionDecisionSellerMismatch - ), - single!( - RevisionDecisionListingAddressInvalid, - RevisionDecisionListingAddressInvalid - ), - single!( - RevisionDecisionListingMismatch, - RevisionDecisionListingMismatch - ), - single!(RevisionDecisionRootMismatch, RevisionDecisionRootMismatch), - single!( - RevisionDecisionPreviousMismatch, - RevisionDecisionPreviousMismatch - ), - single!( - RevisionDecisionRevisionIdMismatch, - RevisionDecisionRevisionIdMismatch - ), - single!( - CancellationWithoutCancellableOrder, - CancellationWithoutCancellableOrder - ), - single!(CancellationPayloadInvalid, CancellationPayloadInvalid), - single!(CancellationOrderIdMismatch, CancellationOrderIdMismatch), - single!(CancellationAuthorMismatch, CancellationAuthorMismatch), - single!( - CancellationCounterpartyMismatch, - CancellationCounterpartyMismatch - ), - single!(CancellationBuyerMismatch, CancellationBuyerMismatch), - single!(CancellationSellerMismatch, CancellationSellerMismatch), - single!( - CancellationListingAddressInvalid, - CancellationListingAddressInvalid - ), - single!(CancellationListingMismatch, CancellationListingMismatch), - single!(CancellationRootMismatch, CancellationRootMismatch), - single!(CancellationPreviousMismatch, CancellationPreviousMismatch), - ( - RadrootsOrderIssue::ForkedLifecycle { - event_ids: many.clone(), - }, - SdkTradeStatusIssueKind::ForkedLifecycle, - 2, - ), - single!( - ValidationReceiptWithoutPendingAgreement, - ValidationReceiptWithoutPendingAgreement - ), - single!( - ValidationReceiptOrderIdMismatch, - ValidationReceiptOrderIdMismatch - ), - single!(ValidationReceiptTypeMismatch, ValidationReceiptTypeMismatch), - single!(ValidationReceiptRootMismatch, ValidationReceiptRootMismatch), - single!( - ValidationReceiptTargetMismatch, - ValidationReceiptTargetMismatch - ), - single!( - ValidationReceiptListingMismatch, - ValidationReceiptListingMismatch - ), - ( - RadrootsOrderIssue::ConflictingValidationReceipts { - event_ids: many.clone(), - }, - SdkTradeStatusIssueKind::ConflictingValidationReceipts, - 2, - ), - ( - RadrootsOrderIssue::DeterministicValidationFailure { - event_id: one.clone(), - reason: "fixture validation failed".to_owned(), - }, - SdkTradeStatusIssueKind::DeterministicValidationFailure, - 1, - ), - ( - RadrootsOrderIssue::StaleListingEvent { - expected_event_id: one, - current_event_id: two, - }, - SdkTradeStatusIssueKind::StaleListingEvent, - 2, - ), - ]; - - for (issue, expected_kind, expected_event_count) in cases { - let sdk_issue = SdkTradeStatusIssue::from(issue); - assert_eq!(sdk_issue.kind, expected_kind); - assert_eq!(sdk_issue.event_ids.len(), expected_event_count); - assert_eq!(sdk_issue.code(), expected_kind.code()); - } -} - -#[test] -fn lifecycle_projection_helpers_reject_unclean_mismatched_and_stale_state() { - let order_id = order_id(); - let root_event_id = event_id('a'); - let previous_event_id = event_id('b'); - let buyer_pubkey = pubkey('c'); - let seller_pubkey = pubkey('d'); - let listing_addr = listing_addr(&seller_pubkey); - let refs = refs( - &order_id, - &listing_addr, - &buyer_pubkey, - &seller_pubkey, - &root_event_id, - &previous_event_id, - ); - let clean = projection( - &order_id, - &listing_addr, - &buyer_pubkey, - &seller_pubkey, - &root_event_id, - &previous_event_id, - ); - assert!(require_clean_lifecycle_projection(refs, &clean).is_ok()); - assert!(require_lifecycle_status(&refs, &clean, RadrootsTradeWorkflowState::Requested).is_ok()); - assert!(require_no_lifecycle_terminal(&refs, &clean).is_ok()); - assert!(require_no_pending_revision(&refs, &clean).is_ok()); - assert!(require_lifecycle_previous_is_current(&refs, &clean).is_ok()); - - let mut missing_request = clean.clone(); - missing_request.request_event_id = None; - assert!( - invalid_request_message( - require_clean_lifecycle_projection(refs, &missing_request).unwrap_err() - ) - .contains("requires local order request evidence") - ); - - let mut wrong_request = clean.clone(); - wrong_request.request_event_id = Some(event_id('e')); - assert!( - invalid_request_message( - require_clean_lifecycle_projection(refs, &wrong_request).unwrap_err() - ) - .contains("root event") - ); - - let mut issued = clean.clone(); - issued.issues = vec![RadrootsOrderIssue::ForkedLifecycle { - event_ids: vec![event_id('f')], - }]; - assert!( - invalid_request_message(require_clean_lifecycle_projection(refs, &issued).unwrap_err()) - .contains("reducer issue") - ); - - let mut missing_listing = clean.clone(); - missing_listing.listing_addr = None; - assert!( - invalid_request_message( - require_clean_lifecycle_projection(refs, &missing_listing).unwrap_err() - ) - .contains("missing listing_addr") - ); - - let mut wrong_buyer = clean.clone(); - wrong_buyer.buyer_pubkey = Some(pubkey('e')); - assert!( - invalid_request_message( - require_clean_lifecycle_projection(refs, &wrong_buyer).unwrap_err() - ) - .contains("buyer_pubkey") - ); - - let mut accepted = clean.clone(); - accepted.status = RadrootsTradeWorkflowState::AgreedPendingRhi; - assert!( - invalid_request_message( - require_lifecycle_status(&refs, &accepted, RadrootsTradeWorkflowState::Requested) - .unwrap_err() - ) - .contains("requires Requested") - ); - - let mut terminal = clean.clone(); - terminal.lifecycle_terminal = true; - assert!( - invalid_request_message(require_no_lifecycle_terminal(&refs, &terminal).unwrap_err()) - .contains("non-terminal") - ); - - assert!( - invalid_request_message(require_pending_revision(&refs, &clean).unwrap_err()) - .contains("requires pending revision proposal") - ); - - let mut wrong_pending = clean.clone(); - wrong_pending.pending_revision_event_id = Some(event_id('e')); - assert!( - invalid_request_message(require_pending_revision(&refs, &wrong_pending).unwrap_err()) - .contains("does not match pending revision") - ); - - let mut pending = clean.clone(); - pending.pending_revision_event_id = Some(previous_event_id.clone()); - assert!(require_pending_revision(&refs, &pending).is_ok()); - assert!( - invalid_request_message(require_no_pending_revision(&refs, &pending).unwrap_err()) - .contains("cannot follow pending revision") - ); - - let mut wrong_previous = clean.clone(); - wrong_previous.last_event_id = Some(event_id('e')); - assert!( - invalid_request_message( - require_lifecycle_previous_is_current(&refs, &wrong_previous).unwrap_err() - ) - .contains("does not match current lifecycle event") - ); - - let mut missing_previous = clean; - missing_previous.last_event_id = None; - assert!( - invalid_request_message( - require_lifecycle_previous_is_current(&refs, &missing_previous).unwrap_err() - ) - .contains("requires current lifecycle event evidence") - ); -} - -#[test] -fn lifecycle_plan_state_wrappers_reject_invalid_status_terminal_and_pending_state() { - let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000); - let buyer_actor = buyer_actor(); - let seller_actor = seller_actor(); - let listing_event_id = event_id('a'); - let submit_plan = order_submit_plan( - &buyer_actor, - ptr(listing_event_id.as_str().to_owned()), - order_request_payload(), - created_at, - ) - .expect("submit plan"); - let decision_plan = order_decision_plan( - &seller_actor, - ptr(submit_plan.expected_event_id.as_str().to_owned()), - order_decision_payload(), - created_at, - ) - .expect("decision plan"); - let proposal_payload = revision_proposal_payload( - &submit_plan.expected_event_id, - &decision_plan.expected_event_id, - ); - let proposal_plan = order_revision_proposal_plan( - &seller_actor, - ptr(submit_plan.expected_event_id.as_str().to_owned()), - ptr(decision_plan.expected_event_id.as_str().to_owned()), - proposal_payload.clone(), - created_at, - ) - .expect("proposal plan"); - let revision_plan = order_revision_decision_plan( - &buyer_actor, - ptr(submit_plan.expected_event_id.as_str().to_owned()), - ptr(proposal_plan.expected_event_id.as_str().to_owned()), - revision_decision_payload( - &proposal_payload, - &proposal_plan.expected_event_id, - RadrootsOrderRevisionOutcome::Accepted, - ), - created_at, - ) - .expect("revision decision plan"); - let cancellation_plan = order_cancellation_plan( - &buyer_actor, - ptr(submit_plan.expected_event_id.as_str().to_owned()), - ptr(decision_plan.expected_event_id.as_str().to_owned()), - cancellation_payload(), - created_at, - ) - .expect("cancellation plan"); - let base = projection( - &proposal_plan.order_id, - &proposal_plan.listing_addr, - &proposal_plan.buyer_pubkey, - &proposal_plan.seller_pubkey, - &submit_plan.expected_event_id, - &decision_plan.expected_event_id, - ); - - assert!(require_revision_proposal_state(&proposal_plan, &base).is_ok()); - - let mut accepted = base.clone(); - accepted.status = RadrootsTradeWorkflowState::AgreedPendingRhi; - assert!( - invalid_request_message( - require_revision_proposal_state(&proposal_plan, &accepted).unwrap_err() - ) - .contains("requires Requested") - ); - - let mut terminal = base.clone(); - terminal.lifecycle_terminal = true; - assert!( - invalid_request_message( - require_revision_proposal_state(&proposal_plan, &terminal).unwrap_err() - ) - .contains("non-terminal") - ); - - let mut proposal_pending = base.clone(); - proposal_pending.pending_revision_event_id = Some(proposal_plan.expected_event_id.clone()); - assert!( - invalid_request_message( - require_revision_proposal_state(&proposal_plan, &proposal_pending).unwrap_err() - ) - .contains("cannot follow pending revision") - ); - - let mut revision_ready = base.clone(); - revision_ready.status = RadrootsTradeWorkflowState::RevisionProposed; - revision_ready.last_event_id = Some(proposal_plan.expected_event_id.clone()); - revision_ready.pending_revision_event_id = Some(proposal_plan.expected_event_id.clone()); - assert!(require_revision_decision_state(&revision_plan, &revision_ready).is_ok()); - - let mut revision_terminal = revision_ready.clone(); - revision_terminal.lifecycle_terminal = true; - assert!( - invalid_request_message( - require_revision_decision_state(&revision_plan, &revision_terminal).unwrap_err() - ) - .contains("non-terminal") - ); - - let mut revision_without_pending = revision_ready; - revision_without_pending.pending_revision_event_id = None; - assert!( - invalid_request_message( - require_revision_decision_state(&revision_plan, &revision_without_pending).unwrap_err() - ) - .contains("requires pending revision proposal") - ); - - assert!(require_cancellation_state(&cancellation_plan, &base).is_ok()); - - let mut cancellation_accepted = base.clone(); - cancellation_accepted.status = RadrootsTradeWorkflowState::AgreedPendingRhi; - assert!( - invalid_request_message( - require_cancellation_state(&cancellation_plan, &cancellation_accepted).unwrap_err() - ) - .contains("cancellation requires requested") - ); - - let mut cancellation_terminal = base.clone(); - cancellation_terminal.lifecycle_terminal = true; - assert!( - invalid_request_message( - require_cancellation_state(&cancellation_plan, &cancellation_terminal).unwrap_err() - ) - .contains("non-terminal") - ); - - let mut cancellation_pending = base; - cancellation_pending.pending_revision_event_id = Some(proposal_plan.expected_event_id); - assert!( - invalid_request_message( - require_cancellation_state(&cancellation_plan, &cancellation_pending).unwrap_err() - ) - .contains("cannot follow pending revision") - ); -} - -#[test] -fn evidence_reference_helpers_reject_invalid_ids_and_payload_mismatches() { - let root_event_id = event_id('a'); - let previous_event_id = event_id('b'); - let alternate_event_id = event_id('c'); - - assert_eq!( - request_event_id(&ptr(root_event_id.as_str().to_owned())).expect("request event"), - root_event_id - ); - assert_eq!( - order_reference_event_id(&ptr(previous_event_id.as_str().to_owned()), "decision") - .expect("reference event"), - previous_event_id - ); - assert!( - invalid_request_message(request_event_id(&ptr("not-hex".to_owned())).unwrap_err()) - .contains("order request evidence event id is invalid") - ); - assert!( - invalid_request_message( - order_reference_event_id(&ptr("not-hex".to_owned()), "decision").unwrap_err() - ) - .contains("order decision evidence event id is invalid") - ); - - assert!( - require_payload_event_refs( - "order decision", - &root_event_id, - &previous_event_id, - &root_event_id, - &previous_event_id, - ) - .is_ok() - ); - assert!( - invalid_request_message( - require_payload_event_refs( - "order decision", - &alternate_event_id, - &previous_event_id, - &root_event_id, - &previous_event_id, - ) - .unwrap_err() - ) - .contains("root_event_id") - ); - assert!( - invalid_request_message( - require_payload_event_refs( - "order decision", - &root_event_id, - &alternate_event_id, - &root_event_id, - &previous_event_id, - ) - .unwrap_err() - ) - .contains("prev_event_id") - ); -} - -#[test] -fn parse_order_evidence_reports_constructor_id_unsupported_kind_and_decode_errors() { - let invalid_id = RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { - id: "not-hex".to_owned(), - author: hex_64('c'), - created_at: 1_700_000_000, - kind: KIND_ORDER_REQUEST, - tags: Vec::new(), - content: "{}".to_owned(), - sig: hex_128('f'), - }) - .expect_err("invalid id"); - assert!( - invalid_id - .to_string() - .contains("event envelope id is invalid") - ); - assert!( - invalid_request_message(parsed_order_evidence_error(parse_order_evidence( - &nostr_event(hex_64('a'), 1), - ))) - .contains("order evidence event kind 1 is not supported") - ); - assert!( - invalid_request_message(parsed_order_evidence_error(parse_order_evidence( - &nostr_event(hex_64('a'), KIND_ORDER_REQUEST), - ))) - .contains("order evidence event is invalid") - ); - for kind in [ - KIND_ORDER_DECISION, - KIND_ORDER_REVISION_PROPOSAL, - KIND_ORDER_REVISION_DECISION, - KIND_ORDER_CANCELLATION, - ] { - assert!( - invalid_request_message(parsed_order_evidence_error(parse_order_evidence( - &nostr_event(hex_64('a'), kind), - ))) - .contains("order evidence event is invalid") - ); - } -} - -#[test] -fn projection_error_maps_store_tag_and_decode_errors() { - assert_eq!( - projection_message(projection_error(RadrootsOrderStoreQueryError::Store( - RadrootsEventStoreError::MissingEvent("event-a".to_owned()) - ))), - "order status store query failed" - ); - assert_eq!( - projection_message(projection_error( - RadrootsOrderStoreQueryError::InvalidStoredTagsJson { - event_id: "event-a".to_owned(), - source: serde_json::from_str::<serde_json::Value>("{").unwrap_err(), - } - )), - "stored order event tags could not be decoded" - ); - assert_eq!( - projection_message(projection_error(RadrootsOrderStoreQueryError::Decode { - event_id: "event-a".to_owned(), - source: RadrootsOrderEventDecodeError::UnsupportedKind { kind: 999 }, - })), - "stored order event could not decode as order record" - ); - let invalid_limit = projection_error(RadrootsOrderStoreQueryError::Projection( - RadrootsTradeProjectionError::InvalidLimit { max: 1000 }, - )); - assert!(matches!( - invalid_limit, - RadrootsSdkError::InvalidRequest { .. } - )); -} - -#[test] -fn order_enqueue_request_mutators_reject_invalid_relays_and_idempotency_keys() { - let buyer = buyer_actor(); - let seller = seller_actor(); - let listing_event = ptr(event_id('a').as_str().to_owned()); - let request_event = ptr(event_id('b').as_str().to_owned()); - let previous_event = ptr(event_id('c').as_str().to_owned()); - let submit_payload = order_request_payload(); - let decision_payload = order_decision_payload(); - let proposal_payload = revision_proposal_payload(&event_id('b'), &event_id('c')); - let revision_decision_payload = revision_decision_payload( - &proposal_payload, - &event_id('d'), - RadrootsOrderRevisionOutcome::Accepted, - ); - let cancellation_payload = cancellation_payload(); - let policy = TargetPolicy::default_profile(); - - assert_error_display( - TradeSubmitEnqueueRequest::new( - buyer.clone(), - listing_event.clone(), - submit_payload.clone(), - policy.clone(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets(Vec::<String>::new(), NostrRelayUrlPolicy::Public), - "transport targets", - ); - assert_error_display( - TradeSubmitEnqueueRequest::new( - buyer.clone(), - listing_event, - submit_payload, - policy.clone(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_idempotency_key(""), - "idempotency key", - ); - - assert_error_display( - TradeDecisionEnqueueRequest::new( - seller.clone(), - request_event.clone(), - decision_payload.clone(), - policy.clone(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets(Vec::<String>::new(), NostrRelayUrlPolicy::Public), - "transport targets", - ); - assert_error_display( - TradeDecisionEnqueueRequest::new( - seller.clone(), - request_event.clone(), - decision_payload, - policy.clone(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_idempotency_key(" leading"), - "idempotency key", - ); - - assert_error_display( - TradeRevisionProposalEnqueueRequest::new( - seller.clone(), - request_event.clone(), - previous_event.clone(), - proposal_payload.clone(), - policy.clone(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets(Vec::<String>::new(), NostrRelayUrlPolicy::Public), - "transport targets", - ); - assert_error_display( - TradeRevisionProposalEnqueueRequest::new( - seller.clone(), - request_event.clone(), - previous_event.clone(), - proposal_payload.clone(), - policy.clone(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_idempotency_key("trailing "), - "idempotency key", - ); - - assert_error_display( - TradeRevisionDecisionEnqueueRequest::new( - buyer.clone(), - request_event.clone(), - previous_event.clone(), - revision_decision_payload.clone(), - policy.clone(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets(Vec::<String>::new(), NostrRelayUrlPolicy::Public), - "transport targets", - ); - assert_error_display( - TradeRevisionDecisionEnqueueRequest::new( - buyer.clone(), - request_event.clone(), - previous_event.clone(), - revision_decision_payload, - policy.clone(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_idempotency_key("invalid\nkey"), - "idempotency key", - ); - - assert_error_display( - TradeCancellationEnqueueRequest::new( - buyer.clone(), - request_event.clone(), - previous_event.clone(), - cancellation_payload.clone(), - policy.clone(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets(Vec::<String>::new(), NostrRelayUrlPolicy::Public), - "transport targets", - ); - assert_error_display( - TradeCancellationEnqueueRequest::new( - buyer, - request_event, - previous_event, - cancellation_payload, - policy, - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_idempotency_key(""), - "idempotency key", - ); -} #[test] -fn trade_enqueue_policy_rejects_publish_modes_without_matching_side_effects() { - assert!(matches!( - validate_trade_enqueue_policy(PublishMode::DryRun, &SatisfactionPolicy::NoWait), - Err(RadrootsSdkError::InvalidRequest { ref message }) - if message == "trade dry-run publish mode must use a prepare request" - )); - assert!(matches!( - validate_trade_enqueue_policy(PublishMode::EnqueueOnly, &SatisfactionPolicy::AnyAccepted), - Err(RadrootsSdkError::InvalidRequest { ref message }) - if message == "trade enqueue-only publish mode only supports no-wait satisfaction" - )); - assert!(matches!( - validate_trade_enqueue_policy(PublishMode::EnqueueAndPublish, &SatisfactionPolicy::NoWait), - Err(RadrootsSdkError::InvalidRequest { ref message }) - if message == "trade enqueue-and-publish requires a transport satisfaction policy" - )); - assert!( - validate_trade_enqueue_policy(PublishMode::EnqueueOnly, &SatisfactionPolicy::NoWait) - .is_ok() - ); - assert!( - validate_trade_enqueue_policy( - PublishMode::EnqueueAndPublish, - &SatisfactionPolicy::AnyAccepted - ) - .is_ok() - ); -} - -#[tokio::test] -async fn orders_client_prepare_methods_resolve_request_created_at() { - let sdk = crate::RadrootsClient::builder() - .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_010)) - .build() - .await - .expect("sdk"); - let root_event_id = event_id('a'); - let previous_event_id = event_id('b'); - let root_event = ptr(root_event_id.as_str().to_owned()); - let previous_event = ptr(previous_event_id.as_str().to_owned()); - - assert_eq!( - sdk.trades() - .prepare_submit(TradeSubmitPrepareRequest::new( - buyer_actor(), - root_event.clone(), - order_request_payload(), - )) - .expect("submit plan") - .created_at, - RadrootsSdkTimestamp::from_unix_seconds(1_700_000_010) - ); - assert_eq!( - sdk.trades() - .prepare_decision( - TradeDecisionPrepareRequest::new( - seller_actor(), - root_event.clone(), - order_decision_payload(), - ) - .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_011)), - ) - .expect("decision plan") - .created_at, - RadrootsSdkTimestamp::from_unix_seconds(1_700_000_011) - ); - - let proposal = revision_proposal_payload(&root_event_id, &previous_event_id); - assert_eq!( - sdk.trades() - .prepare_revision_proposal( - TradeRevisionProposalPrepareRequest::new( - seller_actor(), - root_event.clone(), - previous_event.clone(), - proposal.clone(), - ) - .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_012)), - ) - .expect("proposal plan") - .created_at, - RadrootsSdkTimestamp::from_unix_seconds(1_700_000_012) - ); - assert_eq!( - sdk.trades() - .prepare_revision_decision( - TradeRevisionDecisionPrepareRequest::new( - buyer_actor(), - root_event.clone(), - ptr(event_id('c').as_str().to_owned()), - revision_decision_payload( - &proposal, - &event_id('c'), - RadrootsOrderRevisionOutcome::Accepted, - ), - ) - .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_013)), - ) - .expect("revision decision plan") - .created_at, - RadrootsSdkTimestamp::from_unix_seconds(1_700_000_013) - ); - assert_eq!( - sdk.trades() - .prepare_cancellation( - TradeCancellationPrepareRequest::new( - buyer_actor(), - root_event, - previous_event, - cancellation_payload(), - ) - .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_014)), - ) - .expect("cancellation plan") - .created_at, - RadrootsSdkTimestamp::from_unix_seconds(1_700_000_014) - ); -} - -#[tokio::test] -async fn prepared_submit_and_decision_enqueue_cover_source_attached_success_path() { - let sdk = prepared_order_sdk().await; - let submit = enqueue_fixture_submit(&sdk, "order-prepared-decision"); - let submit = submit.await; - assert_eq!(submit.signed_event_id, submit.expected_event_id); - assert_eq!(submit.workflow.kind, TradeWorkflowKind::Submit); - - let seller = fixture_seller_actor(); - let decision_plan = sdk - .trades() - .prepare_decision(TradeDecisionPrepareRequest::new( - seller.clone(), - fixture_order_event_ptr(&submit.signed_event_id), - fixture_order_decision("order-prepared-decision"), - )) - .expect("decision plan"); - let decision = sdk - .trades() - .enqueue_prepared_decision_with_explicit_signer( - &seller, - decision_plan.clone(), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - Some(SdkIdempotencyKey::new("prepared-decision").expect("idempotency")), - &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue decision"); - - assert_eq!(decision.signed_event_id, decision.expected_event_id); - assert_eq!(decision.workflow.kind, TradeWorkflowKind::Decision); - assert_eq!(decision.request_event_id, submit.signed_event_id); -} - -#[tokio::test] -async fn prepared_revision_lifecycle_enqueue_cover_source_attached_success_paths() { - let sdk = prepared_order_sdk().await; - let submit = enqueue_fixture_submit(&sdk, "order-prepared-revision").await; - let seller = fixture_seller_actor(); - let proposal_payload = fixture_revision_proposal( - "order-prepared-revision", - &submit.signed_event_id, - &submit.signed_event_id, - ); - let proposal_plan = sdk - .trades() - .prepare_revision_proposal(TradeRevisionProposalPrepareRequest::new( - seller.clone(), - fixture_order_event_ptr(&submit.signed_event_id), - fixture_order_event_ptr(&submit.signed_event_id), - proposal_payload.clone(), - )) - .expect("proposal plan"); - let proposal = sdk - .trades() - .enqueue_prepared_revision_proposal_with_explicit_signer( - &seller, - proposal_plan.clone(), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - Some(SdkIdempotencyKey::new("prepared-proposal").expect("idempotency")), - &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue proposal"); - - assert_eq!(proposal.signed_event_id, proposal.expected_event_id); - assert_eq!(proposal.workflow.kind, TradeWorkflowKind::RevisionProposal); - assert_eq!(proposal.root_event_id, submit.signed_event_id); - assert_eq!(proposal.previous_event_id, submit.signed_event_id); - - let buyer = fixture_buyer_actor(); - let revision_decision = fixture_revision_decision(&proposal_payload, &proposal.signed_event_id); - let revision_decision_plan = sdk - .trades() - .prepare_revision_decision(TradeRevisionDecisionPrepareRequest::new( - buyer.clone(), - fixture_order_event_ptr(&submit.signed_event_id), - fixture_order_event_ptr(&proposal.signed_event_id), - revision_decision, - )) - .expect("revision decision plan"); - let revision = sdk - .trades() - .enqueue_prepared_revision_decision_with_explicit_signer( - &buyer, - revision_decision_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue revision decision"); - - assert_eq!(revision.signed_event_id, revision.expected_event_id); - assert_eq!(revision.workflow.kind, TradeWorkflowKind::RevisionDecision); - assert_eq!(revision.root_event_id, submit.signed_event_id); - assert_eq!(revision.previous_event_id, proposal.signed_event_id); -} - -#[tokio::test] -async fn prepared_cancellation_enqueue_covers_source_attached_success_path() { - let sdk = prepared_order_sdk().await; - let submit = enqueue_fixture_submit(&sdk, "order-prepared-cancellation").await; - let buyer = fixture_buyer_actor(); - let cancellation_plan = sdk - .trades() - .prepare_cancellation(TradeCancellationPrepareRequest::new( - buyer.clone(), - fixture_order_event_ptr(&submit.signed_event_id), - fixture_order_event_ptr(&submit.signed_event_id), - fixture_cancellation("order-prepared-cancellation"), - )) - .expect("cancellation plan"); - let cancellation = sdk - .trades() - .enqueue_prepared_cancellation_with_explicit_signer( - &buyer, - cancellation_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - Some(SdkIdempotencyKey::new("prepared-cancellation").expect("idempotency")), - &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue cancellation"); - - assert_eq!(cancellation.signed_event_id, cancellation.expected_event_id); - assert_eq!(cancellation.workflow.kind, TradeWorkflowKind::Cancellation); - assert_eq!(cancellation.root_event_id, submit.signed_event_id); - assert_eq!(cancellation.previous_event_id, submit.signed_event_id); -} - -#[tokio::test] -async fn convenience_order_enqueue_methods_cover_source_attached_wrappers() { - let sdk = prepared_order_sdk().await; - let decision_submit = sdk - .trades() - .enqueue_submit_with_explicit_signer( - TradeSubmitEnqueueRequest::new( - fixture_buyer_actor(), - fixture_event_ptr('b'), - fixture_order_request("order-wrapper-decision"), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ), - &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue submit"); - let decision = sdk - .trades() - .enqueue_decision_with_explicit_signer( - TradeDecisionEnqueueRequest::new( - fixture_seller_actor(), - fixture_order_event_ptr(&decision_submit.signed_event_id), - fixture_order_decision("order-wrapper-decision"), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ), - &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue decision"); - assert_eq!(decision.request_event_id, decision_submit.signed_event_id); - - let revision_submit = sdk - .trades() - .enqueue_submit_with_explicit_signer( - TradeSubmitEnqueueRequest::new( - fixture_buyer_actor(), - fixture_event_ptr('c'), - fixture_order_request("order-wrapper-revision"), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ), - &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue revision submit"); - let proposal_payload = fixture_revision_proposal( - "order-wrapper-revision", - &revision_submit.signed_event_id, - &revision_submit.signed_event_id, - ); - let proposal = sdk - .trades() - .enqueue_revision_proposal_with_explicit_signer( - TradeRevisionProposalEnqueueRequest::new( - fixture_seller_actor(), - fixture_order_event_ptr(&revision_submit.signed_event_id), - fixture_order_event_ptr(&revision_submit.signed_event_id), - proposal_payload.clone(), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ), - &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue proposal"); - let revision = sdk - .trades() - .enqueue_revision_decision_with_explicit_signer( - TradeRevisionDecisionEnqueueRequest::new( - fixture_buyer_actor(), - fixture_order_event_ptr(&revision_submit.signed_event_id), - fixture_order_event_ptr(&proposal.signed_event_id), - fixture_revision_decision(&proposal_payload, &proposal.signed_event_id), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ), - &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue revision decision"); - assert_eq!(revision.previous_event_id, proposal.signed_event_id); - - let cancellation_submit = sdk - .trades() - .enqueue_submit_with_explicit_signer( - TradeSubmitEnqueueRequest::new( - fixture_buyer_actor(), - fixture_event_ptr('d'), - fixture_order_request("order-wrapper-cancellation"), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ), - &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue cancellation submit"); - let cancellation = sdk - .trades() - .enqueue_cancellation_with_explicit_signer( - TradeCancellationEnqueueRequest::new( - fixture_buyer_actor(), - fixture_order_event_ptr(&cancellation_submit.signed_event_id), - fixture_order_event_ptr(&cancellation_submit.signed_event_id), - fixture_cancellation("order-wrapper-cancellation"), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ), - &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue cancellation"); - assert_eq!( - cancellation.previous_event_id, - cancellation_submit.signed_event_id - ); -} - -#[tokio::test] -async fn prepared_lifecycle_enqueues_report_missing_and_closed_preflight_errors() { - let sdk = prepared_order_sdk().await; - let buyer = fixture_buyer_actor(); - let seller = fixture_seller_actor(); - let root_event_id = event_id('a'); - let previous_event_id = event_id('b'); - let root = fixture_order_event_ptr(&root_event_id); - let previous = fixture_order_event_ptr(&previous_event_id); - let proposal = - fixture_revision_proposal("order-preflight-errors", &root_event_id, &previous_event_id); - - let decision_plan = sdk - .trades() - .prepare_decision(TradeDecisionPrepareRequest::new( - seller.clone(), - root.clone(), - fixture_order_decision("order-preflight-errors"), - )) - .expect("decision plan"); - let decision_missing = sdk - .trades() - .enqueue_prepared_decision_with_explicit_signer( - &seller, - decision_plan.clone(), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await - .expect_err("missing decision evidence"); - assert!(matches!( - decision_missing, - RadrootsSdkError::InvalidRequest { .. } - )); - - let proposal_plan = sdk - .trades() - .prepare_revision_proposal(TradeRevisionProposalPrepareRequest::new( - seller.clone(), - root.clone(), - previous.clone(), - proposal.clone(), - )) - .expect("proposal plan"); - let proposal_missing = sdk - .trades() - .enqueue_prepared_revision_proposal_with_explicit_signer( - &seller, - proposal_plan.clone(), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await - .expect_err("missing proposal evidence"); - assert!(matches!( - proposal_missing, - RadrootsSdkError::InvalidRequest { .. } - )); - - let revision_decision_plan = sdk - .trades() - .prepare_revision_decision(TradeRevisionDecisionPrepareRequest::new( - buyer.clone(), - root.clone(), - previous.clone(), - fixture_revision_decision(&proposal, &previous_event_id), - )) - .expect("revision decision plan"); - let revision_missing = sdk - .trades() - .enqueue_prepared_revision_decision_with_explicit_signer( - &buyer, - revision_decision_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) - .await - .expect_err("missing revision evidence"); - assert!(matches!( - revision_missing, - RadrootsSdkError::InvalidRequest { .. } - )); - - let cancellation_plan = sdk - .trades() - .prepare_cancellation(TradeCancellationPrepareRequest::new( - buyer.clone(), - root, - previous, - fixture_cancellation("order-preflight-errors"), - )) - .expect("cancellation plan"); - let cancellation_missing = sdk - .trades() - .enqueue_prepared_cancellation_with_explicit_signer( - &buyer, - cancellation_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) - .await - .expect_err("missing cancellation evidence"); - assert!(matches!( - cancellation_missing, - RadrootsSdkError::InvalidRequest { .. } - )); - - let closed_sdk = prepared_order_sdk().await; - let closed_root_event_id = event_id('c'); - let closed_previous_event_id = event_id('d'); - let closed_root = fixture_order_event_ptr(&closed_root_event_id); - let closed_previous = fixture_order_event_ptr(&closed_previous_event_id); - let closed_proposal = fixture_revision_proposal( - "order-closed-preflight", - &closed_root_event_id, - &closed_previous_event_id, - ); - let closed_plan = closed_sdk - .trades() - .prepare_decision(TradeDecisionPrepareRequest::new( - seller.clone(), - closed_root.clone(), - fixture_order_decision("order-closed-preflight"), - )) - .expect("closed decision plan"); - let closed_proposal_plan = closed_sdk - .trades() - .prepare_revision_proposal(TradeRevisionProposalPrepareRequest::new( - seller.clone(), - closed_root.clone(), - closed_previous.clone(), - closed_proposal.clone(), - )) - .expect("closed proposal plan"); - let closed_revision_plan = closed_sdk - .trades() - .prepare_revision_decision(TradeRevisionDecisionPrepareRequest::new( - buyer.clone(), - closed_root.clone(), - closed_previous.clone(), - fixture_revision_decision(&closed_proposal, &closed_previous_event_id), - )) - .expect("closed revision decision plan"); - let closed_cancellation_plan = closed_sdk - .trades() - .prepare_cancellation(TradeCancellationPrepareRequest::new( - buyer.clone(), - closed_root, - closed_previous, - fixture_cancellation("order-closed-preflight"), - )) - .expect("closed cancellation plan"); - closed_sdk._event_store.pool().close().await; - let closed_error = closed_sdk - .trades() - .enqueue_prepared_decision_with_explicit_signer( - &seller, - closed_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await - .expect_err("closed preflight"); - assert!(matches!(closed_error, RadrootsSdkError::EventStore { .. })); - let closed_proposal_error = closed_sdk - .trades() - .enqueue_prepared_revision_proposal_with_explicit_signer( - &seller, - closed_proposal_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await - .expect_err("closed proposal preflight"); - assert!(matches!( - closed_proposal_error, - RadrootsSdkError::EventStore { .. } - )); - let closed_revision_error = closed_sdk - .trades() - .enqueue_prepared_revision_decision_with_explicit_signer( - &buyer, - closed_revision_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) - .await - .expect_err("closed revision preflight"); - assert!(matches!( - closed_revision_error, - RadrootsSdkError::EventStore { .. } - )); - let closed_cancellation_error = closed_sdk - .trades() - .enqueue_prepared_cancellation_with_explicit_signer( - &buyer, - closed_cancellation_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) - .await - .expect_err("closed cancellation preflight"); - assert!(matches!( - closed_cancellation_error, - RadrootsSdkError::EventStore { .. } - )); -} - -#[tokio::test] -async fn configured_prepared_lifecycle_enqueues_run_preflight_guards() { - let buyer = fixture_buyer_actor(); - let seller = fixture_seller_actor(); - let root_event_id = event_id('e'); - let previous_event_id = event_id('f'); - let root = fixture_order_event_ptr(&root_event_id); - let previous = fixture_order_event_ptr(&previous_event_id); - let proposal = fixture_revision_proposal( - "order-configured-preflight", - &root_event_id, - &previous_event_id, - ); - - let seller_sdk = configured_order_sdk(SELLER_SECRET_KEY_HEX).await; - let decision_plan = seller_sdk - .trades() - .prepare_decision(TradeDecisionPrepareRequest::new( - seller.clone(), - root.clone(), - fixture_order_decision("order-configured-preflight"), - )) - .expect("decision plan"); - let decision_missing = seller_sdk - .trades() - .enqueue_prepared_decision( - &seller, - decision_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - ) - .await - .expect_err("configured missing decision evidence"); - assert!(matches!( - decision_missing, - RadrootsSdkError::InvalidRequest { .. } - )); - - let proposal_plan = seller_sdk - .trades() - .prepare_revision_proposal(TradeRevisionProposalPrepareRequest::new( - seller.clone(), - root.clone(), - previous.clone(), - proposal.clone(), - )) - .expect("proposal plan"); - let proposal_missing = seller_sdk - .trades() - .enqueue_prepared_revision_proposal( - &seller, - proposal_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - ) - .await - .expect_err("configured missing proposal evidence"); - assert!(matches!( - proposal_missing, - RadrootsSdkError::InvalidRequest { .. } - )); - - let buyer_sdk = configured_order_sdk(BUYER_SECRET_KEY_HEX).await; - let revision_plan = buyer_sdk - .trades() - .prepare_revision_decision(TradeRevisionDecisionPrepareRequest::new( - buyer.clone(), - root.clone(), - previous.clone(), - fixture_revision_decision(&proposal, &previous_event_id), - )) - .expect("revision decision plan"); - let revision_missing = buyer_sdk - .trades() - .enqueue_prepared_revision_decision( - &buyer, - revision_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - ) - .await - .expect_err("configured missing revision decision evidence"); - assert!(matches!( - revision_missing, - RadrootsSdkError::InvalidRequest { .. } - )); - - let cancellation_plan = buyer_sdk - .trades() - .prepare_cancellation(TradeCancellationPrepareRequest::new( - buyer.clone(), - root, - previous, - fixture_cancellation("order-configured-preflight"), - )) - .expect("cancellation plan"); - let cancellation_missing = buyer_sdk - .trades() - .enqueue_prepared_cancellation( - &buyer, - cancellation_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - ) - .await - .expect_err("configured missing cancellation evidence"); - assert!(matches!( - cancellation_missing, - RadrootsSdkError::InvalidRequest { .. } - )); -} - -#[tokio::test] -async fn configured_prepared_lifecycle_enqueues_report_existing_event_lookup_errors() { - let buyer = fixture_buyer_actor(); - let seller = fixture_seller_actor(); - let root_event_id = event_id('e'); - let previous_event_id = event_id('f'); - let root = fixture_order_event_ptr(&root_event_id); - let previous = fixture_order_event_ptr(&previous_event_id); - let proposal = fixture_revision_proposal( - "order-configured-existing-lookup", - &root_event_id, - &previous_event_id, - ); - - let seller_sdk = configured_order_sdk(SELLER_SECRET_KEY_HEX).await; - let decision_plan = seller_sdk - .trades() - .prepare_decision(TradeDecisionPrepareRequest::new( - seller.clone(), - root.clone(), - fixture_order_decision("order-configured-existing-lookup"), - )) - .expect("decision plan"); - let proposal_plan = seller_sdk - .trades() - .prepare_revision_proposal(TradeRevisionProposalPrepareRequest::new( - seller.clone(), - root.clone(), - previous.clone(), - proposal.clone(), - )) - .expect("proposal plan"); - seller_sdk._event_store.pool().close().await; - assert!(matches!( - seller_sdk - .trades() - .enqueue_prepared_decision( - &seller, - decision_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None - ) - .await, - Err(RadrootsSdkError::EventStore { .. }) - )); - assert!(matches!( - seller_sdk - .trades() - .enqueue_prepared_revision_proposal( - &seller, - proposal_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - ) - .await, - Err(RadrootsSdkError::EventStore { .. }) - )); - - let buyer_sdk = configured_order_sdk(BUYER_SECRET_KEY_HEX).await; - let revision_plan = buyer_sdk - .trades() - .prepare_revision_decision(TradeRevisionDecisionPrepareRequest::new( - buyer.clone(), - root.clone(), - previous.clone(), - fixture_revision_decision(&proposal, &previous_event_id), - )) - .expect("revision decision plan"); - let cancellation_plan = buyer_sdk - .trades() - .prepare_cancellation(TradeCancellationPrepareRequest::new( - buyer.clone(), - root, - previous, - fixture_cancellation("order-configured-existing-lookup"), - )) - .expect("cancellation plan"); - buyer_sdk._event_store.pool().close().await; - assert!(matches!( - buyer_sdk - .trades() - .enqueue_prepared_revision_decision( - &buyer, - revision_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None - ) - .await, - Err(RadrootsSdkError::EventStore { .. }) - )); - assert!(matches!( - buyer_sdk - .trades() - .enqueue_prepared_cancellation( - &buyer, - cancellation_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None - ) - .await, - Err(RadrootsSdkError::EventStore { .. }) - )); -} - -#[tokio::test] -async fn configured_enqueue_wrappers_report_prepare_errors_before_signing() { - let seller_sdk = configured_order_sdk(SELLER_SECRET_KEY_HEX).await; - let buyer_sdk = configured_order_sdk(BUYER_SECRET_KEY_HEX).await; - let root_event_id = event_id('a'); - let previous_event_id = event_id('b'); - let proposal = fixture_revision_proposal( - "order-configured-prepare-errors", - &root_event_id, - &previous_event_id, - ); - - assert!(matches!( - buyer_sdk - .trades() - .enqueue_submit(TradeSubmitEnqueueRequest::new( - fixture_seller_actor(), - fixture_event_ptr('a'), - fixture_order_request("order-configured-prepare-submit"), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - )) - .await, - Err(RadrootsSdkError::UnauthorizedActor { .. }) - )); - assert!(matches!( - seller_sdk - .trades() - .enqueue_decision(TradeDecisionEnqueueRequest::new( - fixture_buyer_actor(), - fixture_event_ptr('a'), - fixture_order_decision("order-configured-prepare-decision"), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - )) - .await, - Err(RadrootsSdkError::UnauthorizedActor { .. }) - )); - assert!(matches!( - seller_sdk - .trades() - .enqueue_revision_proposal(TradeRevisionProposalEnqueueRequest::new( - fixture_buyer_actor(), - fixture_order_event_ptr(&root_event_id), - fixture_order_event_ptr(&previous_event_id), - proposal.clone(), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - )) - .await, - Err(RadrootsSdkError::UnauthorizedActor { .. }) - )); - assert!(matches!( - buyer_sdk - .trades() - .enqueue_revision_decision(TradeRevisionDecisionEnqueueRequest::new( - fixture_seller_actor(), - fixture_order_event_ptr(&root_event_id), - fixture_order_event_ptr(&previous_event_id), - fixture_revision_decision(&proposal, &previous_event_id), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - )) - .await, - Err(RadrootsSdkError::UnauthorizedActor { .. }) - )); - assert!(matches!( - buyer_sdk - .trades() - .enqueue_cancellation(TradeCancellationEnqueueRequest::new( - fixture_seller_actor(), - fixture_order_event_ptr(&root_event_id), - fixture_order_event_ptr(&previous_event_id), - fixture_cancellation("order-configured-prepare-cancel"), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - )) - .await, - Err(RadrootsSdkError::UnauthorizedActor { .. }) - )); -} - -#[tokio::test] -async fn configured_prepared_methods_report_missing_configured_signer_after_preflight() { - let sdk = prepared_order_sdk().await; - let buyer = fixture_buyer_actor(); - let seller = fixture_seller_actor(); +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 submit_plan = sdk - .trades() - .prepare_submit(TradeSubmitPrepareRequest::new( - buyer.clone(), - fixture_event_ptr('a'), - fixture_order_request("order-missing-configured-submit"), - )) - .expect("submit plan"); - assert!(matches!( - sdk.trades() - .enqueue_prepared_submit( - &buyer, - submit_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None - ) - .await, - Err(RadrootsSdkError::SignerUnavailable { .. }) - )); - - let decision_submit = enqueue_fixture_submit(&sdk, "order-missing-configured-decision").await; - let decision_plan = sdk - .trades() - .prepare_decision(TradeDecisionPrepareRequest::new( - seller.clone(), - fixture_order_event_ptr(&decision_submit.signed_event_id), - fixture_order_decision("order-missing-configured-decision"), - )) - .expect("decision plan"); - assert!(matches!( - sdk.trades() - .enqueue_prepared_decision( - &seller, - decision_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None - ) - .await, - Err(RadrootsSdkError::SignerUnavailable { .. }) - )); - - let proposal_submit = enqueue_fixture_submit(&sdk, "order-missing-configured-proposal").await; - let proposal_payload = fixture_revision_proposal( - "order-missing-configured-proposal", - &proposal_submit.signed_event_id, - &proposal_submit.signed_event_id, - ); - let proposal_plan = sdk - .trades() - .prepare_revision_proposal(TradeRevisionProposalPrepareRequest::new( - seller.clone(), - fixture_order_event_ptr(&proposal_submit.signed_event_id), - fixture_order_event_ptr(&proposal_submit.signed_event_id), - proposal_payload.clone(), - )) - .expect("proposal plan"); - assert!(matches!( - sdk.trades() - .enqueue_prepared_revision_proposal( - &seller, - proposal_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None - ) - .await, - Err(RadrootsSdkError::SignerUnavailable { .. }) - )); - - let revision_submit = enqueue_fixture_submit(&sdk, "order-missing-configured-revision").await; - let revision_proposal_payload = fixture_revision_proposal( - "order-missing-configured-revision", - &revision_submit.signed_event_id, - &revision_submit.signed_event_id, - ); - let revision_proposal_plan = sdk - .trades() - .prepare_revision_proposal(TradeRevisionProposalPrepareRequest::new( - seller.clone(), - fixture_order_event_ptr(&revision_submit.signed_event_id), - fixture_order_event_ptr(&revision_submit.signed_event_id), - revision_proposal_payload.clone(), - )) - .expect("revision proposal plan"); - let revision_proposal = sdk - .trades() - .enqueue_prepared_revision_proposal_with_explicit_signer( - &seller, - revision_proposal_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await - .expect("revision proposal evidence"); - let revision_plan = sdk - .trades() - .prepare_revision_decision(TradeRevisionDecisionPrepareRequest::new( - buyer.clone(), - fixture_order_event_ptr(&revision_submit.signed_event_id), - fixture_order_event_ptr(&revision_proposal.signed_event_id), - fixture_revision_decision( - &revision_proposal_payload, - &revision_proposal.signed_event_id, - ), - )) - .expect("revision plan"); - assert!(matches!( - sdk.trades() - .enqueue_prepared_revision_decision( - &buyer, - revision_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None - ) - .await, - Err(RadrootsSdkError::SignerUnavailable { .. }) - )); - - let cancellation_submit = enqueue_fixture_submit(&sdk, "order-missing-configured-cancel").await; - let cancellation_plan = sdk - .trades() - .prepare_cancellation(TradeCancellationPrepareRequest::new( - buyer.clone(), - fixture_order_event_ptr(&cancellation_submit.signed_event_id), - fixture_order_event_ptr(&cancellation_submit.signed_event_id), - fixture_cancellation("order-missing-configured-cancel"), - )) - .expect("cancellation plan"); - assert!(matches!( - sdk.trades() - .enqueue_prepared_cancellation( - &buyer, - cancellation_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None - ) - .await, - Err(RadrootsSdkError::SignerUnavailable { .. }) - )); -} - -#[tokio::test] -async fn lifecycle_preflight_helpers_map_projection_query_failures() { - let sdk = prepared_order_sdk().await; - let client = sdk.trades(); - let buyer = fixture_buyer_actor(); - let seller = fixture_seller_actor(); - let root_event_id = event_id('e'); - let previous_event_id = event_id('f'); - let root = fixture_order_event_ptr(&root_event_id); - let previous = fixture_order_event_ptr(&previous_event_id); - let proposal = fixture_revision_proposal( - "order-preflight-query-failure", - &root_event_id, - &previous_event_id, - ); - - let decision_plan = client - .prepare_decision(TradeDecisionPrepareRequest::new( - seller.clone(), - root.clone(), - fixture_order_decision("order-preflight-query-failure"), - )) - .expect("decision plan"); - let proposal_plan = client - .prepare_revision_proposal(TradeRevisionProposalPrepareRequest::new( - seller, - root.clone(), - previous.clone(), - proposal.clone(), - )) - .expect("proposal plan"); - let revision_plan = client - .prepare_revision_decision(TradeRevisionDecisionPrepareRequest::new( - buyer.clone(), - root.clone(), - previous.clone(), - fixture_revision_decision(&proposal, &previous_event_id), - )) - .expect("revision plan"); - let cancellation_plan = client - .prepare_cancellation(TradeCancellationPrepareRequest::new( - buyer, - root, - previous, - fixture_cancellation("order-preflight-query-failure"), - )) - .expect("cancellation plan"); - - sdk._event_store.pool().close().await; - assert!(matches!( - client.require_decision_preflight(&decision_plan).await, - Err(RadrootsSdkError::Projection { .. }) - )); - assert!(matches!( - client - .require_revision_proposal_preflight(&proposal_plan) - .await, - Err(RadrootsSdkError::Projection { .. }) - )); - assert!(matches!( - client - .require_revision_decision_preflight(&revision_plan) - .await, - Err(RadrootsSdkError::Projection { .. }) - )); - assert!(matches!( - client - .require_cancellation_preflight(&cancellation_plan) - .await, - Err(RadrootsSdkError::Projection { .. }) - )); -} - -#[tokio::test] -async fn prepared_lifecycle_enqueues_report_closed_outbox_after_preflight() { - let proposal_sdk = prepared_order_sdk().await; - let proposal_submit = - enqueue_fixture_submit(&proposal_sdk, "order-closed-outbox-proposal").await; - let seller = fixture_seller_actor(); - let proposal_payload = fixture_revision_proposal( - "order-closed-outbox-proposal", - &proposal_submit.signed_event_id, - &proposal_submit.signed_event_id, - ); - let proposal_plan = proposal_sdk - .trades() - .prepare_revision_proposal(TradeRevisionProposalPrepareRequest::new( - seller.clone(), - fixture_order_event_ptr(&proposal_submit.signed_event_id), - fixture_order_event_ptr(&proposal_submit.signed_event_id), - proposal_payload, - )) - .expect("proposal plan"); - let proposal_events_before = local_event_count(&proposal_sdk).await; - proposal_sdk._outbox.pool().close().await; - let proposal_error = proposal_sdk - .trades() - .enqueue_prepared_revision_proposal_with_explicit_signer( - &seller, - proposal_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await - .expect_err("closed outbox proposal"); - assert_outbox_preflight_error(proposal_error); - assert_eq!( - local_event_count(&proposal_sdk).await, - proposal_events_before - ); - - let revision_sdk = prepared_order_sdk().await; - let revision_submit = - enqueue_fixture_submit(&revision_sdk, "order-closed-outbox-revision").await; - let proposal_payload = fixture_revision_proposal( - "order-closed-outbox-revision", - &revision_submit.signed_event_id, - &revision_submit.signed_event_id, - ); - let proposal_plan = revision_sdk - .trades() - .prepare_revision_proposal(TradeRevisionProposalPrepareRequest::new( - seller.clone(), - fixture_order_event_ptr(&revision_submit.signed_event_id), - fixture_order_event_ptr(&revision_submit.signed_event_id), - proposal_payload.clone(), - )) - .expect("revision proposal plan"); - let proposal = revision_sdk - .trades() - .enqueue_prepared_revision_proposal_with_explicit_signer( - &seller, - proposal_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue proposal"); - let buyer = fixture_buyer_actor(); - let revision_plan = revision_sdk - .trades() - .prepare_revision_decision(TradeRevisionDecisionPrepareRequest::new( - buyer.clone(), - fixture_order_event_ptr(&revision_submit.signed_event_id), - fixture_order_event_ptr(&proposal.signed_event_id), - fixture_revision_decision(&proposal_payload, &proposal.signed_event_id), - )) - .expect("revision plan"); - let revision_events_before = local_event_count(&revision_sdk).await; - revision_sdk._outbox.pool().close().await; - let revision_error = revision_sdk - .trades() - .enqueue_prepared_revision_decision_with_explicit_signer( - &buyer, - revision_plan.clone(), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) - .await - .expect_err("closed outbox revision"); - assert_outbox_preflight_error(revision_error); - assert_eq!( - local_event_count(&revision_sdk).await, - revision_events_before - ); - - let cancellation_sdk = prepared_order_sdk().await; - let cancellation_submit = - enqueue_fixture_submit(&cancellation_sdk, "order-closed-outbox-cancellation").await; - let cancellation_plan = cancellation_sdk - .trades() - .prepare_cancellation(TradeCancellationPrepareRequest::new( - buyer.clone(), - fixture_order_event_ptr(&cancellation_submit.signed_event_id), - fixture_order_event_ptr(&cancellation_submit.signed_event_id), - fixture_cancellation("order-closed-outbox-cancellation"), - )) - .expect("cancellation plan"); - let cancellation_events_before = local_event_count(&cancellation_sdk).await; - cancellation_sdk._outbox.pool().close().await; - let cancellation_error = cancellation_sdk - .trades() - .enqueue_prepared_cancellation_with_explicit_signer( - &buyer, - cancellation_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) - .await - .expect_err("closed outbox cancellation"); - assert_outbox_preflight_error(cancellation_error); - assert_eq!( - local_event_count(&cancellation_sdk).await, - cancellation_events_before - ); -} - -#[tokio::test] -async fn prepared_lifecycle_enqueues_skip_preflight_for_existing_events() { - let decision_sdk = configured_order_sdk(SELLER_SECRET_KEY_HEX).await; - let decision_submit = enqueue_fixture_submit(&decision_sdk, "order-existing-decision").await; - let seller = fixture_seller_actor(); - let decision_plan = decision_sdk - .trades() - .prepare_decision(TradeDecisionPrepareRequest::new( - seller.clone(), - fixture_order_event_ptr(&decision_submit.signed_event_id), - fixture_order_decision("order-existing-decision"), - )) - .expect("decision plan"); - decision_sdk - .trades() - .enqueue_prepared_decision_with_explicit_signer( - &seller, - decision_plan.clone(), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue decision"); - let decision_repeat = decision_sdk - .trades() - .enqueue_prepared_decision_with_explicit_signer( - &seller, - decision_plan.clone(), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await - .expect("repeat decision replay"); + let zero = validate_validation_receipt_limit(0).expect_err("zero limit"); assert_eq!( - decision_repeat.workflow.state, - SdkMutationState::AlreadyQueued - ); - assert!( - decision_repeat - .workflow - .idempotency - .replayed_existing_operation - ); - let configured_decision_repeat = decision_sdk - .trades() - .enqueue_prepared_decision( - &seller, - decision_plan.clone(), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - ) - .await - .expect("configured decision replay"); - assert_eq!( - configured_decision_repeat.workflow.state, - SdkMutationState::AlreadyQueued - ); - assert!( - configured_decision_repeat - .workflow - .idempotency - .replayed_existing_operation - ); - - let proposal_sdk = configured_order_sdk(SELLER_SECRET_KEY_HEX).await; - let proposal_submit = enqueue_fixture_submit(&proposal_sdk, "order-existing-proposal").await; - let proposal_payload = fixture_revision_proposal( - "order-existing-proposal", - &proposal_submit.signed_event_id, - &proposal_submit.signed_event_id, - ); - let proposal_plan = proposal_sdk - .trades() - .prepare_revision_proposal(TradeRevisionProposalPrepareRequest::new( - seller.clone(), - fixture_order_event_ptr(&proposal_submit.signed_event_id), - fixture_order_event_ptr(&proposal_submit.signed_event_id), - proposal_payload, - )) - .expect("proposal plan"); - proposal_sdk - .trades() - .enqueue_prepared_revision_proposal_with_explicit_signer( - &seller, - proposal_plan.clone(), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue proposal"); - let proposal_repeat = proposal_sdk - .trades() - .enqueue_prepared_revision_proposal_with_explicit_signer( - &seller, - proposal_plan.clone(), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await - .expect("repeat proposal replay"); - assert_eq!( - proposal_repeat.workflow.state, - SdkMutationState::AlreadyQueued - ); - assert!( - proposal_repeat - .workflow - .idempotency - .replayed_existing_operation - ); - let configured_proposal_repeat = proposal_sdk - .trades() - .enqueue_prepared_revision_proposal( - &seller, - proposal_plan.clone(), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - ) - .await - .expect("configured proposal replay"); - assert_eq!( - configured_proposal_repeat.workflow.state, - SdkMutationState::AlreadyQueued - ); - assert!( - configured_proposal_repeat - .workflow - .idempotency - .replayed_existing_operation + zero.to_string(), + format!("sdk order status limit invalid: limit=0, min=1, max={TRADE_STATUS_MAX_LIMIT}") ); - let revision_sdk = configured_order_sdk(BUYER_SECRET_KEY_HEX).await; - let revision_submit = enqueue_fixture_submit(&revision_sdk, "order-existing-revision").await; - let proposal_payload = fixture_revision_proposal( - "order-existing-revision", - &revision_submit.signed_event_id, - &revision_submit.signed_event_id, - ); - let proposal_plan = revision_sdk - .trades() - .prepare_revision_proposal(TradeRevisionProposalPrepareRequest::new( - seller.clone(), - fixture_order_event_ptr(&revision_submit.signed_event_id), - fixture_order_event_ptr(&revision_submit.signed_event_id), - proposal_payload.clone(), - )) - .expect("revision proposal plan"); - let proposal = revision_sdk - .trades() - .enqueue_prepared_revision_proposal_with_explicit_signer( - &seller, - proposal_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue proposal"); - let buyer = fixture_buyer_actor(); - let revision_plan = revision_sdk - .trades() - .prepare_revision_decision(TradeRevisionDecisionPrepareRequest::new( - buyer.clone(), - fixture_order_event_ptr(&revision_submit.signed_event_id), - fixture_order_event_ptr(&proposal.signed_event_id), - fixture_revision_decision(&proposal_payload, &proposal.signed_event_id), - )) - .expect("revision plan"); - revision_sdk - .trades() - .enqueue_prepared_revision_decision_with_explicit_signer( - &buyer, - revision_plan.clone(), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue revision"); - let revision_repeat = revision_sdk - .trades() - .enqueue_prepared_revision_decision_with_explicit_signer( - &buyer, - revision_plan.clone(), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) - .await - .expect("repeat revision replay"); - assert_eq!( - revision_repeat.workflow.state, - SdkMutationState::AlreadyQueued - ); - assert!( - revision_repeat - .workflow - .idempotency - .replayed_existing_operation - ); - let configured_revision_repeat = revision_sdk - .trades() - .enqueue_prepared_revision_decision( - &buyer, - revision_plan.clone(), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - ) - .await - .expect("configured revision replay"); + let over_max = + validate_validation_receipt_limit(TRADE_STATUS_MAX_LIMIT + 1).expect_err("over max"); assert_eq!( - configured_revision_repeat.workflow.state, - SdkMutationState::AlreadyQueued - ); - assert!( - configured_revision_repeat - .workflow - .idempotency - .replayed_existing_operation - ); - - let cancellation_sdk = configured_order_sdk(BUYER_SECRET_KEY_HEX).await; - let cancellation_submit = - enqueue_fixture_submit(&cancellation_sdk, "order-existing-cancellation").await; - let cancellation_plan = cancellation_sdk - .trades() - .prepare_cancellation(TradeCancellationPrepareRequest::new( - buyer.clone(), - fixture_order_event_ptr(&cancellation_submit.signed_event_id), - fixture_order_event_ptr(&cancellation_submit.signed_event_id), - fixture_cancellation("order-existing-cancellation"), - )) - .expect("cancellation plan"); - cancellation_sdk - .trades() - .enqueue_prepared_cancellation_with_explicit_signer( - &buyer, - cancellation_plan.clone(), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, - &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) - .await - .expect("enqueue cancellation"); - let cancellation_repeat = cancellation_sdk - .trades() - .enqueue_prepared_cancellation( - &buyer, - cancellation_plan, - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - None, + over_max.to_string(), + format!( + "sdk order status limit invalid: limit={}, min=1, max={TRADE_STATUS_MAX_LIMIT}", + TRADE_STATUS_MAX_LIMIT + 1 ) - .await - .expect("configured cancellation replay"); - assert_eq!( - cancellation_repeat.workflow.state, - SdkMutationState::AlreadyQueued - ); - assert!( - cancellation_repeat - .workflow - .idempotency - .replayed_existing_operation - ); -} - -#[tokio::test] -async fn order_ingest_and_enqueue_wrappers_report_prepare_timestamp_errors() { - let sdk = prepared_order_sdk().await; - let out_of_range = RadrootsSdkTimestamp::from_unix_seconds(u64::MAX); - let buyer = fixture_buyer_actor(); - let seller = fixture_seller_actor(); - - assert!(matches!( - sdk.trades() - .ingest_evidence( - TradeEvidenceIngestRequest::new(request_signed_event()) - .with_observed_at(out_of_range,) - ) - .await, - Err(RadrootsSdkError::TimestampOutOfRange { .. }) - )); - assert!(matches!( - sdk.trades() - .ingest_request_evidence( - TradeRequestEvidenceIngestRequest::new(request_signed_event()) - .with_observed_at(out_of_range,), - ) - .await, - Err(RadrootsSdkError::TimestampOutOfRange { .. }) - )); - assert!(matches!( - sdk.trades() - .enqueue_submit_with_explicit_signer( - TradeSubmitEnqueueRequest::new( - buyer.clone(), - fixture_event_ptr('a'), - fixture_order_request("order-wrapper-submit-error"), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .with_created_at(out_of_range), - &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) - .await, - Err(RadrootsSdkError::TimestampOutOfRange { .. }) - )); - assert!(matches!( - sdk.trades() - .enqueue_decision_with_explicit_signer( - TradeDecisionEnqueueRequest::new( - seller.clone(), - fixture_event_ptr('b'), - fixture_order_decision("order-wrapper-decision-error"), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .with_created_at(out_of_range), - &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await, - Err(RadrootsSdkError::TimestampOutOfRange { .. }) - )); - - let root_event_id = event_id('c'); - let previous_event_id = event_id('d'); - let proposal_payload = fixture_revision_proposal( - "order-wrapper-proposal-error", - &root_event_id, - &previous_event_id, - ); - assert!(matches!( - sdk.trades() - .enqueue_revision_proposal_with_explicit_signer( - TradeRevisionProposalEnqueueRequest::new( - seller.clone(), - ptr(root_event_id.as_str().to_owned()), - ptr(previous_event_id.as_str().to_owned()), - proposal_payload.clone(), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .with_created_at(out_of_range), - &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), - ) - .await, - Err(RadrootsSdkError::TimestampOutOfRange { .. }) - )); - assert!(matches!( - sdk.trades() - .enqueue_revision_decision_with_explicit_signer( - TradeRevisionDecisionEnqueueRequest::new( - buyer.clone(), - ptr(root_event_id.as_str().to_owned()), - ptr(previous_event_id.as_str().to_owned()), - fixture_revision_decision(&proposal_payload, &previous_event_id), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .with_created_at(out_of_range), - &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) - .await, - Err(RadrootsSdkError::TimestampOutOfRange { .. }) - )); - assert!(matches!( - sdk.trades() - .enqueue_cancellation_with_explicit_signer( - TradeCancellationEnqueueRequest::new( - buyer, - ptr(root_event_id.as_str().to_owned()), - ptr(previous_event_id.as_str().to_owned()), - fixture_cancellation("order-wrapper-cancellation-error"), - fixture_target_policy(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .with_created_at(out_of_range), - &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), - ) - .await, - Err(RadrootsSdkError::TimestampOutOfRange { .. }) - )); -} - -#[tokio::test] -async fn order_default_timestamp_paths_report_clock_errors() { - let clock_error_sdk = crate::RadrootsClient::builder() - .clock(crate::RadrootsSdkClock::BeforeUnixEpoch) - .build() - .await - .expect("sdk"); - let buyer = buyer_actor(); - let seller = seller_actor(); - let root_event_id = event_id('a'); - let previous_event_id = event_id('b'); - let proposal = revision_proposal_payload(&root_event_id, &previous_event_id); - - assert!(matches!( - clock_error_sdk - .trades() - .ingest_evidence(TradeEvidenceIngestRequest::new(request_signed_event())) - .await, - Err(RadrootsSdkError::ClockBeforeUnixEpoch) - )); - assert!(matches!( - clock_error_sdk - .trades() - .ingest_request_evidence(TradeRequestEvidenceIngestRequest::new( - request_signed_event() - )) - .await, - Err(RadrootsSdkError::ClockBeforeUnixEpoch) - )); - assert!(matches!( - clock_error_sdk - .trades() - .prepare_submit(TradeSubmitPrepareRequest::new( - buyer.clone(), - ptr(root_event_id.as_str().to_owned()), - order_request_payload(), - )), - Err(RadrootsSdkError::ClockBeforeUnixEpoch) - )); - assert!(matches!( - clock_error_sdk - .trades() - .prepare_decision(TradeDecisionPrepareRequest::new( - seller.clone(), - ptr(root_event_id.as_str().to_owned()), - order_decision_payload(), - )), - Err(RadrootsSdkError::ClockBeforeUnixEpoch) - )); - assert!(matches!( - clock_error_sdk.trades().prepare_revision_proposal( - TradeRevisionProposalPrepareRequest::new( - seller, - ptr(root_event_id.as_str().to_owned()), - ptr(previous_event_id.as_str().to_owned()), - proposal.clone(), - ), - ), - Err(RadrootsSdkError::ClockBeforeUnixEpoch) - )); - assert!(matches!( - clock_error_sdk.trades().prepare_revision_decision( - TradeRevisionDecisionPrepareRequest::new( - buyer.clone(), - ptr(root_event_id.as_str().to_owned()), - ptr(previous_event_id.as_str().to_owned()), - revision_decision_payload( - &proposal, - &previous_event_id, - RadrootsOrderRevisionOutcome::Accepted, - ), - ), - ), - Err(RadrootsSdkError::ClockBeforeUnixEpoch) - )); - assert!(matches!( - clock_error_sdk - .trades() - .prepare_cancellation(TradeCancellationPrepareRequest::new( - buyer, - ptr(root_event_id.as_str().to_owned()), - ptr(previous_event_id.as_str().to_owned()), - cancellation_payload(), - )), - Err(RadrootsSdkError::ClockBeforeUnixEpoch) - )); -} - -#[test] -fn order_runtime_request_builders_and_serializers_cover_source_attached_paths() { - let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_321); - let root_event_id = event_id('a'); - let previous_event_id = event_id('b'); - let root_event = ptr(root_event_id.as_str().to_owned()); - let previous_event = ptr(previous_event_id.as_str().to_owned()); - let proposal = revision_proposal_payload(&root_event_id, &previous_event_id); - let revision_decision = revision_decision_payload( - &proposal, - &event_id('c'), - RadrootsOrderRevisionOutcome::Declined { - reason: "not workable".to_owned(), - }, - ); - let policy = TargetPolicy::default_profile(); - - let submit_prepare = - TradeSubmitPrepareRequest::new(buyer_actor(), root_event.clone(), order_request_payload()) - .with_created_at(created_at); - assert_struct_serialize_error_paths(&submit_prepare, 4); - assert_eq!( - serde_json::to_value(&submit_prepare).expect("submit prepare json")["created_at"], - 1_700_000_321 - ); - - let submit_enqueue = TradeSubmitEnqueueRequest::new( - buyer_actor(), - root_event.clone(), - order_request_payload(), - policy.clone(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets(["wss://relay-a.radroots.test"], NostrRelayUrlPolicy::Public) - .expect("submit relays") - .with_idempotency_key(SdkIdempotencyKey::new("submit-unit-key").expect("key")) - .with_created_at(created_at); - assert_struct_serialize_error_paths(&submit_enqueue, 6); - - let request_ingest = - TradeRequestEvidenceIngestRequest::new(request_signed_event()).with_observed_at(created_at); - assert_struct_serialize_error_paths(&request_ingest, 2); - let evidence_ingest = - TradeEvidenceIngestRequest::new(request_signed_event()).with_observed_at(created_at); - assert_struct_serialize_error_paths(&evidence_ingest, 2); - - let decision_prepare = TradeDecisionPrepareRequest::new( - seller_actor(), - root_event.clone(), - order_decision_payload(), - ) - .with_created_at(created_at); - assert_struct_serialize_error_paths(&decision_prepare, 4); - - let decision_enqueue = TradeDecisionEnqueueRequest::new( - seller_actor(), - root_event.clone(), - order_decision_payload(), - policy.clone(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets(["wss://relay-b.radroots.test"], NostrRelayUrlPolicy::Public) - .expect("decision relays") - .with_idempotency_key(SdkIdempotencyKey::new("decision-unit-key").expect("key")) - .with_created_at(created_at); - assert_struct_serialize_error_paths(&decision_enqueue, 6); - - let proposal_prepare = TradeRevisionProposalPrepareRequest::new( - seller_actor(), - root_event.clone(), - previous_event.clone(), - proposal.clone(), - ) - .with_created_at(created_at); - assert_struct_serialize_error_paths(&proposal_prepare, 5); - - let proposal_enqueue = TradeRevisionProposalEnqueueRequest::new( - seller_actor(), - root_event.clone(), - previous_event.clone(), - proposal.clone(), - policy.clone(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets(["wss://relay-c.radroots.test"], NostrRelayUrlPolicy::Public) - .expect("proposal relays") - .with_idempotency_key(SdkIdempotencyKey::new("proposal-unit-key").expect("key")) - .with_created_at(created_at); - assert_struct_serialize_error_paths(&proposal_enqueue, 7); - - let revision_decision_prepare = TradeRevisionDecisionPrepareRequest::new( - buyer_actor(), - root_event.clone(), - previous_event.clone(), - revision_decision.clone(), - ) - .with_created_at(created_at); - assert_struct_serialize_error_paths(&revision_decision_prepare, 5); - - let revision_decision_enqueue = TradeRevisionDecisionEnqueueRequest::new( - buyer_actor(), - root_event.clone(), - previous_event.clone(), - revision_decision, - policy.clone(), - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets(["wss://relay-d.radroots.test"], NostrRelayUrlPolicy::Public) - .expect("revision decision relays") - .with_idempotency_key(SdkIdempotencyKey::new("revision-decision-unit-key").expect("key")) - .with_created_at(created_at); - assert_struct_serialize_error_paths(&revision_decision_enqueue, 7); - - let cancellation_prepare = TradeCancellationPrepareRequest::new( - buyer_actor(), - root_event.clone(), - previous_event.clone(), - cancellation_payload(), - ) - .with_created_at(created_at); - assert_struct_serialize_error_paths(&cancellation_prepare, 5); - - let cancellation_enqueue = TradeCancellationEnqueueRequest::new( - buyer_actor(), - root_event, - previous_event, - cancellation_payload(), - policy, - PublishMode::EnqueueOnly, - SatisfactionPolicy::NoWait, - ) - .try_with_nostr_targets(["wss://relay-e.radroots.test"], NostrRelayUrlPolicy::Public) - .expect("cancellation relays") - .with_idempotency_key(SdkIdempotencyKey::new("cancellation-unit-key").expect("key")) - .with_created_at(created_at); - assert_struct_serialize_error_paths(&cancellation_enqueue, 7); - - let parsed_status = TradeStatusRequest::parse(order_id().as_str()) - .expect("status request") - .with_limit(TRADE_STATUS_DEFAULT_LIMIT); - parsed_status.validate().expect("status validates"); - assert_eq!( - serde_json::to_value(&parsed_status).expect("status json")["limit"], - TRADE_STATUS_DEFAULT_LIMIT ); - - let issue = - SdkTradeStatusIssue::single(SdkTradeStatusIssueKind::ForkedLifecycle, event_id('f')); - assert_eq!(issue.code(), "forked_lifecycle"); - assert_struct_serialize_error_paths(&issue, 3); } #[test] -fn relay_outcome_labels_cover_resync_and_validation_receipt_variants() { +fn relay_outcome_labels_cover_current_variants() { for (kind, label) in [ (TradeResyncNostrRelayOutcomeKind::Eose, "eose"), (TradeResyncNostrRelayOutcomeKind::Closed, "closed"), @@ -4242,6 +33,20 @@ fn relay_outcome_labels_cover_resync_and_validation_receipt_variants() { } 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", @@ -4309,20 +114,6 @@ fn relay_outcome_labels_cover_resync_and_validation_receipt_variants() { } for (kind, label) in [ - (TradeValidationReceiptNostrRelayOutcomeKind::Eose, "eose"), - ( - TradeValidationReceiptNostrRelayOutcomeKind::Closed, - "closed", - ), - ( - TradeValidationReceiptNostrRelayOutcomeKind::Notice, - "notice", - ), - ] { - assert_eq!(kind.as_str(), label); - } - - for (kind, label) in [ ( TradeValidationReceiptNostrRelayTransportOutcomeKind::Accepted, "accepted", @@ -4396,72 +187,12 @@ fn relay_outcome_labels_cover_resync_and_validation_receipt_variants() { } } -#[tokio::test] -async fn closed_event_store_errors_are_mapped_for_ingest_and_prepared_lookup() { - let sdk = crate::RadrootsClient::builder().build().await.expect("sdk"); - sdk._event_store.pool().close().await; - let ingest_error = sdk - .trades() - .ingest_evidence(TradeEvidenceIngestRequest::new(request_signed_event())) - .await - .expect_err("closed ingest evidence"); - assert!(matches!(ingest_error, RadrootsSdkError::EventStore { .. })); - - let sdk = crate::RadrootsClient::builder().build().await.expect("sdk"); - sdk._event_store.pool().close().await; - let request_ingest_error = sdk - .trades() - .ingest_request_evidence(TradeRequestEvidenceIngestRequest::new( - request_signed_event(), - )) - .await - .expect_err("closed request evidence ingest"); - assert!(matches!( - request_ingest_error, - RadrootsSdkError::EventStore { .. } - )); - - let sdk = crate::RadrootsClient::builder().build().await.expect("sdk"); - sdk._event_store.pool().close().await; - let lookup_error = sdk - .trades() - .prepared_order_event_exists(&event_id('a')) - .await - .expect_err("closed prepared lookup"); - assert!(matches!(lookup_error, RadrootsSdkError::EventStore { .. })); -} - -#[tokio::test] -async fn order_status_and_evidence_ingest_cover_source_attached_success_paths() { - let sdk = prepared_order_sdk().await; - let request_event = request_signed_event(); - let request_receipt = sdk - .trades() - .ingest_request_evidence(TradeRequestEvidenceIngestRequest::new( - request_event.clone(), - )) - .await - .expect("request evidence ingest"); - assert!(request_receipt.inserted); - assert_eq!(request_receipt.order_id, order_id()); - - let submit = enqueue_fixture_submit(&sdk, "order-status-source-attached").await; - let status = sdk - .trades() - .status(TradeStatusRequest::parse(submit.order_id.as_str()).expect("status request")) - .await - .expect("order status"); - assert_eq!(status.status, TradeStatusKind::Requested); - assert!(status.evidence.has_request); - - let duplicate_receipt = sdk - .trades() - .ingest_evidence(TradeEvidenceIngestRequest::new(request_event)) - .await - .expect("order evidence ingest"); - assert!(!duplicate_receipt.inserted); +#[test] +fn camel_to_snake_converts_debug_case_labels() { + assert_eq!(camel_to_snake("AwaitValidation"), "await_validation"); assert_eq!( - duplicate_receipt.local_event_seq, - request_receipt.local_event_seq + camel_to_snake("InspectEvidenceIssues"), + "inspect_evidence_issues" ); + assert_eq!(camel_to_snake("Terminal"), "terminal"); } diff --git a/crates/sdk/tests/unit/private_store_tests.rs b/crates/sdk/tests/unit/private_store_tests.rs @@ -194,7 +194,7 @@ async fn private_store_status_update_delete_and_pragmas_round_trip() { } #[tokio::test] -async fn private_store_file_open_materializes_label_column_for_existing_stores() { +async fn private_store_file_open_rejects_pre_label_schema_without_repair() { let tempdir = tempfile::tempdir().expect("tempdir"); let path = tempdir.path().join("private.sqlite"); let options = SqliteConnectOptions::new() @@ -231,18 +231,8 @@ async fn private_store_file_open_materializes_label_column_for_existing_stores() let store = SdkPrivateStore::open_file(&path).await.expect("open store"); let record = private_location_record(); - store - .upsert_farm_location(&record) - .await - .expect("upsert labeled location"); - assert_eq!( - store - .farm_location(&record.farm_addr) - .await - .expect("read labeled location") - .expect("stored location") - .label - .as_deref(), - Some("Main pickup point") - ); + assert!(matches!( + store.upsert_farm_location(&record).await, + Err(RadrootsSdkError::PrivateStore { .. }) + )); } diff --git a/crates/sdk/tests/unit/runtime_tests.rs b/crates/sdk/tests/unit/runtime_tests.rs @@ -39,6 +39,21 @@ fn assert_private_store_error<T>(result: Result<T, RadrootsSdkError>) { } } +fn assert_studio_store_error<T>(result: Result<T, RadrootsSdkError>) { + match result { + Err(RadrootsSdkError::StudioStore { .. }) => {} + Err(other) => panic!("expected studio store error, got {other:?}"), + Ok(_) => panic!("expected studio store error"), + } +} + +fn assert_unsupported_profile_error<T>(result: Result<T, RadrootsSdkError>) -> PathBuf { + match result.err().expect("expected unsupported profile error") { + RadrootsSdkError::UnsupportedProfileSchema { path, .. } => path, + other => panic!("expected unsupported profile error, got {other:?}"), + } +} + fn sqlite_status() -> SdkSqliteStoreStatus { SdkSqliteStoreStatus { schema_version: 1, @@ -108,6 +123,10 @@ fn storage_status() -> StorageStatusReceipt { store: private_sqlite_status(), farm_private_locations: 0, }, + studio_store: SdkStudioStoreStorageStatus { + store: sqlite_status(), + studio_state_records: 0, + }, } } @@ -116,9 +135,11 @@ fn verification(event_store_ok: bool, outbox_ok: bool) -> SdkBackupVerification event_store_ok, outbox_ok, private_store_ok: true, + studio_store_ok: true, event_store_events: 0, outbox_events: 0, private_farm_locations: 0, + studio_state_records: 0, } } @@ -154,9 +175,9 @@ fn manifest() -> SdkBackupManifest { source_storage: SdkStorageKind::Memory, source_paths: None, backup_paths: RadrootsSdkStoragePaths { - event_store_path: PathBuf::from(EVENT_STORE_BACKUP_FILE), - outbox_path: PathBuf::from(OUTBOX_BACKUP_FILE), - private_store_path: PathBuf::from(PRIVATE_STORE_BACKUP_FILE), + runtime_path: PathBuf::from(RUNTIME_SQLITE_FILE), + studio_path: PathBuf::from(STUDIO_SQLITE_FILE), + private_path: PathBuf::from(PRIVATE_SQLITE_FILE), }, source_status: storage_status(), backup_verification: verification(true, true), @@ -294,6 +315,7 @@ async fn open_storage_and_storage_kind_cover_memory_directory_and_file_failures( _event_store: memory.event_store, _outbox: memory.outbox, _private_store: memory.private_store, + _studio_store: memory.studio_store, storage_paths: None, geonames: None, clock: RadrootsSdkClock::Fixed(RadrootsSdkTimestamp::from_unix_seconds(1)), @@ -309,13 +331,14 @@ async fn open_storage_and_storage_kind_cover_memory_directory_and_file_failures( .await .expect("directory storage"); let directory_paths = directory_storage.paths.expect("directory paths"); - assert!(directory_paths.event_store_path.exists()); - assert!(directory_paths.outbox_path.exists()); - assert!(directory_paths.private_store_path.exists()); + assert!(directory_paths.runtime_path.exists()); + assert!(directory_paths.private_path.exists()); + assert!(directory_paths.studio_path.exists()); let directory_sdk = RadrootsClient { _event_store: directory_storage.event_store, _outbox: directory_storage.outbox, _private_store: directory_storage.private_store, + _studio_store: directory_storage.studio_store, storage_paths: Some(directory_paths), geonames: None, clock: RadrootsSdkClock::Fixed(RadrootsSdkTimestamp::from_unix_seconds(1)), @@ -331,18 +354,18 @@ async fn open_storage_and_storage_kind_cover_memory_directory_and_file_failures( 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(EVENT_STORE_BACKUP_FILE)) + 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); - let outbox_directory = tempdir.path().join("outbox-directory"); - fs::create_dir(&outbox_directory).expect("outbox dir"); - fs::create_dir(outbox_directory.join(OUTBOX_BACKUP_FILE)).expect("outbox file slot dir"); - assert_outbox_error(open_directory_storage(&outbox_directory).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); 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_STORE_BACKUP_FILE)) + 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); } @@ -565,6 +588,74 @@ fn sqlite_wal_checkpoint_receipt_mapping_covers_edge_states() { } #[tokio::test] +async fn storage_status_inspection_is_read_only_and_never_creates_missing_profiles() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let missing = tempdir.path().join("missing-profile"); + assert_event_store_error( + RadrootsClient::inspect_storage_status(&missing, StorageStatusRequest::new()).await, + ); + assert!(!missing.exists()); + + let pre_v1_profile = tempdir.path().join("pre-v1"); + fs::create_dir(&pre_v1_profile).expect("pre-v1 profile"); + fs::write(pre_v1_profile.join("event_store.sqlite"), b"old runtime").expect("old event store"); + let unsupported = assert_unsupported_profile_error( + RadrootsClient::inspect_storage_status(&pre_v1_profile, StorageStatusRequest::new()).await, + ); + assert_eq!(unsupported, pre_v1_profile.join("event_store.sqlite")); + assert!(!pre_v1_profile.join(RUNTIME_SQLITE_FILE).exists()); + assert!(!pre_v1_profile.join(PRIVATE_SQLITE_FILE).exists()); + assert!(!pre_v1_profile.join(STUDIO_SQLITE_FILE).exists()); +} + +#[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; @@ -595,13 +686,13 @@ async fn storage_status_integrity_and_backup_map_closed_pool_errors() { outbox_closed .storage_status(StorageStatusRequest::new()) .await, - Err(RadrootsSdkError::Outbox { .. }) + Err(RadrootsSdkError::EventStore { .. }) )); assert_outbox_error(outbox_sqlite_status(&outbox_closed._outbox).await); assert_outbox_error(outbox_status_summary(&outbox_closed._outbox, 1).await); assert!(matches!( outbox_closed.integrity(IntegrityRequest::new()).await, - Err(RadrootsSdkError::Outbox { .. }) + Err(RadrootsSdkError::EventStore { .. }) )); let private_store_closed = RadrootsClient::builder().build().await.expect("sdk"); @@ -709,19 +800,19 @@ async fn storage_status_integrity_and_backup_map_closed_pool_errors() { let event_store = RadrootsEventStore::open_memory() .await .expect("event store"); - let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); let private_store = SdkPrivateStore::open_memory().await.expect("private store"); + let studio_store = SdkStudioStore::open_memory().await.expect("studio store"); private_store.pool().close().await; let tempdir = tempfile::tempdir().expect("tempdir"); assert_private_store_error( backup_sqlite_stores( event_store.pool(), - outbox.pool(), private_store.pool(), + studio_store.pool(), &RadrootsSdkStoragePaths { - event_store_path: tempdir.path().join(EVENT_STORE_BACKUP_FILE), - outbox_path: tempdir.path().join(OUTBOX_BACKUP_FILE), - private_store_path: tempdir.path().join(PRIVATE_STORE_BACKUP_FILE), + runtime_path: tempdir.path().join(RUNTIME_SQLITE_FILE), + studio_path: tempdir.path().join(STUDIO_SQLITE_FILE), + private_path: tempdir.path().join(PRIVATE_SQLITE_FILE), }, ) .await, @@ -742,26 +833,26 @@ async fn verify_backup_paths_reports_each_store_member_failure() { .await .expect("backup"); - let bad_event_store_path = backup_destination.join("bad-event-store.sqlite"); - fs::create_dir(&bad_event_store_path).expect("bad event store dir"); - let bad_outbox_path = backup_destination.join("bad-outbox.sqlite"); - fs::create_dir(&bad_outbox_path).expect("bad outbox dir"); - let bad_private_store_path = backup_destination.join("bad-private.sqlite"); - fs::create_dir(&bad_private_store_path).expect("bad private store dir"); + let bad_runtime_path = backup_destination.join("bad-event-store.sqlite"); + fs::create_dir(&bad_runtime_path).expect("bad event store dir"); + let bad_studio_path = backup_destination.join("bad-studio.sqlite"); + fs::create_dir(&bad_studio_path).expect("bad studio dir"); + let bad_private_path = backup_destination.join("bad-private.sqlite"); + fs::create_dir(&bad_private_path).expect("bad private store dir"); let mut invalid_member = RadrootsSdkStoragePaths { - event_store_path: bad_event_store_path, - outbox_path: backup_destination.join(OUTBOX_BACKUP_FILE), - private_store_path: backup_destination.join(PRIVATE_STORE_BACKUP_FILE), + runtime_path: bad_runtime_path, + studio_path: backup_destination.join(STUDIO_SQLITE_FILE), + private_path: backup_destination.join(PRIVATE_SQLITE_FILE), }; assert_event_store_error(verify_backup_paths(&invalid_member).await); - invalid_member.event_store_path = backup_destination.join(EVENT_STORE_BACKUP_FILE); - invalid_member.outbox_path = bad_outbox_path; - assert_outbox_error(verify_backup_paths(&invalid_member).await); + invalid_member.runtime_path = backup_destination.join(RUNTIME_SQLITE_FILE); + invalid_member.studio_path = bad_studio_path; + assert_studio_store_error(verify_backup_paths(&invalid_member).await); - invalid_member.outbox_path = backup_destination.join(OUTBOX_BACKUP_FILE); - invalid_member.private_store_path = bad_private_store_path; + invalid_member.studio_path = backup_destination.join(STUDIO_SQLITE_FILE); + invalid_member.private_path = bad_private_path; assert_private_store_error(verify_backup_paths(&invalid_member).await); } @@ -771,16 +862,14 @@ fn restore_archive_path_validators_cover_missing_outside_and_manifest_edges() { let source = tempdir.path().join("source"); fs::create_dir(&source).expect("source"); let source_root = canonical_restore_directory(&source).expect("canonical source"); - let file_member = source.join(EVENT_STORE_BACKUP_FILE); + let file_member = source.join(RUNTIME_SQLITE_FILE); fs::write(&file_member, b"sqlite").expect("file member"); let outside_file = tempdir.path().join("outside.sqlite"); fs::write(&outside_file, b"sqlite").expect("outside file"); let dir_member = source.join("dir-member"); fs::create_dir(&dir_member).expect("dir member"); - assert!( - validate_relative_archive_path(Path::new(EVENT_STORE_BACKUP_FILE), "event store").is_ok() - ); + assert!(validate_relative_archive_path(Path::new(RUNTIME_SQLITE_FILE), "event store").is_ok()); assert!( invalid_request_message(validate_relative_archive_path(Path::new(""), "event store")) .contains("must not be empty") @@ -818,12 +907,8 @@ fn restore_archive_path_validators_cover_missing_outside_and_manifest_edges() { .contains("No such") ); assert!( - restore_archive_member_path( - &source_root, - Path::new(EVENT_STORE_BACKUP_FILE), - "event store", - ) - .is_ok() + restore_archive_member_path(&source_root, Path::new(RUNTIME_SQLITE_FILE), "event store",) + .is_ok() ); assert!( invalid_request_message(restore_archive_member_path( @@ -886,16 +971,16 @@ fn restore_destination_preflight_covers_empty_existing_new_and_overlap_paths() { let paths = preflight_restore_destination(&source, &new_destination, false).expect("new preflight"); assert_eq!( - paths.event_store_path, - new_destination.join(EVENT_STORE_BACKUP_FILE) + paths.runtime_path, + new_destination.join(RUNTIME_SQLITE_FILE) ); let nested_new_destination = parent.join("nested").join("new-destination"); fs::create_dir(nested_new_destination.parent().expect("nested parent")).expect("nested parent"); let nested_paths = preflight_restore_destination(&source, &nested_new_destination, false) .expect("nested new preflight"); assert_eq!( - nested_paths.private_store_path, - nested_new_destination.join(PRIVATE_STORE_BACKUP_FILE) + nested_paths.private_path, + nested_new_destination.join(PRIVATE_SQLITE_FILE) ); let relative_destination = PathBuf::from(format!( "relative-restore-{}", @@ -904,8 +989,8 @@ fn restore_destination_preflight_covers_empty_existing_new_and_overlap_paths() { let relative_paths = preflight_restore_destination(&source, &relative_destination, false) .expect("relative preflight"); assert_eq!( - relative_paths.event_store_path, - relative_destination.join(EVENT_STORE_BACKUP_FILE) + relative_paths.runtime_path, + relative_destination.join(RUNTIME_SQLITE_FILE) ); let file_source = tempdir.path().join("file-source"); @@ -1064,9 +1149,9 @@ fn backup_destination_and_restore_file_helpers_cover_cleanup_and_io_edges() { io_message(copy_restore_file( &tempdir.path().join("missing-source"), &tempdir.path().join("copy-destination"), - "event store", + "runtime store", )) - .contains("restore event store copy failed") + .contains("restore runtime store copy failed") ); assert!( io_message(rename_restore_path( @@ -1310,42 +1395,41 @@ async fn sqlite_backup_errors_cover_invalid_paths_and_execute_failures() { SqliteStoreRole::EventStore, ) .await - .err() - .expect("sqlite error"); + .expect_err("sqlite error"); assert!(matches!( error, RadrootsSdkError::EventStore { message } if message.contains("backup failed") )); let backup_paths = RadrootsSdkStoragePaths { - event_store_path: tempdir.path().join("closed-event-store-backup.sqlite"), - outbox_path: tempdir.path().join("closed-event-store-outbox.sqlite"), - private_store_path: tempdir.path().join("closed-event-store-private.sqlite"), + runtime_path: tempdir.path().join("closed-event-store-backup.sqlite"), + studio_path: tempdir.path().join("closed-event-store-outbox.sqlite"), + private_path: tempdir.path().join("closed-event-store-private.sqlite"), }; assert_event_store_error( backup_sqlite_stores( storage.event_store.pool(), - storage.outbox.pool(), storage.private_store.pool(), + storage.studio_store.pool(), &backup_paths, ) .await, ); - let outbox_closed_storage = open_storage(&RadrootsSdkStorageConfig::Memory) + let studio_closed_storage = open_storage(&RadrootsSdkStorageConfig::Memory) .await - .expect("outbox closed storage"); - outbox_closed_storage.outbox.pool().close().await; - let outbox_closed_paths = RadrootsSdkStoragePaths { - event_store_path: tempdir.path().join("open-event-store-backup.sqlite"), - outbox_path: tempdir.path().join("closed-outbox-backup.sqlite"), - private_store_path: tempdir.path().join("outbox-closed-private.sqlite"), + .expect("studio closed storage"); + studio_closed_storage.studio_store.pool().close().await; + let studio_closed_paths = RadrootsSdkStoragePaths { + runtime_path: tempdir.path().join("open-runtime-backup.sqlite"), + studio_path: tempdir.path().join("closed-studio-backup.sqlite"), + private_path: tempdir.path().join("studio-closed-private.sqlite"), }; - assert_outbox_error( + assert_studio_store_error( backup_sqlite_stores( - outbox_closed_storage.event_store.pool(), - outbox_closed_storage.outbox.pool(), - outbox_closed_storage.private_store.pool(), - &outbox_closed_paths, + studio_closed_storage.event_store.pool(), + studio_closed_storage.private_store.pool(), + studio_closed_storage.studio_store.pool(), + &studio_closed_paths, ) .await, ); @@ -1353,9 +1437,9 @@ async fn sqlite_backup_errors_cover_invalid_paths_and_execute_failures() { !io_message(write_backup_receipt( tempdir.path().join("receipt-destination"), RadrootsSdkStoragePaths { - event_store_path: tempdir.path().join(EVENT_STORE_BACKUP_FILE), - outbox_path: tempdir.path().join(OUTBOX_BACKUP_FILE), - private_store_path: tempdir.path().join(PRIVATE_STORE_BACKUP_FILE), + runtime_path: tempdir.path().join(RUNTIME_SQLITE_FILE), + studio_path: tempdir.path().join(STUDIO_SQLITE_FILE), + private_path: tempdir.path().join(PRIVATE_SQLITE_FILE), }, tempdir.path().to_path_buf(), manifest(), @@ -1401,43 +1485,42 @@ async fn restore_archive_private_failures_cover_staging_and_verification_edges() let missing_archive = RestoreArchive { source: tempdir.path().join("missing-archive"), - event_store_path: tempdir.path().join("missing-event-store.sqlite"), - outbox_path: tempdir.path().join("missing-outbox.sqlite"), - private_store_path: tempdir.path().join("missing-private.sqlite"), + runtime_path: tempdir.path().join("missing-event-store.sqlite"), + studio_path: tempdir.path().join("missing-outbox.sqlite"), + private_path: tempdir.path().join("missing-private.sqlite"), manifest_path: tempdir.path().join(BACKUP_MANIFEST_FILE), manifest: manifest(), verification: verification(true, true), }; let staging_paths = RadrootsSdkStoragePaths { - event_store_path: tempdir.path().join("staging-event-store.sqlite"), - outbox_path: tempdir.path().join("staging-outbox.sqlite"), - private_store_path: tempdir.path().join("staging-private.sqlite"), + runtime_path: tempdir.path().join("staging-event-store.sqlite"), + studio_path: tempdir.path().join("staging-outbox.sqlite"), + private_path: tempdir.path().join("staging-private.sqlite"), }; assert!( io_message(copy_restore_archive_to_staging(&missing_archive, &staging_paths).await) - .contains("restore event store copy failed") + .contains("restore runtime store copy failed") ); let partial_archive = RestoreArchive { - event_store_path: staging_paths.event_store_path.clone(), + runtime_path: staging_paths.runtime_path.clone(), ..missing_archive.clone() }; - fs::write(&partial_archive.event_store_path, b"not sqlite").expect("partial event store"); + fs::write(&partial_archive.runtime_path, b"not sqlite").expect("partial event store"); assert!( io_message(copy_restore_archive_to_staging(&partial_archive, &staging_paths).await) - .contains("restore outbox copy failed") + .contains("restore studio copy failed") ); let private_partial_archive = RestoreArchive { - event_store_path: tempdir.path().join("private-partial-event-store.sqlite"), - outbox_path: tempdir.path().join("private-partial-outbox.sqlite"), + runtime_path: tempdir.path().join("private-partial-event-store.sqlite"), + studio_path: tempdir.path().join("private-partial-outbox.sqlite"), ..missing_archive.clone() }; - fs::write(&private_partial_archive.event_store_path, b"event store") - .expect("private partial event store"); - fs::write(&private_partial_archive.outbox_path, b"outbox").expect("private partial outbox"); + fs::write(&private_partial_archive.runtime_path, b"runtime").expect("private partial runtime"); + fs::write(&private_partial_archive.studio_path, b"studio").expect("private partial studio"); let private_staging_paths = RadrootsSdkStoragePaths { - event_store_path: tempdir.path().join("private-staging-event-store.sqlite"), - outbox_path: tempdir.path().join("private-staging-outbox.sqlite"), - private_store_path: tempdir.path().join("private-staging-missing.sqlite"), + runtime_path: tempdir.path().join("private-staging-event-store.sqlite"), + studio_path: tempdir.path().join("private-staging-outbox.sqlite"), + private_path: tempdir.path().join("private-staging-missing.sqlite"), }; assert!( io_message( @@ -1447,14 +1530,14 @@ async fn restore_archive_private_failures_cover_staging_and_verification_edges() .contains("restore private store copy failed") ); let corrupt_archive = RestoreArchive { - event_store_path: tempdir.path().join("corrupt-event-store.sqlite"), - outbox_path: tempdir.path().join("corrupt-outbox.sqlite"), - private_store_path: tempdir.path().join("corrupt-private.sqlite"), + runtime_path: tempdir.path().join("corrupt-event-store.sqlite"), + studio_path: tempdir.path().join("corrupt-outbox.sqlite"), + private_path: tempdir.path().join("corrupt-private.sqlite"), ..missing_archive.clone() }; - fs::write(&corrupt_archive.event_store_path, b"not sqlite").expect("corrupt event store"); - fs::write(&corrupt_archive.outbox_path, b"not sqlite").expect("corrupt outbox"); - fs::write(&corrupt_archive.private_store_path, b"not sqlite").expect("corrupt private store"); + fs::write(&corrupt_archive.runtime_path, b"not sqlite").expect("corrupt runtime"); + fs::write(&corrupt_archive.studio_path, b"not sqlite").expect("corrupt studio"); + fs::write(&corrupt_archive.private_path, b"not sqlite").expect("corrupt private store"); assert_event_store_error( copy_restore_archive_to_staging(&corrupt_archive, &staging_paths).await, ); @@ -1465,38 +1548,38 @@ async fn restore_archive_private_failures_cover_staging_and_verification_edges() .contains("parent is required") ); - let invalid_outbox_member_source = tempdir.path().join("invalid-outbox-member"); - fs::create_dir(&invalid_outbox_member_source).expect("invalid outbox source"); + let invalid_studio_member_source = tempdir.path().join("invalid-studio-member"); + fs::create_dir(&invalid_studio_member_source).expect("invalid studio source"); fs::write( - invalid_outbox_member_source.join(EVENT_STORE_BACKUP_FILE), + invalid_studio_member_source.join(RUNTIME_SQLITE_FILE), b"not sqlite", ) - .expect("event store member"); - let mut invalid_outbox_manifest = manifest(); - invalid_outbox_manifest.backup_paths.outbox_path = PathBuf::from("../outside.sqlite"); + .expect("runtime member"); + let mut invalid_studio_manifest = manifest(); + invalid_studio_manifest.backup_paths.studio_path = PathBuf::from("../outside.sqlite"); write_backup_manifest( - &invalid_outbox_member_source.join(BACKUP_MANIFEST_FILE), - &invalid_outbox_manifest, + &invalid_studio_member_source.join(BACKUP_MANIFEST_FILE), + &invalid_studio_manifest, ) - .expect("invalid outbox manifest"); + .expect("invalid studio manifest"); assert!( - invalid_request_message(inspect_restore_archive(invalid_outbox_member_source).await) - .contains("outbox archive path") + invalid_request_message(inspect_restore_archive(invalid_studio_member_source).await) + .contains("studio archive path") ); let invalid_private_member_source = tempdir.path().join("invalid-private-member"); fs::create_dir(&invalid_private_member_source).expect("invalid private source"); fs::write( - invalid_private_member_source.join(EVENT_STORE_BACKUP_FILE), + invalid_private_member_source.join(RUNTIME_SQLITE_FILE), b"not sqlite", ) - .expect("invalid private event store member"); + .expect("invalid private runtime member"); fs::write( - invalid_private_member_source.join(OUTBOX_BACKUP_FILE), + invalid_private_member_source.join(STUDIO_SQLITE_FILE), b"not sqlite", ) - .expect("invalid private outbox member"); + .expect("invalid private studio member"); let mut invalid_private_manifest = manifest(); - invalid_private_manifest.backup_paths.private_store_path = PathBuf::from("../outside.sqlite"); + invalid_private_manifest.backup_paths.private_path = PathBuf::from("../outside.sqlite"); write_backup_manifest( &invalid_private_member_source.join(BACKUP_MANIFEST_FILE), &invalid_private_manifest, @@ -1511,9 +1594,9 @@ async fn restore_archive_private_failures_cover_staging_and_verification_edges() fs::create_dir(&protected_parent).expect("protected parent"); let protected_destination = protected_parent.join("restore"); let protected_paths = RadrootsSdkStoragePaths { - event_store_path: protected_destination.join(EVENT_STORE_BACKUP_FILE), - outbox_path: protected_destination.join(OUTBOX_BACKUP_FILE), - private_store_path: protected_destination.join(PRIVATE_STORE_BACKUP_FILE), + runtime_path: protected_destination.join(RUNTIME_SQLITE_FILE), + studio_path: protected_destination.join(STUDIO_SQLITE_FILE), + private_path: protected_destination.join(PRIVATE_SQLITE_FILE), }; set_mode(&protected_parent, 0o500); let protected_result = @@ -1545,18 +1628,18 @@ async fn restore_archive_private_failures_cover_staging_and_verification_edges() .await; set_mode(&public_protected_parent, 0o700); assert!(!io_message(public_protected_result).is_empty()); - let missing_outbox_paths = RadrootsSdkStoragePaths { - event_store_path: archive.event_store_path.clone(), - outbox_path: tempdir.path().to_path_buf(), - private_store_path: archive.private_store_path.clone(), + let missing_studio_paths = RadrootsSdkStoragePaths { + runtime_path: archive.runtime_path.clone(), + studio_path: tempdir.path().to_path_buf(), + private_path: archive.private_path.clone(), }; - assert!(verify_backup_paths(&missing_outbox_paths).await.is_err()); + assert!(verify_backup_paths(&missing_studio_paths).await.is_err()); let invalid_destination = tempdir.path().join(nul_path()); let invalid_paths = RadrootsSdkStoragePaths { - event_store_path: invalid_destination.join(EVENT_STORE_BACKUP_FILE), - outbox_path: invalid_destination.join(OUTBOX_BACKUP_FILE), - private_store_path: invalid_destination.join(PRIVATE_STORE_BACKUP_FILE), + runtime_path: invalid_destination.join(RUNTIME_SQLITE_FILE), + studio_path: invalid_destination.join(STUDIO_SQLITE_FILE), + private_path: invalid_destination.join(PRIVATE_SQLITE_FILE), }; let invalid_restore_message = io_message( restore_archive_to_destination(&archive, &invalid_destination, &invalid_paths).await, @@ -1572,13 +1655,9 @@ async fn restore_archive_private_failures_cover_staging_and_verification_edges() restore_archive_to_destination(&archive, &existing_destination, &existing_paths) .await .expect("overwrite existing restore"); - assert!(existing_destination.join(EVENT_STORE_BACKUP_FILE).exists()); - assert!(existing_destination.join(OUTBOX_BACKUP_FILE).exists()); - assert!( - existing_destination - .join(PRIVATE_STORE_BACKUP_FILE) - .exists() - ); + assert!(existing_destination.join(RUNTIME_SQLITE_FILE).exists()); + assert!(existing_destination.join(STUDIO_SQLITE_FILE).exists()); + assert!(existing_destination.join(PRIVATE_SQLITE_FILE).exists()); let mut mismatch_archive = archive.clone(); mismatch_archive.verification.event_store_events += 1; @@ -1651,9 +1730,9 @@ async fn restore_archive_private_failures_cover_staging_and_verification_edges() assert_ne!(archive.verification, populated_archive.verification); let verification_mismatch_destination = tempdir.path().join("verification-mismatch-restore"); let wrong_destination_paths = RadrootsSdkStoragePaths { - event_store_path: populated_archive.event_store_path.clone(), - outbox_path: populated_archive.outbox_path.clone(), - private_store_path: populated_archive.private_store_path.clone(), + runtime_path: populated_archive.runtime_path.clone(), + studio_path: populated_archive.studio_path.clone(), + private_path: populated_archive.private_path.clone(), }; assert!( invalid_request_message( @@ -1673,8 +1752,8 @@ async fn restore_archive_private_failures_cover_staging_and_verification_edges() preflight_restore_destination(&archive.source, &bad_verify_destination, false) .expect("bad verify preflight"); let mut mismatched_verify_paths = bad_verify_paths.clone(); - mismatched_verify_paths.event_store_path = bad_verify_destination.clone(); - mismatched_verify_paths.outbox_path = bad_verify_destination.join(OUTBOX_BACKUP_FILE); + mismatched_verify_paths.runtime_path = bad_verify_destination.clone(); + mismatched_verify_paths.studio_path = bad_verify_destination.join(STUDIO_SQLITE_FILE); assert!( restore_archive_to_destination( &archive, diff --git a/crates/sdk/tests/unit/signer_provider_tests.rs b/crates/sdk/tests/unit/signer_provider_tests.rs @@ -369,7 +369,7 @@ fn signer_provider_reports_myc_status_capability_and_constructor_errors() { transport.clone(), ) .expect("signer"); - let provider = RadrootsSdkSignerProvider::MycNip46(signer); + let provider = RadrootsSdkSignerProvider::MycNip46(Box::new(signer)); assert_eq!(provider.mode(), RadrootsSdkSignerMode::MycNip46); assert_eq!( @@ -542,7 +542,7 @@ async fn myc_nip46_provider_signs_and_validates_remote_event() { let signer = RadrootsSdkMycNip46Signer::new(client_keys, target, USER_PUBLIC_KEY_HEX, transport.clone()) .expect("signer"); - let provider = RadrootsSdkSignerProvider::MycNip46(signer); + let provider = RadrootsSdkSignerProvider::MycNip46(Box::new(signer)); assert_eq!( provider.capability().nip46_permissions, radroots_sdk_myc_nip46_product_permission_strings() diff --git a/crates/sdk/tests/unit/sync_runtime_tests.rs b/crates/sdk/tests/unit/sync_runtime_tests.rs @@ -770,7 +770,7 @@ async fn sync_status_maps_closed_store_errors() { .sync() .status(super::SyncStatusRequest::new()) .await, - Err(RadrootsSdkError::Outbox { .. }) + Err(RadrootsSdkError::EventStore { .. }) )); } diff --git a/crates/sdk/tests/unit/transport_tests.rs b/crates/sdk/tests/unit/transport_tests.rs @@ -9,10 +9,7 @@ use radroots_transport::{ RadrootsTransportTarget, RadrootsTransportTargetFingerprint, RadrootsTransportTargetUri, }; -#[path = "../support/serializer_failure.rs"] -mod serializer_failure; - -use serializer_failure::assert_struct_serialize_error_paths; +use crate::serializer_failure::assert_struct_serialize_error_paths; #[test] fn publish_mode_and_ack_policy_serialize_explicit_product_contracts() { diff --git a/crates/sdk/tests/unit/workflow_runtime_tests.rs b/crates/sdk/tests/unit/workflow_runtime_tests.rs @@ -14,6 +14,11 @@ const FARMER_SECRET_KEY_HEX: &str = const FARMER_PUBLIC_KEY_HEX: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; +fn workflow_idempotency_key(index: u16) -> SdkIdempotencyKey { + SdkIdempotencyKey::new(format!("01890f0e-6c00-7000-8000-00000000{index:04x}")) + .expect("workflow idempotency") +} + struct WorkflowSigner { identity: RadrootsSignerIdentity, keys: RadrootsNostrKeys, @@ -135,7 +140,8 @@ fn workflow_digest_and_event_helpers_cover_error_and_input_paths() { assert_eq!(event.id(), signed.id()); assert_eq!(event.author(), signed.pubkey()); - let idempotency_key = SdkIdempotencyKey::new("workflow-idempotency").expect("idempotency"); + let idempotency_key = + SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000237").expect("idempotency"); let input = signed_outbox_input( "workflow.test.v1", &draft, @@ -185,7 +191,7 @@ async fn default_operation_idempotency_ignores_target_policy() { frozen_draft: &draft, target_policy: first_target_policy, satisfaction_policy: SatisfactionPolicy::AllAccepted, - idempotency_key: None, + idempotency_key: Some(workflow_idempotency_key(0x240)), }, &signer, ) @@ -199,7 +205,7 @@ async fn default_operation_idempotency_ignores_target_policy() { frozen_draft: &draft, target_policy: second_target_policy, satisfaction_policy: SatisfactionPolicy::AllAccepted, - idempotency_key: None, + idempotency_key: Some(workflow_idempotency_key(0x240)), }, &signer, ) @@ -270,7 +276,7 @@ async fn enqueue_signed_workflow_maps_no_wait_directly_and_allows_local_only_pro frozen_draft: &draft, target_policy: TargetPolicy::default_profile(), satisfaction_policy: SatisfactionPolicy::NoWait, - idempotency_key: None, + idempotency_key: Some(workflow_idempotency_key(0x241)), }, &signer, ) @@ -323,6 +329,56 @@ async fn enqueue_signed_workflow_maps_no_wait_directly_and_allows_local_only_pro } #[tokio::test] +async fn enqueue_signed_workflow_rejects_missing_explicit_idempotency_key_without_mutation() { + let sdk = crate::RadrootsClient::builder() + .fixed_clock(crate::RadrootsSdkTimestamp::from_unix_seconds( + 1_700_000_013, + )) + .build() + .await + .expect("sdk"); + let actor = RadrootsActorContext::test(FARMER_PUBLIC_KEY_HEX, [RadrootsActorRole::Farmer]) + .expect("actor"); + let draft = frozen_draft_for_d_tag(FARMER_PUBLIC_KEY_HEX, "workflow-missing-idempotency"); + + let error = match enqueue_signed_workflow( + &sdk, + SdkWorkflowEnqueueRequest { + operation_kind: "workflow.test.v1", + actor: &actor, + frozen_draft: &draft, + target_policy: TargetPolicy::default_profile(), + satisfaction_policy: SatisfactionPolicy::NoWait, + idempotency_key: None, + }, + &WorkflowSigner::new(), + ) + .await + { + Err(error) => error, + Ok(_) => panic!("expected missing idempotency error"), + }; + + assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. })); + assert_eq!( + sdk._event_store + .status_summary() + .await + .expect("event store status") + .total_events, + 0 + ); + assert_eq!( + sdk._outbox + .status_summary(0) + .await + .expect("outbox status") + .total_events, + 0 + ); +} + +#[tokio::test] async fn enqueue_signed_workflow_stores_signed_event_and_reports_idempotency_conflicts() { let sdk = crate::RadrootsClient::builder() .transport_profile(nostr_profile("wss://relay.example.com")) @@ -336,7 +392,8 @@ async fn enqueue_signed_workflow_stores_signed_event_and_reports_idempotency_con .expect("actor"); let signer = WorkflowSigner::new(); let first_draft = frozen_draft_for_d_tag(FARMER_PUBLIC_KEY_HEX, "workflow-success"); - let idempotency_key = SdkIdempotencyKey::new("workflow-idempotency").expect("idempotency"); + let idempotency_key = + SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000237").expect("idempotency"); let receipt = enqueue_signed_workflow( &sdk, SdkWorkflowEnqueueRequest { @@ -449,7 +506,7 @@ async fn enqueue_configured_signed_workflow_uses_sdk_signer_provider() { frozen_draft: &draft, target_policy: TargetPolicy::default_profile(), satisfaction_policy: SatisfactionPolicy::AllAccepted, - idempotency_key: None, + idempotency_key: Some(workflow_idempotency_key(0x242)), }, ) .await @@ -463,7 +520,7 @@ async fn enqueue_configured_signed_workflow_uses_sdk_signer_provider() { } #[tokio::test] -async fn enqueue_signed_workflow_reports_outbox_preflight_failure_without_mutation() { +async fn enqueue_signed_workflow_reports_runtime_pool_failure_before_mutation() { let sdk = crate::RadrootsClient::builder() .transport_profile(nostr_profile("wss://relay.example.com")) .build() @@ -487,7 +544,7 @@ async fn enqueue_signed_workflow_reports_outbox_preflight_failure_without_mutati frozen_draft: &draft, target_policy: TargetPolicy::default_profile(), satisfaction_policy: SatisfactionPolicy::AllAccepted, - idempotency_key: None, + idempotency_key: Some(workflow_idempotency_key(0x243)), }; let error = match enqueue_signed_workflow(&sdk, request, &WorkflowSigner::new()).await { @@ -495,15 +552,7 @@ async fn enqueue_signed_workflow_reports_outbox_preflight_failure_without_mutati Ok(_) => panic!("expected closed outbox error"), }; - assert!(matches!(error, RadrootsSdkError::Outbox { .. })); - assert_eq!( - sdk._event_store - .status_summary() - .await - .expect("event store summary") - .total_events, - 0 - ); + assert!(matches!(error, RadrootsSdkError::EventStore { .. })); } #[tokio::test] @@ -523,7 +572,7 @@ async fn enqueue_signed_workflow_reports_store_failures() { frozen_draft: &draft, target_policy: TargetPolicy::default_profile(), satisfaction_policy: SatisfactionPolicy::AllAccepted, - idempotency_key: None, + idempotency_key: Some(workflow_idempotency_key(0x244)), }; assert!(matches!( enqueue_signed_workflow( @@ -553,7 +602,7 @@ async fn enqueue_signed_workflow_reports_clock_failures() { frozen_draft: &draft, target_policy: TargetPolicy::default_profile(), satisfaction_policy: SatisfactionPolicy::AllAccepted, - idempotency_key: None, + idempotency_key: Some(workflow_idempotency_key(0x245)), }; assert!(matches!( enqueue_signed_workflow(&sdk, request, &WorkflowSigner::new()).await, @@ -573,7 +622,7 @@ async fn enqueue_signed_workflow_rejects_transport_profile_targets_without_proxy frozen_draft: &draft, target_policy: TargetPolicy::DefaultProfile, satisfaction_policy: SatisfactionPolicy::AllAccepted, - idempotency_key: None, + idempotency_key: Some(workflow_idempotency_key(0x246)), }; assert!(matches!(