sdk

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

commit 16bdb7f65ec8ff902041d5736e6773e1684a5560
parent 79f1018994c344f59a9b342cd43493b62d3c771f
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 11:40:53 +0000

sdk: refactor trade commands and queries

- freeze every trade command through canonical workflow and event contracts
- delegate commit pagination and private metadata to shared sync and storage
- return lower projections evidence pages receipts and canonical trade identity
- remove the retired SQL-backed duplicate trade runtime and tests

Diffstat:
Mcrates/sdk/src/client.rs | 9+++++++++
Mcrates/sdk/src/trade.rs | 804++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Dcrates/sdk/src/trade_runtime.rs | 2174-------------------------------------------------------------------------------
Mcrates/sdk/tests/package_boundary.rs | 41+++++++++++++++++++++++++++++++++++++++++
Dcrates/sdk/tests/unit/trade_runtime_tests.rs | 375-------------------------------------------------------------------------------
5 files changed, 853 insertions(+), 2550 deletions(-)

diff --git a/crates/sdk/src/client.rs b/crates/sdk/src/client.rs @@ -251,6 +251,15 @@ impl Client { Ok(self.sync()?.map(crate::listing::Operations::new)) } + /// Returns trade operations when canonical synchronization is configured. + #[cfg(feature = "sync")] + pub fn trade(&self) -> Result<Option<crate::trade::Operations<'_>>> { + let storage = self.storage()?; + Ok(self + .sync()? + .map(|sync| crate::trade::Operations::new(storage, sync))) + } + /// Returns whether explicit close completed successfully or reached the /// lower storage commit point. #[must_use] diff --git a/crates/sdk/src/trade.rs b/crates/sdk/src/trade.rs @@ -1 +1,803 @@ -//! Trade commands and queries. +//! Canonical trade planning, commit, query, and private-evidence operations. + +use std::{error, fmt}; + +use radroots_event::{ + EventDraft, + contract::AuthorRole, + trade::{TradeMutationEnvelopeV1, TradeProtocolError, canonical_trade_mutation_content}, +}; +use radroots_event_codec::{encode::EventEncodeError, encode::trade::trade_mutation_event_build}; +use radroots_signing::Actor; +use radroots_trade::{Projection, ReductionInput, WorkflowPlan, reducer::reduce_trade_records}; + +/// Pure inputs for one frozen trade command. +#[derive(Clone, Debug)] +pub struct PrepareRequest { + actor: Actor, + mutation: TradeMutationEnvelopeV1, +} + +impl PrepareRequest { + /// Creates explicit inputs for any canonical proposal, revision, decision, + /// cancellation, or resumable mutation. + #[must_use] + pub const fn new(actor: Actor, mutation: TradeMutationEnvelopeV1) -> Self { + Self { actor, mutation } + } +} + +/// Frozen, replay-stable trade workflow plan. +#[derive(Clone, Debug)] +pub struct Plan { + actor: Actor, + workflow: WorkflowPlan, + draft: EventDraft, +} + +impl Plan { + /// Returns the exact authorized actor carried into signing. + #[must_use] + pub const fn actor(&self) -> &Actor { + &self.actor + } + + /// Returns the lower-owned validated workflow and required host actions. + pub const fn workflow(&self) -> &WorkflowPlan { + &self.workflow + } + + /// Returns the frozen canonical event draft. + #[must_use] + pub const fn draft(&self) -> &EventDraft { + &self.draft + } +} + +/// Trade planning failure stage. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum PrepareErrorKind { + /// The actor identity or role cannot author the supplied mutation. + UnauthorizedActor, + /// Event-domain canonicalization rejected the mutation. + CanonicalMutation, + /// The lower trade workflow rejected the canonical mutation. + Workflow, + /// The canonical event codec rejected the mutation. + Encode, + /// The canonical event draft rejected the encoded mutation. + Draft, +} + +/// One secret-safe trade planning failure retaining its lower source. +pub struct PrepareError { + kind: PrepareErrorKind, + source: Option<Box<dyn error::Error + Send + Sync>>, +} + +impl PrepareError { + /// Returns the stable client-level planning stage. + #[must_use] + pub const fn kind(&self) -> PrepareErrorKind { + self.kind + } + + fn unauthorized_actor() -> Self { + Self { + kind: PrepareErrorKind::UnauthorizedActor, + source: None, + } + } + + fn canonical(source: TradeProtocolError) -> Self { + Self::with_source(PrepareErrorKind::CanonicalMutation, source) + } + + fn workflow(source: radroots_trade::Error) -> Self { + Self::with_source(PrepareErrorKind::Workflow, source) + } + + fn encode(source: EventEncodeError) -> Self { + Self::with_source(PrepareErrorKind::Encode, source) + } + + fn draft(source: radroots_event::draft::DraftError) -> Self { + Self::with_source(PrepareErrorKind::Draft, source) + } + + fn with_source( + kind: PrepareErrorKind, + source: impl error::Error + Send + Sync + 'static, + ) -> Self { + Self { + kind, + source: Some(Box::new(source)), + } + } +} + +impl fmt::Display for PrepareError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + PrepareErrorKind::UnauthorizedActor => "trade actor is not authorized", + PrepareErrorKind::CanonicalMutation => "trade mutation is not canonical", + PrepareErrorKind::Workflow => "trade workflow is invalid", + PrepareErrorKind::Encode => "trade event encoding failed", + PrepareErrorKind::Draft => "trade event draft is invalid", + }) + } +} + +impl fmt::Debug for PrepareError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PrepareError") + .field("kind", &self.kind) + .finish_non_exhaustive() + } +} + +impl error::Error for PrepareError { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + self.source + .as_deref() + .map(|source| source as &(dyn error::Error + 'static)) + } +} + +/// Canonicalizes, authorizes, validates, and freezes one trade command. +/// +/// This operation performs no signing, persistence, private-artifact access, +/// scheduling, or delivery. Every proposal, revision, decision, cancellation, +/// and resumed command uses the same lower-owned `TradeId` and workflow law. +pub fn prepare(request: PrepareRequest) -> Result<Plan, PrepareError> { + let canonical = canonical_trade_mutation_content(request.mutation) + .map_err(PrepareError::canonical)? + .envelope; + let required_role = if canonical.author_pubkey == canonical.buyer_pubkey { + AuthorRole::Buyer + } else if canonical.author_pubkey == canonical.seller_pubkey { + AuthorRole::Seller + } else { + return Err(PrepareError::unauthorized_actor()); + }; + if request.actor.public_key() != canonical.author_pubkey + || !request.actor.satisfies(required_role) + { + return Err(PrepareError::unauthorized_actor()); + } + let workflow = WorkflowPlan::prepare(canonical.clone()).map_err(PrepareError::workflow)?; + let parts = trade_mutation_event_build(canonical.clone()).map_err(PrepareError::encode)?; + let draft = EventDraft::new( + canonical.contract_id.clone(), + parts.kind, + canonical.authored_at_unix_s, + parts.tags, + parts.content, + canonical.author_pubkey.to_hex(), + ) + .map_err(PrepareError::draft)?; + Ok(Plan { + actor: request.actor, + workflow, + draft, + }) +} + +/// Deterministically reduces caller-supplied canonical evidence. +#[must_use] +pub fn project(input: ReductionInput) -> Projection { + reduce_trade_records(input) +} + +#[cfg(feature = "sync")] +use radroots_signing::request::CancellationPolicy; +#[cfg(feature = "sync")] +use radroots_storage::{ + event::{EventPage, EventQuery, StoredVisibleEvent}, + journal::IdempotencyKey, + private_artifact::{PrivateArtifactId, PrivateArtifactMetadata, PrivateArtifactStage}, +}; +#[cfg(feature = "sync")] +use radroots_sync::{ + PushReceipt, + policy::{Error as SyncError, SyncId}, +}; + +/// Explicit commit inputs for one prepared trade command. +#[cfg(feature = "sync")] +#[derive(Clone, Debug)] +pub struct EnqueueRequest { + operation_id: SyncId, + idempotency_key: IdempotencyKey, + plan: Plan, + profile: crate::transport::Profile, + cancellation: CancellationPolicy, +} + +#[cfg(feature = "sync")] +impl EnqueueRequest { + /// Creates a command request whose transport selection has no fallback. + #[must_use] + pub const fn new( + operation_id: SyncId, + idempotency_key: IdempotencyKey, + plan: Plan, + profile: crate::transport::Profile, + cancellation: CancellationPolicy, + ) -> Self { + Self { + operation_id, + idempotency_key, + plan, + profile, + cancellation, + } + } +} + +/// Private-term metadata verification failure. +#[cfg(feature = "sync")] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum PrivateTermsError { + /// Canonical storage failed to inspect the requested metadata. + Storage, + /// The workflow does not require private terms. + NotRequired, + /// Metadata is absent, inactive, or does not match the public commitment. + EvidenceMismatch, +} + +/// Borrowed trade operations over canonical storage and sync capabilities. +#[cfg(feature = "sync")] +#[derive(Clone, Copy)] +pub struct Operations<'a> { + storage: &'a dyn radroots_storage::Storage, + sync: crate::sync::Operations<'a>, +} + +#[cfg(feature = "sync")] +impl fmt::Debug for Operations<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Operations") + .field("storage", &"<borrowed canonical storage>") + .field("sync", &self.sync) + .finish() + } +} + +#[cfg(feature = "sync")] +impl<'a> Operations<'a> { + pub(crate) const fn new( + storage: &'a dyn radroots_storage::Storage, + sync: crate::sync::Operations<'a>, + ) -> Self { + Self { storage, sync } + } + + /// Signs and atomically enqueues a prepared command. Reusing the same + /// idempotency input is the canonical resume/replay operation. + pub async fn enqueue(&self, request: EnqueueRequest) -> Result<PushReceipt, SyncError> { + let targets = request + .profile + .targets() + .cloned() + .ok_or(SyncError::InvalidPushRequest)?; + let satisfaction = request + .profile + .satisfaction() + .cloned() + .ok_or(SyncError::InvalidPushRequest)?; + self.sync + .sign_and_enqueue(radroots_sync::PushRequest::new( + request.operation_id, + request.idempotency_key, + request.plan.actor, + request.plan.draft, + targets, + satisfaction, + request.cancellation, + )?) + .await + } + + /// Returns one native, bounded, generation-bound page of visible evidence. + pub async fn query_visible( + &self, + query: EventQuery, + ) -> Result<EventPage<StoredVisibleEvent>, radroots_storage::Error> { + radroots_storage::event::EventStore::query_visible(self.storage, query).await + } + + /// Returns native private-artifact metadata without reading secret material. + pub async fn private_artifact( + &self, + artifact_id: PrivateArtifactId, + ) -> Result<Option<PrivateArtifactMetadata>, radroots_storage::Error> { + radroots_storage::private_artifact::PrivateArtifactStore::metadata( + self.storage, + artifact_id, + ) + .await + } + + /// Verifies that canonical active metadata matches a plan's public schema + /// and ciphertext commitment. Plaintext, ciphertext, and keys never cross + /// the SDK boundary. + pub async fn verify_private_terms( + &self, + plan: &Plan, + artifact_id: PrivateArtifactId, + ) -> Result<PrivateArtifactMetadata, PrivateTermsError> { + let expected = plan + .workflow + .private_terms() + .ok_or(PrivateTermsError::NotRequired)?; + let metadata = self + .private_artifact(artifact_id) + .await + .map_err(|_| PrivateTermsError::Storage)? + .ok_or(PrivateTermsError::EvidenceMismatch)?; + let commitment = hex_lower(metadata.commitment().as_bytes()); + if metadata.stage() != PrivateArtifactStage::Active + || metadata.schema_id().as_str() != expected.schema_id() + || commitment != expected.ciphertext_commitment() + { + return Err(PrivateTermsError::EvidenceMismatch); + } + Ok(metadata) + } +} + +#[cfg(feature = "sync")] +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + encoded +} + +#[cfg(test)] +mod tests { + use radroots_event::{ + id::{ClassifiedListingAddress, DTag, EventId, InventoryBinId, MutationId, TradeId}, + trade::{ + FulfillmentProfileV1, RADROOTS_TRADE_CANCELLATION_CONTRACT_ID, + RADROOTS_TRADE_DECISION_CONTRACT_ID, RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, + RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID, + RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID, RADROOTS_TRADE_SCHEMA_VERSION, + TradeCancellationProfileV1, TradeCandidateLineV1, TradeCandidateTermsV1, + TradeDecisionV1, TradeEconomicAdjustmentV1, TradeEconomicsProfileV1, + TradeLineTombstoneV1, TradeMutationBodyV1, TradeMutationKindV1, TradePrivateTermsRefV1, + }, + }; + use radroots_identity::PublicKey; + use radroots_signing::actor::ActorSource; + use radroots_trade::workflow::WorkflowAction; + + use super::*; + + const BUYER: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; + const SELLER: &str = "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; + + fn pubkey(value: &str) -> PublicKey { + PublicKey::from_hex(value).expect("public key") + } + + fn actor(public_key: &str, role: AuthorRole) -> Actor { + Actor::from_public_key_hex(public_key, ActorSource::ExplicitPublicKey, [role]) + .expect("actor") + } + + fn mutation_id(marker: char) -> MutationId { + MutationId::parse(std::iter::repeat_n(marker, 64).collect::<String>()).expect("mutation id") + } + + fn candidate(suffix: &str) -> TradeCandidateTermsV1 { + TradeCandidateTermsV1 { + candidate_id: None, + schema_version: RADROOTS_TRADE_SCHEMA_VERSION, + base_candidate_id: None, + supersession_intent: None, + buyer_pubkey: pubkey(BUYER), + seller_pubkey: pubkey(SELLER), + farm_id: DTag::parse("farm-1").expect("farm id"), + lines: vec![TradeCandidateLineV1 { + line_id: DTag::parse(format!("line-{suffix}")).expect("line id"), + listing_addr: ClassifiedListingAddress::parse(format!( + "30402:{SELLER}:listing-{suffix}" + )) + .expect("listing address"), + listing_event_id: EventId::parse("cc".repeat(32)).expect("event id"), + listing_snapshot_sha256: "dd".repeat(32), + product_id: format!("carrots-{suffix}"), + option_id: None, + bin_id: InventoryBinId::parse(format!("bin-{suffix}")).expect("bin id"), + quantity_mantissa: "2".into(), + quantity_scale: 0, + unit_code: "count".into(), + unit_profile: "mvp-count".into(), + unit_price_mantissa: "500".into(), + currency_code: "USD".into(), + line_subtotal_mantissa: "1000".into(), + replaces_line_id: None, + }], + line_tombstones: Vec::<TradeLineTombstoneV1>::new(), + economics: TradeEconomicsProfileV1 { + profile_id: "mvp-fixed".into(), + currency_code: "USD".into(), + currency_exponent: 2, + rounding_profile: "half-even".into(), + subtotal_mantissa: "1000".into(), + discount_total_mantissa: "0".into(), + adjustment_total_mantissa: "0".into(), + total_mantissa: "1000".into(), + adjustments: Vec::<TradeEconomicAdjustmentV1>::new(), + }, + fulfillment: FulfillmentProfileV1 { + profile_id: "market-pickup".into(), + method: "pickup".into(), + starts_at_unix_s: 1_800_000_000, + ends_at_unix_s: 1_800_003_600, + timezone: "America/New_York".into(), + utc_offset_seconds: -18_000, + fold: 0, + location_class: "farmstand".into(), + requires_private_terms: true, + }, + cancellation: TradeCancellationProfileV1 { + profile_id: "buyer-pre-agreement".into(), + buyer_pre_agreement: true, + post_agreement_cutoff_unix_s: None, + }, + private_terms: Some(TradePrivateTermsRefV1 { + artifact_id: "artifact-1".into(), + schema_id: "radroots.private.fulfillment.v1".into(), + ciphertext_commitment: "ee".repeat(32), + required_acknowledgement: true, + }), + proposal_expires_at_unix_s: 1_800_010_000, + } + } + + fn envelope(contract_id: &str, body: TradeMutationBodyV1) -> TradeMutationEnvelopeV1 { + let initial = body.mutation_kind() == TradeMutationKindV1::Proposal; + TradeMutationEnvelopeV1 { + mutation_id: None, + contract_id: contract_id.into(), + schema_version: RADROOTS_TRADE_SCHEMA_VERSION, + trade_id: TradeId::parse("11".repeat(16)).expect("trade id"), + root_mutation_id: (!initial).then(|| mutation_id('1')), + buyer_pubkey: pubkey(BUYER), + seller_pubkey: pubkey(SELLER), + farm_id: DTag::parse("farm-1").expect("farm id"), + parent_mutation_ids: if initial { + vec![] + } else { + vec![mutation_id('1')] + }, + author_pubkey: pubkey(BUYER), + counterparty_pubkey: pubkey(SELLER), + authored_at_unix_s: 1_800_000_000, + body, + } + } + + fn all_commands() -> Vec<TradeMutationEnvelopeV1> { + let proposal = canonical_trade_mutation_content(envelope( + RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, + TradeMutationBodyV1::Proposal { + candidate: candidate("1"), + }, + )) + .expect("proposal") + .envelope; + let proposal_id = proposal.mutation_id.expect("proposal id"); + let candidate_id = match &proposal.body { + TradeMutationBodyV1::Proposal { candidate } => { + candidate.candidate_id.expect("candidate id") + } + _ => unreachable!(), + }; + vec![ + proposal, + envelope( + RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID, + TradeMutationBodyV1::RevisionProposal { + candidate: candidate("2"), + }, + ), + envelope( + RADROOTS_TRADE_DECISION_CONTRACT_ID, + TradeMutationBodyV1::Decision { + proposal_mutation_id: proposal_id, + candidate_id, + decision: TradeDecisionV1::Declined { + reason: "unavailable".into(), + }, + }, + ), + envelope( + RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID, + TradeMutationBodyV1::RevisionDecision { + proposal_mutation_id: mutation_id('2'), + candidate_id, + decision: TradeDecisionV1::Declined { + reason: "unavailable".into(), + }, + }, + ), + envelope( + RADROOTS_TRADE_CANCELLATION_CONTRACT_ID, + TradeMutationBodyV1::Cancellation { + target_candidate_id: Some(candidate_id), + target_claim_mutation_id: None, + reason: "cancelled".into(), + }, + ), + ] + } + + #[test] + fn prepare_covers_every_command_with_one_trade_identity_and_private_plan() { + let plans = all_commands() + .into_iter() + .map(|mutation| { + prepare(PrepareRequest::new( + actor(BUYER, AuthorRole::Buyer), + mutation, + )) + }) + .collect::<Result<Vec<_>, _>>() + .expect("plans"); + + assert_eq!( + plans + .iter() + .map(|plan| plan.workflow().kind()) + .collect::<Vec<_>>(), + [ + TradeMutationKindV1::Proposal, + TradeMutationKindV1::RevisionProposal, + TradeMutationKindV1::Decision, + TradeMutationKindV1::RevisionDecision, + TradeMutationKindV1::Cancellation, + ] + ); + assert!( + plans + .iter() + .all(|plan| plan.workflow().trade_id() == plans[0].workflow().trade_id()) + ); + assert_eq!( + plans[0].workflow().required_actions()[0], + WorkflowAction::VerifyPrivateTerms + ); + assert_eq!( + plans[0] + .workflow() + .private_terms() + .expect("private terms") + .artifact_id(), + "artifact-1" + ); + for plan in plans { + assert_eq!( + plan.draft().contract_id(), + plan.workflow().kind().contract_id() + ); + assert_eq!(plan.draft().kind_u32(), plan.workflow().kind().nostr_kind()); + } + } + + #[test] + fn prepare_rejects_wrong_identity_role_and_invalid_protocol_once() { + let mutation = all_commands().remove(0); + let wrong_role = prepare(PrepareRequest::new( + actor(BUYER, AuthorRole::Seller), + mutation.clone(), + )) + .expect_err("wrong role"); + assert_eq!(wrong_role.kind(), PrepareErrorKind::UnauthorizedActor); + assert!(std::error::Error::source(&wrong_role).is_none()); + + let wrong_identity = prepare(PrepareRequest::new( + actor(SELLER, AuthorRole::Buyer), + mutation, + )) + .expect_err("wrong identity"); + assert_eq!(wrong_identity.kind(), PrepareErrorKind::UnauthorizedActor); + + let mut invalid = all_commands().remove(0); + invalid.contract_id = "radroots.trade.cancellation.v1".into(); + let canonical = prepare(PrepareRequest::new( + actor(BUYER, AuthorRole::Buyer), + invalid, + )) + .expect_err("invalid contract"); + assert_eq!(canonical.kind(), PrepareErrorKind::CanonicalMutation); + assert!(std::error::Error::source(&canonical).is_some()); + assert!(!format!("{canonical:?}").contains("artifact-1")); + } + + #[test] + fn query_projection_returns_the_lower_projection_and_conflict_evidence_types() { + let trade_id = TradeId::parse("22".repeat(16)).expect("trade id"); + let projection = project(ReductionInput::new(trade_id)); + assert_eq!(projection.trade_id(), &trade_id); + assert!(projection.candidate_heads().is_empty()); + assert!(!projection.projection_digest().is_empty()); + } + + #[cfg(all(feature = "sync", feature = "memory", feature = "local-signing"))] + mod operations { + use std::sync::{ + Arc, + atomic::{AtomicU8, Ordering}, + }; + + use radroots_nostr::key::SecretKey; + use radroots_signing::request::CancellationPolicy; + use radroots_storage::{ + Outbox, + event::{EventQuery, EventQueryBounds, SourceGeneration}, + journal::IdempotencyKey, + memory::MemoryStorage, + private_artifact::{ + ArtifactCommitment, ArtifactKind, ArtifactSchemaId, DurableSecretReference, + PrivateArtifactId, PrivateArtifactMetadata, PrivateArtifactStore, RetentionPolicy, + }, + }; + use radroots_sync::{ + Engine, + policy::{Clock, DeadlinePolicy, Error, IdSource, OperationKind, SyncId, SyncStorage}, + }; + use radroots_transport::{ + DeliveryReceipt, DeliveryRequest, Error as TransportError, EventSink, SinkStatus, + Target, TargetSet, TransportId, + capability::{Availability, Maturity, SinkCapabilities}, + policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy}, + }; + + use super::*; + use crate::{ClientBuilder, transport::Profile}; + + const BUYER_SECRET: &str = + "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; + + struct FixedClock; + struct SequenceIds(AtomicU8); + struct NoopSink; + + impl Clock for FixedClock { + fn now_unix_ms(&self) -> Result<u64, Error> { + Ok(2_000_000_000_000) + } + } + impl IdSource for SequenceIds { + fn next_id(&self, _operation: OperationKind) -> Result<SyncId, Error> { + SyncId::new([self.0.fetch_add(1, Ordering::Relaxed); 16]) + } + } + impl EventSink for NoopSink { + fn status( + &self, + ) -> radroots_transport::BoxFuture<'_, Result<SinkStatus, TransportError>> { + Box::pin(async { + Ok(SinkStatus::new( + TransportId::NOSTR, + true, + Maturity::Stable, + Availability::Available, + SinkCapabilities::DELIVER, + "ready", + )) + }) + } + fn deliver( + &self, + _request: DeliveryRequest, + ) -> radroots_transport::BoxFuture<'_, Result<DeliveryReceipt, TransportError>> + { + Box::pin(async { Err(TransportError::UnsupportedOperation) }) + } + } + + #[tokio::test] + async fn operations_cover_private_evidence_pagination_commit_replay_and_cancellation() { + let storage = Arc::new(MemoryStorage::new( + SourceGeneration::new([6; 32]).expect("generation"), + )); + let artifact_id = PrivateArtifactId::new([7; 16]).expect("artifact id"); + let metadata = PrivateArtifactMetadata::new( + artifact_id, + ArtifactKind::parse("trade_private_terms").expect("kind"), + ArtifactSchemaId::parse("radroots.private.fulfillment.v1").expect("schema"), + ArtifactCommitment::new([0xee; 32]), + 64, + DurableSecretReference::new("memory", "trade-artifact-1", 1).expect("reference"), + RetentionPolicy::indefinite(), + 1_800_000_000_000, + ) + .expect("metadata"); + PrivateArtifactStore::put_metadata(storage.as_ref(), metadata) + .await + .expect("store metadata"); + + let signer = Arc::new( + radroots_nostr::signing::LocalSigner::new( + SecretKey::parse(BUYER_SECRET).expect("secret"), + ) + .expect("signer"), + ); + let capability: Arc<dyn SyncStorage> = storage.clone(); + let engine = Engine::builder( + capability, + Arc::new(FixedClock), + Arc::new(SequenceIds(AtomicU8::new(1))), + DeadlinePolicy::new(1_000, 1_000, 1_000).expect("deadlines"), + ) + .sink(Arc::new(NoopSink)) + .signer(signer) + .build() + .expect("engine"); + let client = ClientBuilder::new() + .storage(storage.clone()) + .sync_engine(engine) + .build() + .expect("client"); + let operations = client.trade().expect("open").expect("trade operations"); + let plan = prepare(PrepareRequest::new( + actor(BUYER, AuthorRole::Buyer), + all_commands().remove(0), + )) + .expect("plan"); + + let verified = operations + .verify_private_terms(&plan, artifact_id) + .await + .expect("private evidence"); + assert_eq!(verified.artifact_id(), artifact_id); + let page = operations + .query_visible(EventQuery::all(EventQueryBounds::first(1).expect("bounds"))) + .await + .expect("page"); + assert!(page.items().is_empty()); + assert!(page.next_cursor().is_none()); + + let targets = TargetSet::new(vec![ + Target::nostr_relay("wss://trade.example").expect("target"), + ]) + .expect("targets"); + let satisfaction = + SatisfactionPolicy::new(SatisfactionClass::Delivered, TargetPolicy::all()); + let request = EnqueueRequest::new( + SyncId::new([11; 16]).expect("operation id"), + IdempotencyKey::parse("trade-proposal-a").expect("idempotency"), + plan, + Profile::delivery(targets.clone(), satisfaction.clone()).expect("profile"), + CancellationPolicy::PreservePublishedRequest, + ); + drop(operations.enqueue(request.clone())); + assert_eq!( + Outbox::status(storage.as_ref()) + .await + .expect("status") + .pending, + 0 + ); + let committed = operations.enqueue(request.clone()).await.expect("commit"); + assert!(!committed.is_replay()); + assert_eq!(committed.outbox().request().target_set(), &targets); + let replay = operations.enqueue(request).await.expect("resume replay"); + assert!(replay.is_replay()); + assert_eq!(replay.outbox().item_id(), committed.outbox().item_id()); + } + } +} diff --git a/crates/sdk/src/trade_runtime.rs b/crates/sdk/src/trade_runtime.rs @@ -1,2174 +0,0 @@ -#[cfg(feature = "signer-adapters")] -use crate::workflow_runtime::enqueue_configured_signed_workflow; -#[cfg(feature = "runtime")] -use crate::{ - RadrootsClient, RadrootsSdkError, RadrootsSdkRecoveryAction, RadrootsSdkTradeErrorKind, - SatisfactionPolicy, SdkIdempotencyKey, SdkMutationState, TargetPolicy, TradesClient, - private_store::{ - SDK_PRIVATE_STORE_SCHEMA_VERSION, SdkPrivateTradeArtifactInput, - SdkPrivateTradeArtifactKind, SdkPrivateTradeArtifactMetadata, - }, - runtime::sdk_now_ms, - workflow_runtime::{ - SdkWorkflowEnqueueReceipt, SdkWorkflowEnqueueRequest, enqueue_signed_workflow, - }, -}; -#[cfg(feature = "runtime")] -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -#[cfg(feature = "runtime")] -use radroots_event::{ - draft::EventDraft, - envelope::kind::TRADE_MUTATION_EVENT_KINDS, - id::{CandidateId, EventId, MutationId, TradeId}, - trade::{ - RADROOTS_TRADE_MAX_PRIVATE_ARTIFACT_BYTES, RADROOTS_TRADE_MUTATION_CONTRACT_IDS, - RADROOTS_TRADE_SCHEMA_VERSION, TradeDecisionV1, TradeMutationBodyV1, - TradeMutationEnvelopeV1, TradePrivateTermsRefV1, trade_mutation_from_canonical_content, - }, -}; -#[cfg(feature = "runtime")] -use radroots_event_codec::encode::trade::trade_mutation_event_build; -#[cfg(feature = "runtime")] -use radroots_event_store::{RadrootsStoredTradeMutation, RadrootsTradeProjectionCheckpoint}; -#[cfg(feature = "runtime")] -use radroots_signing::{Actor, Signer}; -#[cfg(feature = "runtime")] -use radroots_trade::evidence::{ - RadrootsTradeEvidenceStateV1, RadrootsTradeMutationRecordV1, - RadrootsTradePrivateTermsEvidenceV1, -}; -#[cfg(feature = "runtime")] -use radroots_trade::model::{ - RadrootsTradeAgreementStateV1, RadrootsTradeAttestationStateV1, RadrootsTradeConflictStateV1, - RadrootsTradeFulfillmentStateV1, RadrootsTradeNegotiationStateV1, RadrootsTradePaymentStateV1, - RadrootsTradePrivateTermsStateV1, -}; -#[cfg(feature = "runtime")] -use radroots_trade::reducer::{ - RADROOTS_TRADE_REDUCER_CONTRACT_ID, RADROOTS_TRADE_REDUCER_VERSION, reduce_trade_records, -}; -#[cfg(feature = "runtime")] -use radroots_trade::{Projection, ReductionInput}; -#[cfg(feature = "runtime")] -use serde::{Deserialize, Serialize}; -#[cfg(feature = "runtime")] -use sha2::{Digest, Sha256}; -#[cfg(feature = "runtime")] -use sqlx::{QueryBuilder, Row, Sqlite}; -#[cfg(feature = "runtime")] -use std::collections::{BTreeMap, BTreeSet}; - -#[cfg(feature = "runtime")] -pub const TRADE_SUBMIT_PROPOSAL_OPERATION_KIND: &str = "trade.submit_proposal.v1"; -#[cfg(feature = "runtime")] -pub const TRADE_PROPOSE_REVISION_OPERATION_KIND: &str = "trade.propose_revision.v1"; -#[cfg(feature = "runtime")] -pub const TRADE_DECIDE_CANDIDATE_OPERATION_KIND: &str = "trade.decide_candidate.v1"; -#[cfg(feature = "runtime")] -pub const TRADE_CANCEL_OPERATION_KIND: &str = "trade.cancel.v1"; -#[cfg(feature = "runtime")] -pub const TRADE_RESUME_OPERATION_KIND: &str = "trade.resume_operation.v1"; -#[cfg(feature = "runtime")] -pub const TRADE_QUERY_DEFAULT_LIMIT: u32 = 50; -#[cfg(feature = "runtime")] -pub const TRADE_QUERY_MAX_LIMIT: u32 = 100; -#[cfg(feature = "runtime")] -pub const TRADE_RUNTIME_CAPABILITY_API_VERSION: u16 = 1; -#[cfg(feature = "runtime")] -pub const TRADE_RUNTIME_PROTOCOL_PROFILE_ID: &str = "radroots.trade.protocol.v1"; -#[cfg(feature = "runtime")] -pub const TRADE_RUNTIME_WIRE_PROFILE_ID: &str = "radroots.trade.nostr_regular_immutable_jcs.v1"; -#[cfg(feature = "runtime")] -pub const TRADE_RUNTIME_STORAGE_PROFILE_ID: &str = "radroots.sdk.trade.sqlite.v1"; -#[cfg(feature = "runtime")] -pub const TRADE_RUNTIME_PRIVATE_STORAGE_PROFILE_ID: &str = - "radroots.sdk.trade.private_artifacts.v1"; -#[cfg(feature = "runtime")] -const TRADE_MUTATION_QUERY_LIMIT: u32 = 1_000; -#[cfg(feature = "runtime")] -const TRADE_LIST_CURSOR_VERSION: u8 = 1; - -#[cfg(feature = "runtime")] -#[derive(Clone, Copy)] -pub struct TradeCommandService<'client> { - sdk: &'client RadrootsClient, -} - -#[cfg(feature = "runtime")] -impl<'client> TradeCommandService<'client> { - pub(crate) fn new(sdk: &'client RadrootsClient) -> Self { - Self { sdk } - } - - #[cfg(feature = "signer-adapters")] - pub async fn submit_proposal( - &self, - request: SubmitProposalRequest, - ) -> Result<TradeCommandReceipt, RadrootsSdkError> { - let command = TradeCommandRequest::SubmitProposal(request); - enqueue_configured_trade_command(self.sdk, command).await - } - - pub async fn submit_proposal_with_explicit_signer( - &self, - request: SubmitProposalRequest, - signer: &dyn Signer, - ) -> Result<TradeCommandReceipt, RadrootsSdkError> { - let command = TradeCommandRequest::SubmitProposal(request); - enqueue_trade_command_with_explicit_signer(self.sdk, command, signer).await - } - - #[cfg(feature = "signer-adapters")] - pub async fn propose_revision( - &self, - request: ProposeRevisionRequest, - ) -> Result<TradeCommandReceipt, RadrootsSdkError> { - let command = TradeCommandRequest::ProposeRevision(request); - enqueue_configured_trade_command(self.sdk, command).await - } - - pub async fn propose_revision_with_explicit_signer( - &self, - request: ProposeRevisionRequest, - signer: &dyn Signer, - ) -> Result<TradeCommandReceipt, RadrootsSdkError> { - let command = TradeCommandRequest::ProposeRevision(request); - enqueue_trade_command_with_explicit_signer(self.sdk, command, signer).await - } - - #[cfg(feature = "signer-adapters")] - pub async fn decide_candidate( - &self, - request: DecideCandidateRequest, - ) -> Result<TradeCommandReceipt, RadrootsSdkError> { - let command = TradeCommandRequest::DecideCandidate(request); - enqueue_configured_trade_command(self.sdk, command).await - } - - pub async fn decide_candidate_with_explicit_signer( - &self, - request: DecideCandidateRequest, - signer: &dyn Signer, - ) -> Result<TradeCommandReceipt, RadrootsSdkError> { - let command = TradeCommandRequest::DecideCandidate(request); - enqueue_trade_command_with_explicit_signer(self.sdk, command, signer).await - } - - #[cfg(feature = "signer-adapters")] - pub async fn cancel_trade( - &self, - request: CancelTradeRequest, - ) -> Result<TradeCommandReceipt, RadrootsSdkError> { - let command = TradeCommandRequest::CancelTrade(request); - enqueue_configured_trade_command(self.sdk, command).await - } - - pub async fn cancel_trade_with_explicit_signer( - &self, - request: CancelTradeRequest, - signer: &dyn Signer, - ) -> Result<TradeCommandReceipt, RadrootsSdkError> { - let command = TradeCommandRequest::CancelTrade(request); - enqueue_trade_command_with_explicit_signer(self.sdk, command, signer).await - } - - #[cfg(feature = "signer-adapters")] - pub async fn resume_operation( - &self, - request: ResumeOperationRequest, - ) -> Result<TradeCommandReceipt, RadrootsSdkError> { - let command = TradeCommandRequest::ResumeOperation(request); - enqueue_configured_trade_command(self.sdk, command).await - } - - pub async fn resume_operation_with_explicit_signer( - &self, - request: ResumeOperationRequest, - signer: &dyn Signer, - ) -> Result<TradeCommandReceipt, RadrootsSdkError> { - let command = TradeCommandRequest::ResumeOperation(request); - enqueue_trade_command_with_explicit_signer(self.sdk, command, signer).await - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Copy)] -pub struct TradeQueryService<'client> { - sdk: &'client RadrootsClient, -} - -#[cfg(feature = "runtime")] -impl<'client> TradeQueryService<'client> { - pub(crate) fn new(sdk: &'client RadrootsClient) -> Self { - Self { sdk } - } - - pub async fn get_trade( - &self, - request: GetTradeRequest, - ) -> Result<TradeStatusView, RadrootsSdkError> { - trade_status_view(self.sdk, &request.trade_id).await - } - - pub async fn list_trades( - &self, - request: ListTradesRequest, - ) -> Result<Page<TradeSummaryView>, RadrootsSdkError> { - list_trade_views(self.sdk, request).await - } - - pub async fn refresh_evidence( - &self, - request: RefreshTradeEvidenceRequest, - ) -> Result<EvidenceRefreshReceipt, RadrootsSdkError> { - let view = trade_status_view(self.sdk, &request.trade_id).await?; - let last = last_trade_mutation_snapshot(self.sdk, &request.trade_id).await?; - let checkpoint = RadrootsTradeProjectionCheckpoint { - trade_id: view.trade_id, - reducer_contract_id: RADROOTS_TRADE_REDUCER_CONTRACT_ID.to_owned(), - reducer_version: RADROOTS_TRADE_REDUCER_VERSION, - projection_digest: view.projection.projection_digest().to_owned(), - root_mutation_id: view.projection.root_mutation_id().copied(), - negotiation_state: enum_label(&view.projection.negotiation_state())?, - agreement_state: enum_label(&view.projection.agreement_state())?, - evidence_state: enum_label(&view.projection.evidence_state())?, - conflict_state: enum_label(&view.projection.conflict_state())?, - private_terms_state: enum_label(&view.projection.private_terms_state())?, - attestation_state: enum_label(&view.projection.attestation_state())?, - fulfillment_state: enum_label(&view.projection.fulfillment_state())?, - payment_state: enum_label(&view.projection.payment_state())?, - projection_json: serde_json::to_string(&view.projection) - .map_err(trade_query_store_error)?, - last_mutation_id: last.mutation_id, - last_transport_event_seq: last.event_seq, - updated_at_ms: sdk_now_ms(self.sdk)?, - }; - self.sdk - ._event_store - .update_trade_projection_checkpoint(&checkpoint) - .await?; - Ok(EvidenceRefreshReceipt { - api_version: 1, - trade_id: view.trade_id, - evidence_count: view.private_terms.len(), - projection_digest: view.projection.projection_digest().to_owned(), - projection_state: view.projection.private_terms_state(), - }) - } - - pub async fn inspect_evidence( - &self, - request: InspectEvidenceRequest, - ) -> Result<Page<EvidenceView>, RadrootsSdkError> { - inspect_evidence_views(self.sdk, request).await - } -} - -#[cfg(feature = "runtime")] -impl<'client> TradesClient<'client> { - pub fn capabilities(&self) -> TradeRuntimeCapabilityReport { - trade_runtime_capabilities() - } - - pub fn commands(&self) -> TradeCommandService<'client> { - TradeCommandService::new(self.sdk) - } - - pub fn queries(&self) -> TradeQueryService<'client> { - TradeQueryService::new(self.sdk) - } - - pub async fn seal_private_artifact( - &self, - request: TradePrivateArtifactSealRequest, - ) -> Result<TradePrivateArtifactSealReceipt, RadrootsSdkError> { - seal_private_artifact(self.sdk, request).await - } - - pub async fn open_private_artifact( - &self, - request: TradePrivateArtifactOpenRequest, - ) -> Result<Option<TradePrivateArtifactOpenReceipt>, RadrootsSdkError> { - open_private_artifact(self.sdk, request).await - } - - pub async fn delete_private_artifact( - &self, - request: TradePrivateArtifactDeleteRequest, - ) -> Result<TradePrivateArtifactDeleteReceipt, RadrootsSdkError> { - delete_private_artifact(self.sdk, request).await - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, Serialize)] -#[non_exhaustive] -pub struct SubmitProposalRequest { - #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] - pub actor: Actor, - pub envelope: TradeMutationEnvelopeV1, - pub target_policy: TargetPolicy, - pub satisfaction_policy: SatisfactionPolicy, - pub idempotency_key: Option<SdkIdempotencyKey>, -} - -#[cfg(feature = "runtime")] -impl SubmitProposalRequest { - pub fn new( - actor: Actor, - envelope: TradeMutationEnvelopeV1, - target_policy: TargetPolicy, - ) -> Self { - Self { - actor, - envelope, - target_policy, - satisfaction_policy: SatisfactionPolicy::AllAccepted, - idempotency_key: None, - } - } - - pub fn with_satisfaction_policy(mut self, policy: SatisfactionPolicy) -> Self { - self.satisfaction_policy = policy; - self - } - - pub fn with_idempotency_key(mut self, key: SdkIdempotencyKey) -> Self { - self.idempotency_key = Some(key); - self - } - - pub fn try_with_idempotency_key( - mut self, - key: impl AsRef<str>, - ) -> Result<Self, RadrootsSdkError> { - self.idempotency_key = Some(SdkIdempotencyKey::new(key)?); - Ok(self) - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, Serialize)] -#[non_exhaustive] -pub struct ProposeRevisionRequest { - #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] - pub actor: Actor, - pub envelope: TradeMutationEnvelopeV1, - pub target_policy: TargetPolicy, - pub satisfaction_policy: SatisfactionPolicy, - pub idempotency_key: Option<SdkIdempotencyKey>, -} - -#[cfg(feature = "runtime")] -impl ProposeRevisionRequest { - pub fn new( - actor: Actor, - envelope: TradeMutationEnvelopeV1, - target_policy: TargetPolicy, - ) -> Self { - Self { - actor, - envelope, - target_policy, - satisfaction_policy: SatisfactionPolicy::AllAccepted, - idempotency_key: None, - } - } - - pub fn with_satisfaction_policy(mut self, policy: SatisfactionPolicy) -> Self { - self.satisfaction_policy = policy; - self - } - - pub fn with_idempotency_key(mut self, key: SdkIdempotencyKey) -> Self { - self.idempotency_key = Some(key); - self - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, Serialize)] -#[non_exhaustive] -pub struct DecideCandidateRequest { - #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] - pub actor: Actor, - pub envelope: TradeMutationEnvelopeV1, - pub target_policy: TargetPolicy, - pub satisfaction_policy: SatisfactionPolicy, - pub idempotency_key: Option<SdkIdempotencyKey>, - pub private_terms_acknowledged: bool, -} - -#[cfg(feature = "runtime")] -impl DecideCandidateRequest { - pub fn new( - actor: Actor, - envelope: TradeMutationEnvelopeV1, - target_policy: TargetPolicy, - ) -> Self { - Self { - actor, - envelope, - target_policy, - satisfaction_policy: SatisfactionPolicy::AllAccepted, - idempotency_key: None, - private_terms_acknowledged: false, - } - } - - pub fn with_satisfaction_policy(mut self, policy: SatisfactionPolicy) -> Self { - self.satisfaction_policy = policy; - self - } - - pub fn with_idempotency_key(mut self, key: SdkIdempotencyKey) -> Self { - self.idempotency_key = Some(key); - self - } - - pub fn acknowledge_private_terms(mut self) -> Self { - self.private_terms_acknowledged = true; - self - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, Serialize)] -#[non_exhaustive] -pub struct CancelTradeRequest { - #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] - pub actor: Actor, - pub envelope: TradeMutationEnvelopeV1, - pub target_policy: TargetPolicy, - pub satisfaction_policy: SatisfactionPolicy, - pub idempotency_key: Option<SdkIdempotencyKey>, -} - -#[cfg(feature = "runtime")] -impl CancelTradeRequest { - pub fn new( - actor: Actor, - envelope: TradeMutationEnvelopeV1, - target_policy: TargetPolicy, - ) -> Self { - Self { - actor, - envelope, - target_policy, - satisfaction_policy: SatisfactionPolicy::AllAccepted, - idempotency_key: None, - } - } - - pub fn with_idempotency_key(mut self, key: SdkIdempotencyKey) -> Self { - self.idempotency_key = Some(key); - self - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, Serialize)] -#[non_exhaustive] -pub struct ResumeOperationRequest { - #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] - pub actor: Actor, - pub envelope: TradeMutationEnvelopeV1, - pub operation_kind: &'static str, - pub target_policy: TargetPolicy, - pub satisfaction_policy: SatisfactionPolicy, - pub idempotency_key: Option<SdkIdempotencyKey>, - pub private_terms_acknowledged: bool, -} - -#[cfg(feature = "runtime")] -impl ResumeOperationRequest { - pub fn new( - actor: Actor, - envelope: TradeMutationEnvelopeV1, - operation_kind: &'static str, - target_policy: TargetPolicy, - ) -> Self { - Self { - actor, - envelope, - operation_kind, - target_policy, - satisfaction_policy: SatisfactionPolicy::AllAccepted, - idempotency_key: None, - private_terms_acknowledged: false, - } - } - - pub fn with_idempotency_key(mut self, key: SdkIdempotencyKey) -> Self { - self.idempotency_key = Some(key); - self - } - - pub fn acknowledge_private_terms(mut self) -> Self { - self.private_terms_acknowledged = true; - self - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum TradeCommandLifecycleState { - Committed, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct TradeCommandReceipt { - pub api_version: u16, - pub operation_kind: String, - pub operation_state: TradeCommandLifecycleState, - pub trade_id: TradeId, - pub mutation_id: MutationId, - pub expected_event_id: EventId, - pub signed_event_id: EventId, - pub local_event_seq: i64, - pub outbox_operation_id: i64, - pub outbox_event_id: i64, - pub delivery_state: SdkMutationState, - pub projection_state: Option<Projection>, - pub idempotency_digest_prefix: String, - pub recovery_actions: Vec<RadrootsSdkRecoveryAction>, - pub warnings: Vec<String>, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TradePrivateArtifactKind { - BindingTerms, - Message, - ContactBundle, - DeliveryInstruction, -} - -#[cfg(feature = "runtime")] -impl From<TradePrivateArtifactKind> for SdkPrivateTradeArtifactKind { - fn from(value: TradePrivateArtifactKind) -> Self { - match value { - TradePrivateArtifactKind::BindingTerms => Self::BindingTerms, - TradePrivateArtifactKind::Message => Self::Message, - TradePrivateArtifactKind::ContactBundle => Self::ContactBundle, - TradePrivateArtifactKind::DeliveryInstruction => Self::DeliveryInstruction, - } - } -} - -#[cfg(feature = "runtime")] -impl From<SdkPrivateTradeArtifactKind> for TradePrivateArtifactKind { - fn from(value: SdkPrivateTradeArtifactKind) -> Self { - match value { - SdkPrivateTradeArtifactKind::BindingTerms => Self::BindingTerms, - SdkPrivateTradeArtifactKind::Message => Self::Message, - SdkPrivateTradeArtifactKind::ContactBundle => Self::ContactBundle, - SdkPrivateTradeArtifactKind::DeliveryInstruction => Self::DeliveryInstruction, - } - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, Serialize)] -#[non_exhaustive] -pub struct TradePrivateArtifactSealRequest { - pub artifact_id: String, - pub trade_id: TradeId, - pub candidate_id: Option<CandidateId>, - pub artifact_kind: TradePrivateArtifactKind, - pub schema_id: String, - pub plaintext: Vec<u8>, - pub retention_class: String, - pub expires_at_ms: Option<i64>, -} - -#[cfg(feature = "runtime")] -impl TradePrivateArtifactSealRequest { - pub fn binding_terms( - artifact_id: impl Into<String>, - trade_id: TradeId, - schema_id: impl Into<String>, - plaintext: impl Into<Vec<u8>>, - ) -> Self { - Self { - artifact_id: artifact_id.into(), - trade_id, - candidate_id: None, - artifact_kind: TradePrivateArtifactKind::BindingTerms, - schema_id: schema_id.into(), - plaintext: plaintext.into(), - retention_class: "trade_private_terms".to_owned(), - expires_at_ms: None, - } - } - - pub fn with_retention_class(mut self, retention_class: impl Into<String>) -> Self { - self.retention_class = retention_class.into(); - self - } - - pub fn with_candidate_id(mut self, candidate_id: CandidateId) -> Self { - self.candidate_id = Some(candidate_id); - self - } - - pub fn with_expires_at_ms(mut self, expires_at_ms: i64) -> Self { - self.expires_at_ms = Some(expires_at_ms); - self - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct TradePrivateArtifactSealReceipt { - pub artifact_id: String, - pub trade_id: TradeId, - pub candidate_id: Option<CandidateId>, - pub artifact_kind: TradePrivateArtifactKind, - pub schema_id: String, - pub ciphertext_commitment: String, - pub private_terms_ref: Option<TradePrivateTermsRefV1>, - pub retention_class: String, - pub created_at_ms: i64, - pub expires_at_ms: Option<i64>, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, Serialize)] -#[non_exhaustive] -pub struct TradePrivateArtifactOpenRequest { - pub artifact_id: String, -} - -#[cfg(feature = "runtime")] -impl TradePrivateArtifactOpenRequest { - pub fn new(artifact_id: impl Into<String>) -> Self { - Self { - artifact_id: artifact_id.into(), - } - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct TradePrivateArtifactOpenReceipt { - pub artifact_id: String, - pub trade_id: TradeId, - pub candidate_id: Option<CandidateId>, - pub artifact_kind: TradePrivateArtifactKind, - pub schema_id: String, - pub plaintext: Vec<u8>, - pub retention_class: String, - pub created_at_ms: i64, - pub expires_at_ms: Option<i64>, - pub deleted_at_ms: Option<i64>, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, Serialize)] -#[non_exhaustive] -pub struct TradePrivateArtifactDeleteRequest { - pub artifact_id: String, -} - -#[cfg(feature = "runtime")] -impl TradePrivateArtifactDeleteRequest { - pub fn new(artifact_id: impl Into<String>) -> Self { - Self { - artifact_id: artifact_id.into(), - } - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct TradePrivateArtifactDeleteReceipt { - pub artifact_id: String, - pub deleted: bool, - pub deleted_at_ms: i64, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -#[non_exhaustive] -pub struct TradeRuntimeCapabilityReport { - pub api_version: u16, - pub protocol: TradeProtocolCapabilityReport, - pub storage: TradeStorageCapabilityReport, - pub core_mvp: TradeCoreMvpCapabilityReport, - pub optional_integrations: TradeOptionalIntegrationCapabilityReport, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -#[non_exhaustive] -pub struct TradeProtocolCapabilityReport { - pub protocol_profile_id: &'static str, - pub wire_profile_id: &'static str, - pub schema_version: u16, - pub mutation_contract_ids: Vec<&'static str>, - pub mutation_event_kinds: Vec<u32>, - pub reducer_contract_id: &'static str, - pub reducer_version: u16, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -#[non_exhaustive] -pub struct TradeStorageCapabilityReport { - pub storage_profile_id: &'static str, - pub private_storage_profile_id: &'static str, - pub private_store_schema_version: i64, - pub max_private_artifact_bytes: usize, - pub private_artifact_kinds: Vec<TradePrivateArtifactKind>, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -#[non_exhaustive] -pub struct TradeCoreMvpCapabilityReport { - pub commands: bool, - pub queries: bool, - pub local_event_store: bool, - pub semantic_outbox: bool, - pub protected_private_artifacts: bool, - pub backup_restore: bool, - pub local_signer: bool, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -#[non_exhaustive] -pub struct TradeOptionalIntegrationCapabilityReport { - pub myc_nip46_signer: bool, - pub radrootsd_execution: bool, - pub rhi_attestation: bool, - pub tangle_transport: bool, - pub reticulum_transport: bool, -} - -#[cfg(feature = "runtime")] -fn trade_runtime_capabilities() -> TradeRuntimeCapabilityReport { - TradeRuntimeCapabilityReport { - api_version: TRADE_RUNTIME_CAPABILITY_API_VERSION, - protocol: TradeProtocolCapabilityReport { - protocol_profile_id: TRADE_RUNTIME_PROTOCOL_PROFILE_ID, - wire_profile_id: TRADE_RUNTIME_WIRE_PROFILE_ID, - schema_version: RADROOTS_TRADE_SCHEMA_VERSION, - mutation_contract_ids: RADROOTS_TRADE_MUTATION_CONTRACT_IDS.to_vec(), - mutation_event_kinds: TRADE_MUTATION_EVENT_KINDS.to_vec(), - reducer_contract_id: RADROOTS_TRADE_REDUCER_CONTRACT_ID, - reducer_version: RADROOTS_TRADE_REDUCER_VERSION, - }, - storage: TradeStorageCapabilityReport { - storage_profile_id: TRADE_RUNTIME_STORAGE_PROFILE_ID, - private_storage_profile_id: TRADE_RUNTIME_PRIVATE_STORAGE_PROFILE_ID, - private_store_schema_version: SDK_PRIVATE_STORE_SCHEMA_VERSION, - max_private_artifact_bytes: RADROOTS_TRADE_MAX_PRIVATE_ARTIFACT_BYTES, - private_artifact_kinds: vec![ - TradePrivateArtifactKind::BindingTerms, - TradePrivateArtifactKind::Message, - TradePrivateArtifactKind::ContactBundle, - TradePrivateArtifactKind::DeliveryInstruction, - ], - }, - core_mvp: TradeCoreMvpCapabilityReport { - commands: true, - queries: true, - local_event_store: true, - semantic_outbox: true, - protected_private_artifacts: true, - backup_restore: true, - local_signer: cfg!(feature = "local-signer"), - }, - optional_integrations: TradeOptionalIntegrationCapabilityReport { - myc_nip46_signer: cfg!(feature = "signer-adapters"), - radrootsd_execution: cfg!(feature = "radrootsd-execution"), - rhi_attestation: false, - tangle_transport: false, - reticulum_transport: false, - }, - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, Serialize)] -#[non_exhaustive] -pub struct GetTradeRequest { - pub trade_id: TradeId, -} - -#[cfg(feature = "runtime")] -impl GetTradeRequest { - pub fn new(trade_id: TradeId) -> Self { - Self { trade_id } - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, Serialize)] -#[non_exhaustive] -pub struct RefreshTradeEvidenceRequest { - pub trade_id: TradeId, -} - -#[cfg(feature = "runtime")] -impl RefreshTradeEvidenceRequest { - pub fn new(trade_id: TradeId) -> Self { - Self { trade_id } - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, Serialize)] -#[non_exhaustive] -pub struct InspectEvidenceRequest { - pub trade_id: TradeId, - pub limit: Option<u32>, - pub cursor: Option<String>, -} - -#[cfg(feature = "runtime")] -impl InspectEvidenceRequest { - pub fn new(trade_id: TradeId) -> Self { - Self { - trade_id, - limit: None, - cursor: None, - } - } - - pub fn with_limit(mut self, limit: u32) -> Self { - self.limit = Some(limit); - self - } - - pub fn with_cursor(mut self, cursor: impl Into<String>) -> Self { - self.cursor = Some(cursor.into()); - self - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[non_exhaustive] -pub struct TradeListFilter { - pub buyer_pubkey: Option<String>, - pub seller_pubkey: Option<String>, - pub participant_pubkeys_any_of: Vec<String>, - pub agreement_states_any_of: Vec<RadrootsTradeAgreementStateV1>, - pub any_of: Vec<TradeListAnyOf>, -} - -#[cfg(feature = "runtime")] -impl TradeListFilter { - pub fn buyer(mut self, pubkey: impl Into<String>) -> Self { - self.buyer_pubkey = Some(pubkey.into()); - self - } - - pub fn seller(mut self, pubkey: impl Into<String>) -> Self { - self.seller_pubkey = Some(pubkey.into()); - self - } - - pub fn participant_any_of<I, S>(mut self, pubkeys: I) -> Self - where - I: IntoIterator<Item = S>, - S: Into<String>, - { - self.participant_pubkeys_any_of = pubkeys.into_iter().map(Into::into).collect(); - self - } - - pub fn agreement_states_any_of<I>(mut self, states: I) -> Self - where - I: IntoIterator<Item = RadrootsTradeAgreementStateV1>, - { - self.agreement_states_any_of = states.into_iter().collect(); - self - } - - pub fn any_of<I>(mut self, clauses: I) -> Self - where - I: IntoIterator<Item = TradeListAnyOf>, - { - self.any_of = clauses.into_iter().collect(); - self - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case", tag = "kind", content = "value")] -pub enum TradeListAnyOf { - TradeId(String), - BuyerPubkey(String), - SellerPubkey(String), - ParticipantPubkey(String), -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TradeListSort { - #[default] - UpdatedDesc, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, Serialize)] -#[non_exhaustive] -pub struct ListTradesRequest { - pub filter: TradeListFilter, - pub sort: TradeListSort, - pub limit: Option<u32>, - pub cursor: Option<String>, -} - -#[cfg(feature = "runtime")] -impl ListTradesRequest { - pub fn new() -> Self { - Self { - filter: TradeListFilter::default(), - sort: TradeListSort::UpdatedDesc, - limit: None, - cursor: None, - } - } - - pub fn with_filter(mut self, filter: TradeListFilter) -> Self { - self.filter = filter; - self - } - - pub fn with_limit(mut self, limit: u32) -> Self { - self.limit = Some(limit); - self - } - - pub fn with_cursor(mut self, cursor: impl Into<String>) -> Self { - self.cursor = Some(cursor.into()); - self - } -} - -#[cfg(feature = "runtime")] -impl Default for ListTradesRequest { - fn default() -> Self { - Self::new() - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct Page<T> { - pub items: Vec<T>, - pub next_cursor: Option<String>, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct TradeStatusView { - pub trade_id: TradeId, - pub projection: Projection, - pub source_event_count: usize, - pub private_terms: Vec<TradePrivateTermsAvailabilityView>, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct TradeSummaryView { - pub trade_id: TradeId, - pub root_mutation_id: Option<MutationId>, - pub buyer_pubkey: Option<String>, - pub seller_pubkey: Option<String>, - pub farm_id: Option<String>, - pub negotiation_state: RadrootsTradeNegotiationStateV1, - pub agreement_state: RadrootsTradeAgreementStateV1, - pub evidence_state: RadrootsTradeEvidenceStateV1, - pub conflict_state: RadrootsTradeConflictStateV1, - pub private_terms_state: RadrootsTradePrivateTermsStateV1, - pub attestation_state: RadrootsTradeAttestationStateV1, - pub fulfillment_state: RadrootsTradeFulfillmentStateV1, - pub payment_state: RadrootsTradePaymentStateV1, - pub projection_digest: String, - pub source_event_count: usize, - pub updated_event_seq: i64, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct TradePrivateTermsAvailabilityView { - pub candidate_id: CandidateId, - pub artifact_id: Option<String>, - pub schema_id: Option<String>, - pub ciphertext_commitment: Option<String>, - pub state: RadrootsTradePrivateTermsStateV1, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct EvidenceRefreshReceipt { - pub api_version: u16, - pub trade_id: TradeId, - pub evidence_count: usize, - pub projection_digest: String, - pub projection_state: RadrootsTradePrivateTermsStateV1, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct EvidenceView { - pub artifact_id: String, - pub trade_id: TradeId, - pub candidate_id: Option<CandidateId>, - pub artifact_kind: TradePrivateArtifactKind, - pub schema_id: String, - pub ciphertext_commitment: String, - pub retention_class: String, - pub state: RadrootsTradePrivateTermsStateV1, - pub created_at_ms: i64, - pub expires_at_ms: Option<i64>, - pub deleted_at_ms: Option<i64>, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug)] -struct TradeCommandPlan { - operation_kind: &'static str, - actor: Actor, - frozen_draft: EventDraft, - trade_id: TradeId, - mutation_id: MutationId, - target_policy: TargetPolicy, - satisfaction_policy: SatisfactionPolicy, - idempotency_key: Option<SdkIdempotencyKey>, -} - -#[cfg(feature = "runtime")] -enum TradeCommandRequest { - SubmitProposal(SubmitProposalRequest), - ProposeRevision(ProposeRevisionRequest), - DecideCandidate(DecideCandidateRequest), - CancelTrade(CancelTradeRequest), - ResumeOperation(ResumeOperationRequest), -} - -#[cfg(feature = "runtime")] -impl TradeCommandRequest { - fn operation_kind(&self) -> &'static str { - match self { - Self::SubmitProposal(_) => TRADE_SUBMIT_PROPOSAL_OPERATION_KIND, - Self::ProposeRevision(_) => TRADE_PROPOSE_REVISION_OPERATION_KIND, - Self::DecideCandidate(_) => TRADE_DECIDE_CANDIDATE_OPERATION_KIND, - Self::CancelTrade(_) => TRADE_CANCEL_OPERATION_KIND, - Self::ResumeOperation(request) => request.operation_kind, - } - } - - fn into_parts( - self, - ) -> ( - Actor, - TradeMutationEnvelopeV1, - TargetPolicy, - SatisfactionPolicy, - Option<SdkIdempotencyKey>, - bool, - ) { - match self { - Self::SubmitProposal(request) => ( - request.actor, - request.envelope, - request.target_policy, - request.satisfaction_policy, - request.idempotency_key, - false, - ), - Self::ProposeRevision(request) => ( - request.actor, - request.envelope, - request.target_policy, - request.satisfaction_policy, - request.idempotency_key, - false, - ), - Self::DecideCandidate(request) => ( - request.actor, - request.envelope, - request.target_policy, - request.satisfaction_policy, - request.idempotency_key, - request.private_terms_acknowledged, - ), - Self::CancelTrade(request) => ( - request.actor, - request.envelope, - request.target_policy, - request.satisfaction_policy, - request.idempotency_key, - false, - ), - Self::ResumeOperation(request) => ( - request.actor, - request.envelope, - request.target_policy, - request.satisfaction_policy, - request.idempotency_key, - request.private_terms_acknowledged, - ), - } - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, Serialize, Deserialize)] -struct TradeListCursorPayload { - version: u8, - sort: TradeListSort, - filter_digest: String, - updated_event_seq: i64, - trade_id: String, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug)] -struct TradeListRow { - trade_id: TradeId, - updated_event_seq: i64, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug)] -struct LastTradeMutationSnapshot { - mutation_id: Option<MutationId>, - event_seq: Option<i64>, -} - -#[cfg(all(feature = "runtime", feature = "signer-adapters"))] -async fn enqueue_configured_trade_command( - sdk: &RadrootsClient, - request: TradeCommandRequest, -) -> Result<TradeCommandReceipt, RadrootsSdkError> { - let plan = trade_command_plan(sdk, request).await?; - let enqueue = enqueue_configured_signed_workflow(sdk, workflow_request(&plan)).await?; - trade_command_receipt(sdk, plan, enqueue).await -} - -#[cfg(feature = "runtime")] -async fn enqueue_trade_command_with_explicit_signer( - sdk: &RadrootsClient, - request: TradeCommandRequest, - signer: &dyn Signer, -) -> Result<TradeCommandReceipt, RadrootsSdkError> { - let plan = trade_command_plan(sdk, request).await?; - let enqueue = enqueue_signed_workflow(sdk, workflow_request(&plan), signer).await?; - trade_command_receipt(sdk, plan, enqueue).await -} - -#[cfg(feature = "runtime")] -async fn trade_command_plan( - sdk: &RadrootsClient, - request: TradeCommandRequest, -) -> Result<TradeCommandPlan, RadrootsSdkError> { - let operation_kind = request.operation_kind(); - let (actor, envelope, target_policy, satisfaction_policy, idempotency_key, acknowledged) = - request.into_parts(); - validate_operation_body(operation_kind, &envelope)?; - validate_actor_matches_envelope(operation_kind, &actor, &envelope)?; - let wire = trade_mutation_event_build(envelope.clone()).map_err(|error| { - trade_command_error( - RadrootsSdkTradeErrorKind::InvalidEnvelope, - operation_kind, - error.to_string(), - ) - })?; - let canonical = - trade_mutation_from_canonical_content(wire.content.as_str()).map_err(|error| { - trade_command_error( - RadrootsSdkTradeErrorKind::InvalidEnvelope, - operation_kind, - error.to_string(), - ) - })?; - validate_command_private_terms(sdk, operation_kind, &canonical, acknowledged).await?; - let mutation_id = canonical.mutation_id.ok_or_else(|| { - trade_command_error( - RadrootsSdkTradeErrorKind::InvalidEnvelope, - operation_kind, - "canonical trade mutation is missing mutation id", - ) - })?; - let frozen_draft = EventDraft::new( - canonical.contract_id.as_str(), - wire.kind, - canonical.authored_at_unix_s, - wire.tags, - wire.content, - actor.public_key().to_hex(), - ) - .map_err(|error| { - trade_command_error( - RadrootsSdkTradeErrorKind::InvalidEnvelope, - operation_kind, - error.to_string(), - ) - })?; - Ok(TradeCommandPlan { - operation_kind, - actor, - frozen_draft, - trade_id: canonical.trade_id, - mutation_id, - target_policy, - satisfaction_policy, - idempotency_key, - }) -} - -#[cfg(feature = "runtime")] -fn workflow_request(plan: &TradeCommandPlan) -> SdkWorkflowEnqueueRequest<'_> { - SdkWorkflowEnqueueRequest { - operation_kind: plan.operation_kind, - actor: &plan.actor, - frozen_draft: &plan.frozen_draft, - target_policy: plan.target_policy.clone(), - satisfaction_policy: plan.satisfaction_policy.clone(), - idempotency_key: plan.idempotency_key.clone(), - } -} - -#[cfg(feature = "runtime")] -async fn trade_command_receipt( - sdk: &RadrootsClient, - plan: TradeCommandPlan, - enqueue: SdkWorkflowEnqueueReceipt, -) -> Result<TradeCommandReceipt, RadrootsSdkError> { - let projection_state = trade_projection_for_trade(sdk, &plan.trade_id).await.ok(); - Ok(TradeCommandReceipt { - api_version: 1, - operation_kind: plan.operation_kind.to_owned(), - operation_state: TradeCommandLifecycleState::Committed, - trade_id: plan.trade_id, - mutation_id: plan.mutation_id, - expected_event_id: EventId::parse(plan.frozen_draft.expected_event_id_hex()) - .expect("trade workflow draft has a valid 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, - delivery_state: enqueue.state.into(), - projection_state, - idempotency_digest_prefix: enqueue.idempotency_digest_prefix, - recovery_actions: Vec::new(), - warnings: Vec::new(), - }) -} - -#[cfg(feature = "runtime")] -fn validate_operation_body( - operation_kind: &'static str, - envelope: &TradeMutationEnvelopeV1, -) -> Result<(), RadrootsSdkError> { - let valid = match operation_kind { - TRADE_SUBMIT_PROPOSAL_OPERATION_KIND => { - matches!(envelope.body, TradeMutationBodyV1::Proposal { .. }) - } - TRADE_PROPOSE_REVISION_OPERATION_KIND => { - matches!(envelope.body, TradeMutationBodyV1::RevisionProposal { .. }) - } - TRADE_DECIDE_CANDIDATE_OPERATION_KIND | TRADE_RESUME_OPERATION_KIND => matches!( - envelope.body, - TradeMutationBodyV1::Decision { .. } | TradeMutationBodyV1::RevisionDecision { .. } - ), - TRADE_CANCEL_OPERATION_KIND => { - matches!(envelope.body, TradeMutationBodyV1::Cancellation { .. }) - } - _ => true, - }; - if valid { - Ok(()) - } else { - Err(trade_command_error( - RadrootsSdkTradeErrorKind::InvalidCommandBody, - operation_kind, - "trade command operation kind does not match mutation body", - )) - } -} - -#[cfg(feature = "runtime")] -fn validate_actor_matches_envelope( - operation_kind: &'static str, - actor: &Actor, - envelope: &TradeMutationEnvelopeV1, -) -> Result<(), RadrootsSdkError> { - if actor.public_key() == envelope.author_pubkey { - Ok(()) - } else { - Err(RadrootsSdkError::UnauthorizedActor { - operation: operation_kind.to_owned(), - reason: "actor pubkey must match trade mutation author_pubkey".to_owned(), - }) - } -} - -#[cfg(feature = "runtime")] -async fn validate_command_private_terms( - sdk: &RadrootsClient, - operation_kind: &'static str, - envelope: &TradeMutationEnvelopeV1, - private_terms_acknowledged: bool, -) -> Result<(), RadrootsSdkError> { - match &envelope.body { - TradeMutationBodyV1::Proposal { candidate } - | TradeMutationBodyV1::RevisionProposal { candidate } => { - let Some(candidate_id) = &candidate.candidate_id else { - return Ok(()); - }; - if candidate.fulfillment.requires_private_terms && candidate.private_terms.is_none() { - return Err(trade_command_error( - RadrootsSdkTradeErrorKind::PrivateArtifactMissing, - operation_kind, - "candidate requires private terms but no private terms reference is present", - )); - } - if let Some(private_ref) = &candidate.private_terms { - ensure_private_terms_ref_available( - sdk, - operation_kind, - &envelope.trade_id, - candidate_id, - private_ref, - ) - .await?; - } - Ok(()) - } - TradeMutationBodyV1::Decision { - proposal_mutation_id, - candidate_id, - decision, - } - | TradeMutationBodyV1::RevisionDecision { - proposal_mutation_id, - candidate_id, - decision, - } => { - if !matches!(decision, TradeDecisionV1::Accepted { .. }) { - return Ok(()); - } - let Some(candidate_ref) = referenced_candidate_for_decision( - sdk, - operation_kind, - proposal_mutation_id, - candidate_id, - ) - .await? - else { - return Ok(()); - }; - let requires_private_terms = candidate_ref.fulfillment.requires_private_terms - || candidate_ref.private_terms.is_some(); - if !requires_private_terms { - return Ok(()); - } - let Some(private_ref) = &candidate_ref.private_terms else { - return Err(trade_command_error( - RadrootsSdkTradeErrorKind::PrivateArtifactMissing, - operation_kind, - "accepted candidate requires private terms but no private terms reference is present", - )); - }; - if private_ref.required_acknowledgement && !private_terms_acknowledged { - return Err(trade_command_error( - RadrootsSdkTradeErrorKind::PrivateArtifactAcknowledgementMissing, - operation_kind, - "accepted candidate private terms require explicit acknowledgement", - )); - } - ensure_private_terms_ref_available( - sdk, - operation_kind, - &envelope.trade_id, - candidate_id, - private_ref, - ) - .await - } - TradeMutationBodyV1::Cancellation { .. } => Ok(()), - } -} - -#[cfg(feature = "runtime")] -async fn referenced_candidate_for_decision( - sdk: &RadrootsClient, - operation_kind: &'static str, - proposal_mutation_id: &MutationId, - candidate_id: &CandidateId, -) -> Result<Option<radroots_event::trade::TradeCandidateTermsV1>, RadrootsSdkError> { - let Some(stored) = sdk - ._event_store - .get_trade_mutation(proposal_mutation_id) - .await? - else { - return Err(trade_command_error( - RadrootsSdkTradeErrorKind::TradeNotFound, - operation_kind, - "referenced proposal mutation is not present in the local event store", - )); - }; - let envelope = stored_trade_envelope(&stored)?; - let candidate = match envelope.body { - TradeMutationBodyV1::Proposal { candidate } - | TradeMutationBodyV1::RevisionProposal { candidate } => candidate, - _ => { - return Err(trade_command_error( - RadrootsSdkTradeErrorKind::InvalidCommandBody, - operation_kind, - "referenced mutation is not a candidate proposal", - )); - } - }; - if candidate.candidate_id.as_ref() != Some(candidate_id) { - return Err(trade_command_error( - RadrootsSdkTradeErrorKind::InvalidCommandBody, - operation_kind, - "decision candidate_id does not match referenced proposal candidate_id", - )); - } - Ok(Some(candidate)) -} - -#[cfg(feature = "runtime")] -async fn ensure_private_terms_ref_available( - sdk: &RadrootsClient, - operation_kind: &'static str, - trade_id: &TradeId, - candidate_id: &CandidateId, - private_ref: &TradePrivateTermsRefV1, -) -> Result<(), RadrootsSdkError> { - let trade_id_hex = trade_id.to_hex(); - let candidate_id_hex = candidate_id.to_hex(); - let evidence = sdk - ._private_store - .private_terms_evidence( - trade_id_hex.as_str(), - candidate_id_hex.as_str(), - private_ref.artifact_id.as_str(), - private_ref.schema_id.as_str(), - private_ref.ciphertext_commitment.as_str(), - ) - .await?; - match evidence.state() { - RadrootsTradePrivateTermsStateV1::AvailableVerified => Ok(()), - RadrootsTradePrivateTermsStateV1::CommitmentMismatch => Err(trade_command_error( - RadrootsSdkTradeErrorKind::PrivateArtifactCommitmentMismatch, - operation_kind, - "private artifact commitment does not match candidate private terms reference", - )), - _ => Err(trade_command_error( - RadrootsSdkTradeErrorKind::PrivateArtifactMissing, - operation_kind, - "private artifact is unavailable for candidate private terms reference", - )), - } -} - -#[cfg(feature = "runtime")] -async fn seal_private_artifact( - sdk: &RadrootsClient, - request: TradePrivateArtifactSealRequest, -) -> Result<TradePrivateArtifactSealReceipt, RadrootsSdkError> { - let now_ms = sdk_now_ms(sdk)?; - let input = SdkPrivateTradeArtifactInput { - artifact_id: request.artifact_id, - trade_id: request.trade_id.to_hex(), - candidate_id: request.candidate_id.as_ref().map(CandidateId::to_hex), - artifact_kind: request.artifact_kind.into(), - schema_id: request.schema_id, - plaintext: request.plaintext, - retention_class: request.retention_class, - created_at_ms: now_ms, - expires_at_ms: request.expires_at_ms, - }; - let metadata = sdk._private_store.upsert_trade_artifact(&input).await?; - Ok(seal_receipt_from_metadata(metadata)) -} - -#[cfg(feature = "runtime")] -fn seal_receipt_from_metadata( - metadata: SdkPrivateTradeArtifactMetadata, -) -> TradePrivateArtifactSealReceipt { - let artifact_kind = TradePrivateArtifactKind::from(metadata.artifact_kind); - let private_terms_ref = if artifact_kind == TradePrivateArtifactKind::BindingTerms { - Some(TradePrivateTermsRefV1 { - artifact_id: metadata.artifact_id.clone(), - schema_id: metadata.schema_id.clone(), - ciphertext_commitment: metadata.ciphertext_commitment.clone(), - required_acknowledgement: true, - }) - } else { - None - }; - TradePrivateArtifactSealReceipt { - artifact_id: metadata.artifact_id, - trade_id: TradeId::parse(metadata.trade_id).expect("stored trade id is valid"), - candidate_id: parse_optional_candidate_id(metadata.candidate_id), - artifact_kind, - schema_id: metadata.schema_id, - ciphertext_commitment: metadata.ciphertext_commitment, - private_terms_ref, - retention_class: metadata.retention_class, - created_at_ms: metadata.created_at_ms, - expires_at_ms: metadata.expires_at_ms, - } -} - -#[cfg(feature = "runtime")] -async fn open_private_artifact( - sdk: &RadrootsClient, - request: TradePrivateArtifactOpenRequest, -) -> Result<Option<TradePrivateArtifactOpenReceipt>, RadrootsSdkError> { - let Some(record) = sdk - ._private_store - .trade_artifact(request.artifact_id.as_str()) - .await? - else { - return Ok(None); - }; - Ok(Some(TradePrivateArtifactOpenReceipt { - artifact_id: record.artifact_id, - trade_id: TradeId::parse(record.trade_id).expect("stored trade id is valid"), - candidate_id: parse_optional_candidate_id(record.candidate_id), - artifact_kind: record.artifact_kind.into(), - schema_id: record.schema_id, - plaintext: record.plaintext, - retention_class: record.retention_class, - created_at_ms: record.created_at_ms, - expires_at_ms: record.expires_at_ms, - deleted_at_ms: record.deleted_at_ms, - })) -} - -#[cfg(feature = "runtime")] -async fn delete_private_artifact( - sdk: &RadrootsClient, - request: TradePrivateArtifactDeleteRequest, -) -> Result<TradePrivateArtifactDeleteReceipt, RadrootsSdkError> { - let deleted_at_ms = sdk_now_ms(sdk)?; - let deleted = sdk - ._private_store - .delete_trade_artifact(request.artifact_id.as_str(), deleted_at_ms) - .await?; - Ok(TradePrivateArtifactDeleteReceipt { - artifact_id: request.artifact_id, - deleted, - deleted_at_ms, - }) -} - -#[cfg(feature = "runtime")] -async fn trade_status_view( - sdk: &RadrootsClient, - trade_id: &TradeId, -) -> Result<TradeStatusView, RadrootsSdkError> { - let projection = trade_projection_for_trade(sdk, trade_id).await?; - let private_terms = private_terms_views_for_trade(sdk, trade_id).await?; - let source_event_count = sdk - ._event_store - .trade_mutations_for_trade(trade_id, TRADE_MUTATION_QUERY_LIMIT) - .await? - .len(); - Ok(TradeStatusView { - trade_id: *trade_id, - projection, - source_event_count, - private_terms, - }) -} - -#[cfg(feature = "runtime")] -async fn trade_projection_for_trade( - sdk: &RadrootsClient, - trade_id: &TradeId, -) -> Result<Projection, RadrootsSdkError> { - let stored = sdk - ._event_store - .trade_mutations_for_trade(trade_id, TRADE_MUTATION_QUERY_LIMIT) - .await?; - if stored.is_empty() { - return Err(trade_query_error( - RadrootsSdkTradeErrorKind::TradeNotFound, - "trade.get", - "trade is not present in the local event store", - )); - } - let mutations = stored - .iter() - .map(stored_trade_mutation_record) - .collect::<Result<Vec<_>, _>>()?; - let private_terms = private_terms_evidence_for_mutations(sdk, trade_id, &mutations).await?; - let input = ReductionInput::new(*trade_id) - .with_mutations(mutations) - .with_private_terms(private_terms) - .with_observed_at_unix_s(Some(sdk.now()?.unix_seconds())); - Ok(reduce_trade_records(input)) -} - -#[cfg(feature = "runtime")] -async fn private_terms_views_for_trade( - sdk: &RadrootsClient, - trade_id: &TradeId, -) -> Result<Vec<TradePrivateTermsAvailabilityView>, RadrootsSdkError> { - let stored = sdk - ._event_store - .trade_mutations_for_trade(trade_id, TRADE_MUTATION_QUERY_LIMIT) - .await?; - let records = stored - .iter() - .map(stored_trade_mutation_record) - .collect::<Result<Vec<_>, _>>()?; - let evidence = private_terms_evidence_for_mutations(sdk, trade_id, &records).await?; - let mut evidence_by_candidate = evidence - .into_iter() - .map(|item| (*item.candidate_id(), item.state())) - .collect::<BTreeMap<_, _>>(); - let mut views = BTreeMap::<CandidateId, TradePrivateTermsAvailabilityView>::new(); - for record in records { - if let Some((candidate_id, private_ref)) = candidate_private_ref(record.mutation()) { - let state = evidence_by_candidate - .remove(&candidate_id) - .unwrap_or(RadrootsTradePrivateTermsStateV1::Missing); - views.insert( - candidate_id, - TradePrivateTermsAvailabilityView { - candidate_id, - artifact_id: Some(private_ref.artifact_id.clone()), - schema_id: Some(private_ref.schema_id.clone()), - ciphertext_commitment: Some(private_ref.ciphertext_commitment.clone()), - state, - }, - ); - } - } - Ok(views.into_values().collect()) -} - -#[cfg(feature = "runtime")] -async fn private_terms_evidence_for_mutations( - sdk: &RadrootsClient, - trade_id: &TradeId, - mutations: &[RadrootsTradeMutationRecordV1], -) -> Result<Vec<RadrootsTradePrivateTermsEvidenceV1>, RadrootsSdkError> { - let mut evidence = Vec::new(); - let mut seen = BTreeSet::new(); - let trade_id_hex = trade_id.to_hex(); - for record in mutations { - if let Some((candidate_id, private_ref)) = candidate_private_ref(record.mutation()) { - if !seen.insert(candidate_id) { - continue; - } - let candidate_id_hex = candidate_id.to_hex(); - evidence.push( - sdk._private_store - .private_terms_evidence( - trade_id_hex.as_str(), - candidate_id_hex.as_str(), - private_ref.artifact_id.as_str(), - private_ref.schema_id.as_str(), - private_ref.ciphertext_commitment.as_str(), - ) - .await?, - ); - } - } - Ok(evidence) -} - -#[cfg(feature = "runtime")] -fn candidate_private_ref( - envelope: &TradeMutationEnvelopeV1, -) -> Option<(CandidateId, TradePrivateTermsRefV1)> { - match &envelope.body { - TradeMutationBodyV1::Proposal { candidate } - | TradeMutationBodyV1::RevisionProposal { candidate } => { - let candidate_id = candidate.candidate_id?; - let private_ref = candidate.private_terms.clone()?; - Some((candidate_id, private_ref)) - } - _ => None, - } -} - -#[cfg(feature = "runtime")] -fn stored_trade_mutation_record( - stored: &RadrootsStoredTradeMutation, -) -> Result<RadrootsTradeMutationRecordV1, RadrootsSdkError> { - Ok(RadrootsTradeMutationRecordV1::new( - Some(stored.first_transport_event_id), - stored_trade_envelope(stored)?, - )) -} - -#[cfg(feature = "runtime")] -fn stored_trade_envelope( - stored: &RadrootsStoredTradeMutation, -) -> Result<TradeMutationEnvelopeV1, RadrootsSdkError> { - let content = - std::str::from_utf8(stored.canonical_payload_bytes.as_slice()).map_err(|error| { - RadrootsSdkError::Projection { - message: error.to_string(), - } - })?; - trade_mutation_from_canonical_content(content).map_err(|error| RadrootsSdkError::Projection { - message: error.to_string(), - }) -} - -#[cfg(feature = "runtime")] -async fn list_trade_views( - sdk: &RadrootsClient, - request: ListTradesRequest, -) -> Result<Page<TradeSummaryView>, RadrootsSdkError> { - let limit = bounded_limit(request.limit, "trade.list")?; - let filter_digest = filter_digest(&request)?; - let cursor = request - .cursor - .as_deref() - .map(|cursor| decode_trade_list_cursor(cursor, &filter_digest, request.sort)) - .transpose()?; - let rows = list_trade_rows(sdk, &request, cursor.as_ref(), limit + 1).await?; - let mut items = Vec::new(); - for row in rows - .iter() - .take(usize::try_from(limit).expect("limit fits usize")) - { - let view = trade_status_view(sdk, &row.trade_id).await?; - items.push(TradeSummaryView { - trade_id: view.trade_id, - root_mutation_id: view.projection.root_mutation_id().copied(), - buyer_pubkey: view.projection.buyer_pubkey().map(ToString::to_string), - seller_pubkey: view.projection.seller_pubkey().map(ToString::to_string), - farm_id: view.projection.farm_id().map(ToString::to_string), - negotiation_state: view.projection.negotiation_state(), - agreement_state: view.projection.agreement_state(), - evidence_state: view.projection.evidence_state(), - conflict_state: view.projection.conflict_state(), - private_terms_state: view.projection.private_terms_state(), - attestation_state: view.projection.attestation_state(), - fulfillment_state: view.projection.fulfillment_state(), - payment_state: view.projection.payment_state(), - projection_digest: view.projection.projection_digest().to_owned(), - source_event_count: view.source_event_count, - updated_event_seq: row.updated_event_seq, - }); - } - let next_cursor = if rows.len() > usize::try_from(limit).expect("limit fits usize") { - rows.get(usize::try_from(limit - 1).expect("limit fits usize")) - .map(|row| encode_trade_list_cursor(row, &filter_digest, request.sort)) - .transpose()? - .flatten() - } else { - None - }; - Ok(Page { items, next_cursor }) -} - -#[cfg(feature = "runtime")] -async fn list_trade_rows( - sdk: &RadrootsClient, - request: &ListTradesRequest, - cursor: Option<&TradeListCursorPayload>, - limit: u32, -) -> Result<Vec<TradeListRow>, RadrootsSdkError> { - let mut query: QueryBuilder<Sqlite> = QueryBuilder::new( - "SELECT m.trade_id, MAX(m.first_event_seq) AS updated_event_seq FROM trade_mutation m", - ); - if !request.filter.agreement_states_any_of.is_empty() { - query.push(" JOIN trade_projection_checkpoint c ON c.trade_id = m.trade_id"); - } - query.push(" WHERE 1 = 1"); - if let Some(buyer_pubkey) = &request.filter.buyer_pubkey { - query.push(" AND m.buyer_pubkey = "); - query.push_bind(buyer_pubkey); - } - if let Some(seller_pubkey) = &request.filter.seller_pubkey { - query.push(" AND m.seller_pubkey = "); - query.push_bind(seller_pubkey); - } - if !request.filter.participant_pubkeys_any_of.is_empty() { - query.push(" AND (m.buyer_pubkey IN ("); - push_string_list(&mut query, &request.filter.participant_pubkeys_any_of); - query.push(") OR m.seller_pubkey IN ("); - push_string_list(&mut query, &request.filter.participant_pubkeys_any_of); - query.push("))"); - } - if !request.filter.agreement_states_any_of.is_empty() { - let labels = request - .filter - .agreement_states_any_of - .iter() - .map(enum_label) - .collect::<Result<Vec<_>, _>>()?; - query.push(" AND c.agreement_state IN ("); - push_string_list(&mut query, &labels); - query.push(")"); - } - if !request.filter.any_of.is_empty() { - query.push(" AND ("); - for (index, clause) in request.filter.any_of.iter().enumerate() { - if index > 0 { - query.push(" OR "); - } - match clause { - TradeListAnyOf::TradeId(value) => { - query.push("m.trade_id = "); - query.push_bind(value); - } - TradeListAnyOf::BuyerPubkey(value) => { - query.push("m.buyer_pubkey = "); - query.push_bind(value); - } - TradeListAnyOf::SellerPubkey(value) => { - query.push("m.seller_pubkey = "); - query.push_bind(value); - } - TradeListAnyOf::ParticipantPubkey(value) => { - query.push("(m.buyer_pubkey = "); - query.push_bind(value); - query.push(" OR m.seller_pubkey = "); - query.push_bind(value); - query.push(")"); - } - } - } - query.push(")"); - } - query.push(" GROUP BY m.trade_id"); - if let Some(cursor) = cursor { - query.push(" HAVING (updated_event_seq < "); - query.push_bind(cursor.updated_event_seq); - query.push(" OR (updated_event_seq = "); - query.push_bind(cursor.updated_event_seq); - query.push(" AND m.trade_id > "); - query.push_bind(cursor.trade_id.as_str()); - query.push("))"); - } - query.push(" ORDER BY updated_event_seq DESC, m.trade_id ASC LIMIT "); - query.push_bind(i64::from(limit)); - let rows = query - .build() - .fetch_all(sdk._event_store.pool()) - .await - .map_err(trade_query_store_error)?; - rows.into_iter() - .map(|row| { - Ok(TradeListRow { - trade_id: TradeId::parse( - row.try_get::<String, _>("trade_id") - .map_err(trade_query_store_error)?, - ) - .map_err(|error| { - trade_query_error( - RadrootsSdkTradeErrorKind::InvalidEnvelope, - "trade.list", - format!("stored trade id is invalid: {error}"), - ) - })?, - updated_event_seq: row - .try_get("updated_event_seq") - .map_err(trade_query_store_error)?, - }) - }) - .collect() -} - -#[cfg(feature = "runtime")] -fn push_string_list(query: &mut QueryBuilder<Sqlite>, values: &[String]) { - let mut separated = query.separated(", "); - for value in values { - separated.push_bind(value.as_str()); - } -} - -#[cfg(feature = "runtime")] -async fn inspect_evidence_views( - sdk: &RadrootsClient, - request: InspectEvidenceRequest, -) -> Result<Page<EvidenceView>, RadrootsSdkError> { - let limit = bounded_limit(request.limit, "trade.inspect_evidence")?; - let offset = evidence_cursor_offset(request.cursor.as_deref())?; - let metadata = sdk - ._private_store - .trade_artifact_metadata_for_trade(&request.trade_id.to_hex()) - .await?; - let mut items = Vec::new(); - for item in metadata - .iter() - .skip(offset) - .take(usize::try_from(limit).expect("limit fits usize")) - { - let state = if item.deleted_at_ms.is_some() { - RadrootsTradePrivateTermsStateV1::Missing - } else { - RadrootsTradePrivateTermsStateV1::AvailableVerified - }; - items.push(EvidenceView { - artifact_id: item.artifact_id.clone(), - trade_id: TradeId::parse(item.trade_id.clone()).expect("stored trade id is valid"), - candidate_id: parse_optional_candidate_id(item.candidate_id.clone()), - artifact_kind: item.artifact_kind.into(), - schema_id: item.schema_id.clone(), - ciphertext_commitment: item.ciphertext_commitment.clone(), - retention_class: item.retention_class.clone(), - state, - created_at_ms: item.created_at_ms, - expires_at_ms: item.expires_at_ms, - deleted_at_ms: item.deleted_at_ms, - }); - } - let next_offset = offset + items.len(); - let next_cursor = if metadata.len() > next_offset { - Some(encode_offset_cursor(next_offset)?) - } else { - None - }; - Ok(Page { items, next_cursor }) -} - -#[cfg(feature = "runtime")] -async fn last_trade_mutation_snapshot( - sdk: &RadrootsClient, - trade_id: &TradeId, -) -> Result<LastTradeMutationSnapshot, RadrootsSdkError> { - let row = sqlx::query( - "SELECT mutation_id, first_event_seq FROM trade_mutation WHERE trade_id = ? ORDER BY first_event_seq DESC, mutation_id DESC LIMIT 1", - ) - .bind(trade_id.to_hex()) - .fetch_optional(sdk._event_store.pool()) - .await - .map_err(trade_query_store_error)?; - match row { - Some(row) => Ok(LastTradeMutationSnapshot { - mutation_id: Some( - MutationId::parse( - row.try_get::<String, _>("mutation_id") - .map_err(trade_query_store_error)?, - ) - .map_err(|error| { - trade_query_error( - RadrootsSdkTradeErrorKind::InvalidEnvelope, - "trade.refresh_evidence", - format!("stored mutation id is invalid: {error}"), - ) - })?, - ), - event_seq: Some( - row.try_get("first_event_seq") - .map_err(trade_query_store_error)?, - ), - }), - None => Ok(LastTradeMutationSnapshot { - mutation_id: None, - event_seq: None, - }), - } -} - -#[cfg(feature = "runtime")] -fn bounded_limit(limit: Option<u32>, operation: &'static str) -> Result<u32, RadrootsSdkError> { - let limit = limit.unwrap_or(TRADE_QUERY_DEFAULT_LIMIT); - if !(1..=TRADE_QUERY_MAX_LIMIT).contains(&limit) { - return Err(trade_query_error( - RadrootsSdkTradeErrorKind::QueryLimitInvalid, - operation, - format!("trade query limit must be between 1 and {TRADE_QUERY_MAX_LIMIT}"), - )); - } - Ok(limit) -} - -#[cfg(feature = "runtime")] -fn filter_digest(request: &ListTradesRequest) -> Result<String, RadrootsSdkError> { - let bytes = serde_json::to_vec(&serde_json::json!({ - "filter": request.filter, - "sort": request.sort - })) - .map_err(trade_query_store_error)?; - Ok(hex::encode(Sha256::digest(bytes))) -} - -#[cfg(feature = "runtime")] -fn encode_trade_list_cursor( - row: &TradeListRow, - filter_digest: &str, - sort: TradeListSort, -) -> Result<Option<String>, RadrootsSdkError> { - let payload = TradeListCursorPayload { - version: TRADE_LIST_CURSOR_VERSION, - sort, - filter_digest: filter_digest.to_owned(), - updated_event_seq: row.updated_event_seq, - trade_id: row.trade_id.to_string(), - }; - let bytes = serde_json::to_vec(&payload).map_err(trade_query_store_error)?; - Ok(Some(URL_SAFE_NO_PAD.encode(bytes))) -} - -#[cfg(feature = "runtime")] -fn decode_trade_list_cursor( - cursor: &str, - filter_digest: &str, - sort: TradeListSort, -) -> Result<TradeListCursorPayload, RadrootsSdkError> { - let bytes = URL_SAFE_NO_PAD.decode(cursor).map_err(|error| { - trade_query_error( - RadrootsSdkTradeErrorKind::CursorInvalid, - "trade.list", - error.to_string(), - ) - })?; - let payload: TradeListCursorPayload = - serde_json::from_slice(bytes.as_slice()).map_err(|error| { - trade_query_error( - RadrootsSdkTradeErrorKind::CursorInvalid, - "trade.list", - error.to_string(), - ) - })?; - if payload.version != TRADE_LIST_CURSOR_VERSION - || payload.filter_digest != filter_digest - || payload.sort != sort - { - return Err(trade_query_error( - RadrootsSdkTradeErrorKind::CursorInvalid, - "trade.list", - "trade list cursor does not match request filter or sort", - )); - } - Ok(payload) -} - -#[cfg(feature = "runtime")] -fn evidence_cursor_offset(cursor: Option<&str>) -> Result<usize, RadrootsSdkError> { - let Some(cursor) = cursor else { - return Ok(0); - }; - let bytes = URL_SAFE_NO_PAD.decode(cursor).map_err(|error| { - trade_query_error( - RadrootsSdkTradeErrorKind::CursorInvalid, - "trade.inspect_evidence", - error.to_string(), - ) - })?; - let value: serde_json::Value = serde_json::from_slice(bytes.as_slice()).map_err(|error| { - trade_query_error( - RadrootsSdkTradeErrorKind::CursorInvalid, - "trade.inspect_evidence", - error.to_string(), - ) - })?; - let version = value - .get("version") - .and_then(serde_json::Value::as_u64) - .ok_or_else(|| { - trade_query_error( - RadrootsSdkTradeErrorKind::CursorInvalid, - "trade.inspect_evidence", - "evidence cursor is missing version", - ) - })?; - let offset = value - .get("offset") - .and_then(serde_json::Value::as_u64) - .ok_or_else(|| { - trade_query_error( - RadrootsSdkTradeErrorKind::CursorInvalid, - "trade.inspect_evidence", - "evidence cursor is missing offset", - ) - })?; - if version != 1 { - return Err(trade_query_error( - RadrootsSdkTradeErrorKind::CursorInvalid, - "trade.inspect_evidence", - "evidence cursor version is unsupported", - )); - } - usize::try_from(offset).map_err(|_| { - trade_query_error( - RadrootsSdkTradeErrorKind::CursorInvalid, - "trade.inspect_evidence", - "evidence cursor offset is too large", - ) - }) -} - -#[cfg(feature = "runtime")] -fn encode_offset_cursor(offset: usize) -> Result<String, RadrootsSdkError> { - let bytes = serde_json::to_vec(&serde_json::json!({ - "version": 1, - "offset": offset - })) - .map_err(trade_query_store_error)?; - Ok(URL_SAFE_NO_PAD.encode(bytes)) -} - -#[cfg(feature = "runtime")] -fn enum_label<T: Serialize>(value: &T) -> Result<String, RadrootsSdkError> { - serde_json::to_value(value) - .map_err(trade_query_store_error)? - .as_str() - .map(ToOwned::to_owned) - .ok_or_else(|| RadrootsSdkError::Projection { - message: "projection state label did not serialize to string".to_owned(), - }) -} - -#[cfg(feature = "runtime")] -fn parse_optional_candidate_id(value: Option<String>) -> Option<CandidateId> { - value - .map(|candidate_id| CandidateId::parse(candidate_id).expect("stored candidate id is valid")) -} - -#[cfg(feature = "runtime")] -fn trade_command_error( - kind: RadrootsSdkTradeErrorKind, - operation: &'static str, - message: impl Into<String>, -) -> RadrootsSdkError { - RadrootsSdkError::Trade { - kind, - operation: operation.to_owned(), - message: message.into(), - } -} - -#[cfg(feature = "runtime")] -fn trade_query_error( - kind: RadrootsSdkTradeErrorKind, - operation: &'static str, - message: impl Into<String>, -) -> RadrootsSdkError { - RadrootsSdkError::Trade { - kind, - operation: operation.to_owned(), - message: message.into(), - } -} - -#[cfg(feature = "runtime")] -fn trade_query_store_error(error: impl ToString) -> RadrootsSdkError { - RadrootsSdkError::Projection { - message: error.to_string(), - } -} - -#[cfg(all(test, feature = "runtime", feature = "signer-adapters"))] -#[path = "../tests/unit/trade_runtime_tests.rs"] -mod tests; diff --git a/crates/sdk/tests/package_boundary.rs b/crates/sdk/tests/package_boundary.rs @@ -5,6 +5,7 @@ const ROOT: &str = include_str!("../src/lib.rs"); const CLIENT: &str = include_str!("../src/client.rs"); const FARM: &str = include_str!("../src/farm.rs"); const LISTING: &str = include_str!("../src/listing.rs"); +const TRADE: &str = include_str!("../src/trade.rs"); const SYNC: &str = include_str!("../src/sync.rs"); const TRANSPORT: &str = include_str!("../src/transport.rs"); @@ -267,6 +268,46 @@ fn listing_operations_reuse_trade_event_sync_and_privacy_boundaries() { ); } +#[test] +fn trade_operations_use_canonical_workflow_storage_sync_and_projection_types() { + for required in [ + "WorkflowPlan", + "TradeMutationEnvelopeV1", + "ReductionInput", + "Projection", + "reduce_trade_records", + "EventQuery", + "EventPage<StoredVisibleEvent>", + "PrivateArtifactMetadata", + "radroots_sync::PushRequest::new", + ] { + assert!( + TRADE.contains(required), + "missing trade boundary `{required}`" + ); + } + for forbidden in [ + "sqlx::", + "QueryBuilder", + "TradeStatusView", + "SdkPrivateTradeArtifact", + "SdkMutationState", + "TradeCommandReceipt", + "struct Page", + "struct TradeId", + ] { + assert!( + !TRADE.contains(forbidden), + "trade source contains retired duplicate `{forbidden}`" + ); + } + assert!( + !std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("src/trade_runtime.rs") + .exists() + ); +} + fn dependency_names(manifest: &str) -> BTreeSet<&str> { let dependencies = manifest .split_once("[dependencies]") diff --git a/crates/sdk/tests/unit/trade_runtime_tests.rs b/crates/sdk/tests/unit/trade_runtime_tests.rs @@ -1,375 +0,0 @@ -use super::*; -use crate::{ - RadrootsClient, RadrootsSdkError, RadrootsSdkTimestamp, RadrootsSdkTradeErrorKind, - SatisfactionPolicy, SdkIdempotencyKey, TargetPolicy, -}; -use radroots_event::{ - contract::AuthorRole, - envelope::kind::TRADE_MUTATION_EVENT_KINDS, - id::{ClassifiedListingAddress, DTag, EventId, InventoryBinId, TradeId}, - trade::{ - FulfillmentProfileV1, RADROOTS_TRADE_DECISION_CONTRACT_ID, - RADROOTS_TRADE_MAX_PRIVATE_ARTIFACT_BYTES, RADROOTS_TRADE_MUTATION_CONTRACT_IDS, - RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, RADROOTS_TRADE_SCHEMA_VERSION, - SellerReservationAssertionV1, SellerReservationLineV1, TradeCancellationProfileV1, - TradeCandidateLineV1, TradeCandidateTermsV1, TradeDecisionV1, TradeEconomicAdjustmentV1, - TradeEconomicsProfileV1, TradeMutationBodyV1, TradeMutationEnvelopeV1, - canonical_trade_mutation_content, - }, -}; -use radroots_identity::PublicKey; -use radroots_nostr::signing::LocalSigner; -use radroots_signing::{Actor, actor::ActorSource}; -use radroots_trade::model::RadrootsTradePrivateTermsStateV1; -use radroots_trade::reducer::{RADROOTS_TRADE_REDUCER_CONTRACT_ID, RADROOTS_TRADE_REDUCER_VERSION}; - -fn pubkey(value: &str) -> PublicKey { - PublicKey::from_hex(value).expect("pubkey") -} - -fn event_id(marker: char) -> EventId { - EventId::parse(std::iter::repeat_n(marker, 64).collect::<String>()).expect("event id") -} - -fn trade_id() -> TradeId { - TradeId::parse("11111111111111111111111111111111").expect("trade id") -} - -fn local_signer() -> (String, LocalSigner) { - let signer = LocalSigner::generate().expect("local signer"); - let pubkey = signer.public_key().to_hex(); - (pubkey, signer) -} - -#[tokio::test] -async fn trade_capabilities_report_canonical_release_product_surface() { - let sdk = RadrootsClient::builder().build().await.expect("sdk"); - let capabilities = sdk.trades().capabilities(); - - assert_eq!( - capabilities.api_version, - TRADE_RUNTIME_CAPABILITY_API_VERSION - ); - assert_eq!( - capabilities.protocol.protocol_profile_id, - TRADE_RUNTIME_PROTOCOL_PROFILE_ID - ); - assert_eq!( - capabilities.protocol.wire_profile_id, - TRADE_RUNTIME_WIRE_PROFILE_ID - ); - assert_eq!( - capabilities.protocol.schema_version, - RADROOTS_TRADE_SCHEMA_VERSION - ); - assert_eq!( - capabilities.protocol.mutation_contract_ids, - RADROOTS_TRADE_MUTATION_CONTRACT_IDS.to_vec() - ); - assert_eq!( - capabilities.protocol.mutation_event_kinds, - TRADE_MUTATION_EVENT_KINDS.to_vec() - ); - assert!(!capabilities.protocol.mutation_event_kinds.contains(&3422)); - assert_eq!( - capabilities.protocol.reducer_contract_id, - RADROOTS_TRADE_REDUCER_CONTRACT_ID - ); - assert_eq!( - capabilities.protocol.reducer_version, - RADROOTS_TRADE_REDUCER_VERSION - ); - assert_eq!( - capabilities.storage.storage_profile_id, - TRADE_RUNTIME_STORAGE_PROFILE_ID - ); - assert_eq!( - capabilities.storage.private_storage_profile_id, - TRADE_RUNTIME_PRIVATE_STORAGE_PROFILE_ID - ); - assert_eq!( - capabilities.storage.max_private_artifact_bytes, - RADROOTS_TRADE_MAX_PRIVATE_ARTIFACT_BYTES - ); - assert!(capabilities.core_mvp.commands); - assert!(capabilities.core_mvp.queries); - assert!(capabilities.core_mvp.local_event_store); - assert!(capabilities.core_mvp.semantic_outbox); - assert!(capabilities.core_mvp.protected_private_artifacts); - assert!(capabilities.core_mvp.backup_restore); - assert!(!capabilities.optional_integrations.rhi_attestation); - assert!(!capabilities.optional_integrations.tangle_transport); - assert!(!capabilities.optional_integrations.reticulum_transport); -} - -fn buyer_actor(buyer_pubkey: &str) -> Actor { - Actor::from_public_key_hex( - buyer_pubkey, - ActorSource::ExplicitPublicKey, - [AuthorRole::Buyer], - ) - .expect("buyer") -} - -fn seller_actor(seller_pubkey: &str) -> Actor { - Actor::from_public_key_hex( - seller_pubkey, - ActorSource::ExplicitPublicKey, - [AuthorRole::Seller], - ) - .expect("seller") -} - -fn candidate(buyer_pubkey: &str, seller_pubkey: &str) -> TradeCandidateTermsV1 { - TradeCandidateTermsV1 { - candidate_id: None, - schema_version: RADROOTS_TRADE_SCHEMA_VERSION, - base_candidate_id: None, - supersession_intent: None, - buyer_pubkey: pubkey(buyer_pubkey), - seller_pubkey: pubkey(seller_pubkey), - farm_id: DTag::parse("farm-1").expect("farm id"), - lines: vec![TradeCandidateLineV1 { - line_id: DTag::parse("line-1").expect("line id"), - listing_addr: ClassifiedListingAddress::parse(format!( - "30402:{seller_pubkey}:listing-1" - )) - .expect("listing address"), - listing_event_id: event_id('c'), - listing_snapshot_sha256: "d".repeat(64), - product_id: "carrots".to_owned(), - option_id: None, - bin_id: InventoryBinId::parse("bin-1").expect("bin id"), - quantity_mantissa: "2".to_owned(), - quantity_scale: 0, - unit_code: "count".to_owned(), - unit_profile: "mvp-count".to_owned(), - unit_price_mantissa: "500".to_owned(), - currency_code: "USD".to_owned(), - line_subtotal_mantissa: "1000".to_owned(), - replaces_line_id: None, - }], - line_tombstones: Vec::new(), - economics: TradeEconomicsProfileV1 { - profile_id: "mvp-fixed".to_owned(), - currency_code: "USD".to_owned(), - currency_exponent: 2, - rounding_profile: "half-even".to_owned(), - subtotal_mantissa: "1000".to_owned(), - discount_total_mantissa: "0".to_owned(), - adjustment_total_mantissa: "0".to_owned(), - total_mantissa: "1000".to_owned(), - adjustments: Vec::<TradeEconomicAdjustmentV1>::new(), - }, - fulfillment: FulfillmentProfileV1 { - profile_id: "market-pickup".to_owned(), - method: "pickup".to_owned(), - starts_at_unix_s: 1_800_000_000, - ends_at_unix_s: 1_800_003_600, - timezone: "America/New_York".to_owned(), - utc_offset_seconds: -18_000, - fold: 0, - location_class: "farmstand".to_owned(), - requires_private_terms: true, - }, - cancellation: TradeCancellationProfileV1 { - profile_id: "buyer-pre-agreement".to_owned(), - buyer_pre_agreement: true, - post_agreement_cutoff_unix_s: None, - }, - private_terms: None, - proposal_expires_at_unix_s: 1_799_999_000, - } -} - -fn proposal( - candidate: TradeCandidateTermsV1, - buyer_pubkey: &str, - seller_pubkey: &str, -) -> TradeMutationEnvelopeV1 { - TradeMutationEnvelopeV1 { - mutation_id: None, - contract_id: RADROOTS_TRADE_PROPOSAL_CONTRACT_ID.to_owned(), - schema_version: RADROOTS_TRADE_SCHEMA_VERSION, - trade_id: trade_id(), - root_mutation_id: None, - buyer_pubkey: pubkey(buyer_pubkey), - seller_pubkey: pubkey(seller_pubkey), - farm_id: DTag::parse("farm-1").expect("farm id"), - parent_mutation_ids: Vec::new(), - author_pubkey: pubkey(buyer_pubkey), - counterparty_pubkey: pubkey(seller_pubkey), - authored_at_unix_s: 1_799_000_000, - body: TradeMutationBodyV1::Proposal { candidate }, - } -} - -fn reservation( - candidate: &TradeCandidateTermsV1, - seller_pubkey: &str, -) -> SellerReservationAssertionV1 { - SellerReservationAssertionV1 { - reservation_id: DTag::parse("reservation-1").expect("reservation id"), - inventory_authority_id: pubkey(seller_pubkey), - inventory_epoch: 42, - candidate_id: candidate.candidate_id.expect("candidate id"), - commitments: candidate - .lines - .iter() - .map(|line| SellerReservationLineV1 { - line_id: line.line_id.clone(), - bin_id: line.bin_id.clone(), - quantity_mantissa: line.quantity_mantissa.clone(), - quantity_scale: line.quantity_scale, - unit_code: line.unit_code.clone(), - }) - .collect(), - reservation_expires_at_unix_s: 1_800_000_000, - assertion_commitment: "e".repeat(64), - } -} - -fn accepted_decision( - proposal: &TradeMutationEnvelopeV1, - buyer_pubkey: &str, - seller_pubkey: &str, -) -> TradeMutationEnvelopeV1 { - let proposal_id = proposal.mutation_id.expect("proposal id"); - let candidate = match &proposal.body { - TradeMutationBodyV1::Proposal { candidate } => candidate.clone(), - _ => unreachable!(), - }; - TradeMutationEnvelopeV1 { - mutation_id: None, - contract_id: RADROOTS_TRADE_DECISION_CONTRACT_ID.to_owned(), - schema_version: RADROOTS_TRADE_SCHEMA_VERSION, - trade_id: proposal.trade_id, - root_mutation_id: Some(proposal_id), - buyer_pubkey: pubkey(buyer_pubkey), - seller_pubkey: pubkey(seller_pubkey), - farm_id: DTag::parse("farm-1").expect("farm id"), - parent_mutation_ids: vec![proposal_id], - author_pubkey: pubkey(seller_pubkey), - counterparty_pubkey: pubkey(buyer_pubkey), - authored_at_unix_s: 1_799_000_060, - body: TradeMutationBodyV1::Decision { - proposal_mutation_id: proposal_id, - candidate_id: candidate.candidate_id.expect("candidate id"), - decision: TradeDecisionV1::Accepted { - reservation_assertion: Some(reservation(&candidate, seller_pubkey)), - }, - }, - } -} - -#[tokio::test] -async fn trade_commands_query_and_private_terms_are_release_product_v1() { - let (buyer_pubkey, buyer_signer) = local_signer(); - let (seller_pubkey, seller_signer) = local_signer(); - let sdk = RadrootsClient::builder() - .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_799_000_100)) - .build() - .await - .expect("sdk"); - let sealed = sdk - .trades() - .seal_private_artifact(TradePrivateArtifactSealRequest::binding_terms( - "terms-1", - trade_id(), - "radroots.trade.binding_terms.v1", - b"{\"pickup\":\"south gate\"}".to_vec(), - )) - .await - .expect("seal private terms"); - let mut candidate = candidate(&buyer_pubkey, &seller_pubkey); - candidate.private_terms = sealed.private_terms_ref.clone(); - let proposal = proposal(candidate, &buyer_pubkey, &seller_pubkey); - let submit = SubmitProposalRequest::new( - buyer_actor(&buyer_pubkey), - proposal.clone(), - TargetPolicy::LocalOnly, - ) - .with_satisfaction_policy(SatisfactionPolicy::NoWait) - .with_idempotency_key( - SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000501").expect("idempotency key"), - ); - let receipt = sdk - .trades() - .commands() - .submit_proposal_with_explicit_signer(submit, &buyer_signer) - .await - .expect("submit proposal"); - let canonical_proposal = canonical_trade_mutation_content(proposal) - .expect("canonical proposal") - .envelope; - - assert_eq!(receipt.operation_kind, TRADE_SUBMIT_PROPOSAL_OPERATION_KIND); - assert_eq!(receipt.trade_id, trade_id()); - assert_eq!( - sdk.trades() - .open_private_artifact(TradePrivateArtifactOpenRequest::new("terms-1")) - .await - .expect("open private artifact") - .expect("private artifact") - .plaintext, - b"{\"pickup\":\"south gate\"}".to_vec() - ); - - let status = sdk - .trades() - .queries() - .get_trade(GetTradeRequest::new(trade_id())) - .await - .expect("trade status"); - assert_eq!(status.source_event_count, 1); - assert_eq!(status.private_terms.len(), 1); - assert_eq!( - status.private_terms[0].state, - RadrootsTradePrivateTermsStateV1::AvailableVerified - ); - - let evidence = sdk - .trades() - .queries() - .inspect_evidence(InspectEvidenceRequest::new(trade_id())) - .await - .expect("evidence"); - assert_eq!(evidence.items.len(), 1); - assert_eq!(evidence.items[0].artifact_id, "terms-1"); - - let listed = sdk - .trades() - .queries() - .list_trades(ListTradesRequest::new()) - .await - .expect("list trades"); - assert_eq!(listed.items.len(), 1); - assert_eq!(listed.items[0].trade_id, trade_id()); - - let decision = accepted_decision(&canonical_proposal, &buyer_pubkey, &seller_pubkey); - let error = sdk - .trades() - .commands() - .decide_candidate_with_explicit_signer( - DecideCandidateRequest::new( - seller_actor(&seller_pubkey), - decision, - TargetPolicy::LocalOnly, - ) - .with_satisfaction_policy(SatisfactionPolicy::NoWait) - .with_idempotency_key( - SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000502") - .expect("idempotency key"), - ), - &seller_signer, - ) - .await - .expect_err("private terms acknowledgement required"); - assert!(matches!( - error, - RadrootsSdkError::Trade { - kind: RadrootsSdkTradeErrorKind::PrivateArtifactAcknowledgementMissing, - .. - } - )); -}