sdk

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

commit baf6f683acd0c16a2f3a53be85f6fa1e67cf83eb
parent 71dc0de57ddc96754f291a6111e9e3dcc9e31234
Author: triesap <tyson@radroots.org>
Date:   Thu, 16 Jul 2026 12:20:41 +0000

runtime: add trade command runtime

- add command and query services for release-product trade mutations
- store and verify protected trade private artifacts
- route signed trade mutations through semantic outbox ingestion
- cover private terms acknowledgement and stable trade errors

Diffstat:
MCargo.lock | 1+
Mcrates/sdk/Cargo.toml | 2++
Mcrates/sdk/src/error.rs | 99++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mcrates/sdk/src/lib.rs | 18+++++++++++++++++-
Mcrates/sdk/src/private_store.rs | 482++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/sdk/src/product_clients.rs | 4++--
Mcrates/sdk/src/runtime.rs | 6++++++
Acrates/sdk/src/trade_runtime.rs | 2058+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/src/workflow_runtime.rs | 128++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/tests/runtime_foundation.rs | 43+++++++++++++++++++------------------------
Mcrates/sdk/tests/unit/error_tests.rs | 19+++++++++++--------
Mcrates/sdk/tests/unit/private_store_tests.rs | 3+--
Acrates/sdk/tests/unit/trade_runtime_tests.rs | 313+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
13 files changed, 3059 insertions(+), 117 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -2094,6 +2094,7 @@ dependencies = [ name = "radroots_sdk" version = "0.1.0" dependencies = [ + "base64 0.22.1", "futures", "hex", "nostr", diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml @@ -76,6 +76,7 @@ runtime = [ "std", "serde_json", "dep:tokio", + "dep:base64", "dep:hex", "dep:radroots_authority", "dep:radroots_event_store", @@ -133,6 +134,7 @@ test-fixture-geonames-asset = [ ] [dependencies] +base64 = { workspace = true, optional = true } futures = { workspace = true, optional = true } radroots_authority = { workspace = true, optional = true, default-features = false } radroots_event_store = { workspace = true, optional = true, default-features = false } diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs @@ -6,8 +6,6 @@ use crate::privacy::{PrivacyPreflightStatus, ProductSensitivityField}; #[cfg(feature = "runtime")] use crate::transport::ReticulumBehavior; #[cfg(feature = "runtime")] -use radroots_trade::identity::RadrootsTradeLocator; -#[cfg(feature = "runtime")] use serde_json::{Value, json}; #[cfg(feature = "runtime")] @@ -41,7 +39,6 @@ pub enum RadrootsSdkRecoveryAction { RetryAfterTransportFailure, RetryGeoNamesDownload, EnableRequiredFeature, - SelectTradeRoot, } #[cfg(feature = "runtime")] @@ -58,6 +55,39 @@ pub enum RadrootsSdkGeoNamesErrorKind { } #[cfg(feature = "runtime")] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum RadrootsSdkTradeErrorKind { + InvalidEnvelope, + InvalidCommandBody, + PrivateArtifactMissing, + PrivateArtifactCommitmentMismatch, + PrivateArtifactAcknowledgementMissing, + TradeNotFound, + QueryLimitInvalid, + CursorInvalid, +} + +#[cfg(feature = "runtime")] +impl RadrootsSdkTradeErrorKind { + pub fn code(self) -> &'static str { + match self { + Self::InvalidEnvelope => "trade_invalid_envelope", + Self::InvalidCommandBody => "trade_invalid_command_body", + Self::PrivateArtifactMissing => "trade_private_artifact_missing", + Self::PrivateArtifactCommitmentMismatch => "trade_private_artifact_commitment_mismatch", + Self::PrivateArtifactAcknowledgementMissing => { + "trade_private_artifact_acknowledgement_missing" + } + Self::TradeNotFound => "trade_not_found", + Self::QueryLimitInvalid => "trade_query_limit_invalid", + Self::CursorInvalid => "trade_cursor_invalid", + } + } +} + +#[cfg(feature = "runtime")] #[derive(Debug)] #[non_exhaustive] pub enum RadrootsSdkError { @@ -122,19 +152,10 @@ pub enum RadrootsSdkError { existing_digest_prefix: String, new_digest_prefix: String, }, - TradeStatusLimitInvalid { - limit: u32, - min: u32, - max: u32, - }, - InvalidTradeId { - value: String, - message: String, - }, - TradeAmbiguous { + Trade { + kind: RadrootsSdkTradeErrorKind, operation: String, - locator: Box<RadrootsTradeLocator>, - candidates: Vec<RadrootsTradeLocator>, + message: String, }, PrivacyPreflight { operation: String, @@ -213,9 +234,7 @@ impl RadrootsSdkError { Self::TransportTargetLimitExceeded { .. } => "transport_target_limit_exceeded", Self::InvalidRelayUrl { .. } => "invalid_relay_url", Self::IdempotencyConflict { .. } => "idempotency_conflict", - Self::TradeStatusLimitInvalid { .. } => "trade_status_limit_invalid", - Self::InvalidTradeId { .. } => "invalid_trade_id", - Self::TradeAmbiguous { .. } => "trade_ambiguous", + Self::Trade { kind, .. } => kind.code(), Self::PrivacyPreflight { .. } => "privacy_preflight", Self::ProductSyncUnsupported { .. } => "product_sync_unsupported", Self::ReticulumTransportUnavailable { behavior, .. } => match behavior { @@ -274,9 +293,7 @@ impl RadrootsSdkError { | Self::TransportTargetLimitExceeded { .. } | Self::InvalidRelayUrl { .. } => RadrootsSdkErrorClass::Configuration, Self::IdempotencyConflict { .. } - | Self::TradeStatusLimitInvalid { .. } - | Self::InvalidTradeId { .. } - | Self::TradeAmbiguous { .. } + | Self::Trade { .. } | Self::PrivacyPreflight { .. } | Self::SignerProtocol { .. } | Self::SignerAuthChallengePending { .. } @@ -351,7 +368,7 @@ impl RadrootsSdkError { Self::IdempotencyConflict { .. } => { vec![RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey] } - Self::TradeAmbiguous { .. } => vec![RadrootsSdkRecoveryAction::SelectTradeRoot], + Self::Trade { .. } => vec![RadrootsSdkRecoveryAction::FixRequest], Self::PrivacyPreflight { .. } => vec![RadrootsSdkRecoveryAction::FixRequest], Self::UnsupportedProfileSchema { .. } => { vec![RadrootsSdkRecoveryAction::InspectLocalStores] @@ -373,8 +390,6 @@ impl RadrootsSdkError { } Self::ClockBeforeUnixEpoch | Self::TimestampOutOfRange { .. } - | Self::TradeStatusLimitInvalid { .. } - | Self::InvalidTradeId { .. } | Self::SignerProtocol { .. } | Self::InvalidRequest { .. } | Self::ListingEdit { .. } @@ -430,20 +445,14 @@ impl RadrootsSdkError { "existing_digest_prefix": existing_digest_prefix, "new_digest_prefix": new_digest_prefix }), - Self::TradeStatusLimitInvalid { limit, min, max } => { - json!({ "limit": limit, "min": min, "max": max }) - } - Self::InvalidTradeId { value, message } => { - json!({ "value": value, "message": message }) - } - Self::TradeAmbiguous { + Self::Trade { + kind, operation, - locator, - candidates, + message, } => json!({ + "kind": kind, "operation": operation, - "locator": locator, - "candidates": candidates + "message": message }), Self::PrivacyPreflight { operation, @@ -591,23 +600,11 @@ impl fmt::Display for RadrootsSdkError { f, "sdk idempotency conflict for {operation_kind}: expected_pubkey_prefix={expected_pubkey_prefix}, existing_digest_prefix={existing_digest_prefix}, new_digest_prefix={new_digest_prefix}" ), - Self::TradeStatusLimitInvalid { limit, min, max } => write!( - f, - "sdk order status limit invalid: limit={limit}, min={min}, max={max}" - ), - Self::InvalidTradeId { value, message } => { - write!(f, "sdk invalid order id `{value}`: {message}") - } - Self::TradeAmbiguous { + Self::Trade { + kind, operation, - locator, - candidates, - } => write!( - f, - "sdk trade root is ambiguous for {operation}: trade_id={}, candidate_count={}", - locator.order_id().as_str(), - candidates.len() - ), + message, + } => write!(f, "sdk trade error for {operation} ({kind:?}): {message}"), Self::PrivacyPreflight { operation, status, diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -50,6 +50,8 @@ mod studio_store; #[cfg(feature = "runtime")] mod sync_runtime; #[cfg(feature = "runtime")] +mod trade_runtime; +#[cfg(feature = "runtime")] pub mod transport; #[cfg(feature = "runtime")] mod workflow_runtime; @@ -59,7 +61,7 @@ pub use radroots_runtime_contract_v1 as runtime_contract_v1; #[cfg(feature = "runtime")] pub use crate::error::{ RadrootsSdkError, RadrootsSdkErrorClass, RadrootsSdkGeoNamesErrorKind, - RadrootsSdkRecoveryAction, + RadrootsSdkRecoveryAction, RadrootsSdkTradeErrorKind, }; #[cfg(feature = "runtime")] pub use crate::farms_runtime::{ @@ -170,6 +172,20 @@ pub use crate::sync_runtime::{ SyncTransportStatusSummary, SyncTransportTargetSummary, }; #[cfg(feature = "runtime")] +pub use crate::trade_runtime::{ + CancelTradeRequest, DecideCandidateRequest, EvidenceRefreshReceipt, EvidenceView, + GetTradeRequest, InspectEvidenceRequest, ListTradesRequest, Page, ProposeRevisionRequest, + RefreshTradeEvidenceRequest, ResumeOperationRequest, SubmitProposalRequest, + TRADE_CANCEL_OPERATION_KIND, TRADE_DECIDE_CANDIDATE_OPERATION_KIND, + TRADE_PROPOSE_REVISION_OPERATION_KIND, TRADE_QUERY_DEFAULT_LIMIT, TRADE_QUERY_MAX_LIMIT, + TRADE_RESUME_OPERATION_KIND, TRADE_SUBMIT_PROPOSAL_OPERATION_KIND, TradeCommandLifecycleState, + TradeCommandReceipt, TradeCommandService, TradeListAnyOf, TradeListFilter, TradeListSort, + TradePrivateArtifactDeleteReceipt, TradePrivateArtifactDeleteRequest, TradePrivateArtifactKind, + TradePrivateArtifactOpenReceipt, TradePrivateArtifactOpenRequest, + TradePrivateArtifactSealReceipt, TradePrivateArtifactSealRequest, + TradePrivateTermsAvailabilityView, TradeQueryService, TradeStatusView, TradeSummaryView, +}; +#[cfg(feature = "runtime")] pub use crate::transport::{ MeshScopeId, MultiTargetProfile, NostrProfile, NostrRelayUrlPolicy, PublishMode, RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryTargetStatus, RadrootsTransportKind, diff --git a/crates/sdk/src/private_store.rs b/crates/sdk/src/private_store.rs @@ -3,9 +3,14 @@ use crate::RadrootsSdkError; use radroots_event::ids::{RadrootsAddressableCoordinate, RadrootsAddressableCoordinateParts}; use radroots_event::kinds::KIND_FARM; +use radroots_event::trade::RADROOTS_TRADE_MAX_PRIVATE_ARTIFACT_BYTES; use radroots_protected_store::{RadrootsProtectedFileKeySource, RadrootsProtectedStoreEnvelope}; use radroots_secret_vault::{RadrootsSecretKeyWrapping, RadrootsSecretVaultAccessError}; +use radroots_trade::workflow::{ + RadrootsTradePrivateTermsEvidenceV1, RadrootsTradePrivateTermsStateV1, +}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use sqlx::{Row, SqlitePool}; use std::path::Path; @@ -61,40 +66,29 @@ CREATE TABLE IF NOT EXISTS private_farm_location ( PRIMARY KEY(farm_kind, owner_pubkey, farm_d_tag) ) STRICT, WITHOUT ROWID; -CREATE TABLE IF NOT EXISTS trade_private_thread ( - private_thread_id BLOB PRIMARY KEY CHECK(length(private_thread_id) = 32), - order_id BLOB NOT NULL CHECK(length(order_id) = 16), - root_request_event_id BLOB NOT NULL CHECK(length(root_request_event_id) = 32), - counterparty_pubkey BLOB NOT NULL CHECK(length(counterparty_pubkey) = 32), +CREATE TABLE IF NOT EXISTS private_trade_artifacts ( + artifact_id TEXT PRIMARY KEY NOT NULL, + trade_id TEXT NOT NULL CHECK(length(trade_id) = 32), + candidate_id TEXT CHECK(candidate_id IS NULL OR length(candidate_id) = 64), + artifact_kind TEXT NOT NULL CHECK(artifact_kind IN ('binding_terms','message','contact_bundle','delivery_instruction')), + schema_id TEXT NOT NULL, + ciphertext_commitment TEXT NOT NULL CHECK(length(ciphertext_commitment) = 64), key_version INTEGER NOT NULL REFERENCES wrapped_profile_key(key_version), ciphertext BLOB NOT NULL, - nonce BLOB NOT NULL CHECK(length(nonce) = 24), - expires_at_ms INTEGER, + encryption_metadata BLOB NOT NULL, + retention_class TEXT NOT NULL, created_at_ms INTEGER NOT NULL, - updated_at_ms INTEGER NOT NULL, - UNIQUE(order_id, root_request_event_id, counterparty_pubkey) -) STRICT; - -CREATE INDEX IF NOT EXISTS trade_private_expiry_idx - ON trade_private_thread(expires_at_ms, private_thread_id) - WHERE expires_at_ms IS NOT NULL; - -CREATE TABLE IF NOT EXISTS buyer_contact_private ( - contact_id BLOB PRIMARY KEY CHECK(length(contact_id) = 16), - order_id BLOB NOT NULL CHECK(length(order_id) = 16), - root_request_event_id BLOB NOT NULL CHECK(length(root_request_event_id) = 32), - key_version INTEGER NOT NULL REFERENCES wrapped_profile_key(key_version), - ciphertext BLOB NOT NULL, - nonce BLOB NOT NULL CHECK(length(nonce) = 24), expires_at_ms INTEGER, - created_at_ms INTEGER NOT NULL, - updated_at_ms INTEGER NOT NULL, - UNIQUE(order_id, root_request_event_id) + deleted_at_ms INTEGER, + UNIQUE(artifact_kind, ciphertext_commitment) ) STRICT; -CREATE INDEX IF NOT EXISTS buyer_contact_expiry_idx - ON buyer_contact_private(expires_at_ms, contact_id) - WHERE expires_at_ms IS NOT NULL; +CREATE INDEX IF NOT EXISTS private_trade_artifacts_trade_idx + ON private_trade_artifacts(trade_id, candidate_id, artifact_kind, deleted_at_ms); + +CREATE INDEX IF NOT EXISTS private_trade_artifacts_expiry_idx + ON private_trade_artifacts(expires_at_ms, artifact_id) + WHERE expires_at_ms IS NOT NULL AND deleted_at_ms IS NULL; CREATE TABLE IF NOT EXISTS cursor_hmac_key ( key_id BLOB PRIMARY KEY CHECK(length(key_id) = 16), @@ -169,6 +163,7 @@ pub(crate) struct SdkPrivateFarmLocationRecord { #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct SdkPrivateStoreStatusSummary { pub farm_private_locations: i64, + pub trade_private_artifacts: i64, } #[derive(Debug, Serialize, Deserialize)] @@ -186,6 +181,92 @@ struct SdkPrivateFarmLocationPayload { updated_at_ms: i64, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum SdkPrivateTradeArtifactKind { + BindingTerms, + Message, + ContactBundle, + DeliveryInstruction, +} + +impl SdkPrivateTradeArtifactKind { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::BindingTerms => "binding_terms", + Self::Message => "message", + Self::ContactBundle => "contact_bundle", + Self::DeliveryInstruction => "delivery_instruction", + } + } + + fn from_str(value: &str) -> Result<Self, RadrootsSdkError> { + match value { + "binding_terms" => Ok(Self::BindingTerms), + "message" => Ok(Self::Message), + "contact_bundle" => Ok(Self::ContactBundle), + "delivery_instruction" => Ok(Self::DeliveryInstruction), + _ => Err(RadrootsSdkError::PrivateStore { + message: format!("unknown private trade artifact kind `{value}`"), + }), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SdkPrivateTradeArtifactInput { + pub artifact_id: String, + pub trade_id: String, + pub candidate_id: Option<String>, + pub artifact_kind: SdkPrivateTradeArtifactKind, + pub schema_id: String, + pub plaintext: Vec<u8>, + pub retention_class: String, + pub created_at_ms: i64, + pub expires_at_ms: Option<i64>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SdkPrivateTradeArtifactRecord { + pub artifact_id: String, + pub trade_id: String, + pub candidate_id: Option<String>, + pub artifact_kind: SdkPrivateTradeArtifactKind, + pub schema_id: String, + pub ciphertext_commitment: 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>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SdkPrivateTradeArtifactMetadata { + pub artifact_id: String, + pub trade_id: String, + pub candidate_id: Option<String>, + pub artifact_kind: SdkPrivateTradeArtifactKind, + pub schema_id: String, + pub ciphertext_commitment: String, + pub retention_class: String, + pub created_at_ms: i64, + pub expires_at_ms: Option<i64>, + pub deleted_at_ms: Option<i64>, +} + +#[derive(Debug, Serialize, Deserialize)] +struct SdkPrivateTradeArtifactPayload { + artifact_id: String, + trade_id: String, + artifact_kind: SdkPrivateTradeArtifactKind, + schema_id: String, + plaintext: Vec<u8>, + retention_class: String, + created_at_ms: i64, + expires_at_ms: Option<i64>, +} + impl SdkPrivateStore { pub async fn open_memory() -> Result<Self, RadrootsSdkError> { let options = @@ -251,6 +332,11 @@ impl SdkPrivateStore { "SELECT COUNT(*) FROM private_farm_location", ) .await?, + trade_private_artifacts: query_i64( + &self.pool, + "SELECT COUNT(*) FROM private_trade_artifacts WHERE deleted_at_ms IS NULL", + ) + .await?, }) } @@ -341,6 +427,185 @@ impl SdkPrivateStore { .map_err(private_store_error) } + pub async fn upsert_trade_artifact( + &self, + input: &SdkPrivateTradeArtifactInput, + ) -> Result<SdkPrivateTradeArtifactMetadata, RadrootsSdkError> { + validate_trade_artifact_input(input)?; + let envelope = self.seal_trade_artifact(input)?; + let nonce = envelope.header.nonce.to_vec(); + let ciphertext = envelope.encode_json().map_err(private_store_error)?; + let ciphertext_commitment = hex::encode(Sha256::digest(ciphertext.as_slice())); + let encryption_metadata = serde_json::to_vec(&serde_json::json!({ + "key_version": PRIVATE_STORE_KEY_VERSION, + "nonce": hex::encode(nonce.as_slice()) + })) + .map_err(private_store_error)?; + sqlx::query( + r#" + INSERT INTO private_trade_artifacts ( + artifact_id, + trade_id, + candidate_id, + artifact_kind, + schema_id, + ciphertext_commitment, + key_version, + ciphertext, + encryption_metadata, + retention_class, + created_at_ms, + expires_at_ms, + deleted_at_ms + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, NULL) + ON CONFLICT(artifact_id) DO UPDATE SET + trade_id = excluded.trade_id, + candidate_id = excluded.candidate_id, + artifact_kind = excluded.artifact_kind, + schema_id = excluded.schema_id, + ciphertext_commitment = excluded.ciphertext_commitment, + key_version = excluded.key_version, + ciphertext = excluded.ciphertext, + encryption_metadata = excluded.encryption_metadata, + retention_class = excluded.retention_class, + created_at_ms = excluded.created_at_ms, + expires_at_ms = excluded.expires_at_ms, + deleted_at_ms = NULL + "#, + ) + .bind(input.artifact_id.as_str()) + .bind(input.trade_id.as_str()) + .bind(input.candidate_id.as_deref()) + .bind(input.artifact_kind.as_str()) + .bind(input.schema_id.as_str()) + .bind(ciphertext_commitment.as_str()) + .bind(PRIVATE_STORE_KEY_VERSION) + .bind(ciphertext) + .bind(encryption_metadata) + .bind(input.retention_class.as_str()) + .bind(input.created_at_ms) + .bind(input.expires_at_ms) + .execute(&self.pool) + .await + .map_err(private_store_error)?; + Ok(SdkPrivateTradeArtifactMetadata { + artifact_id: input.artifact_id.clone(), + trade_id: input.trade_id.clone(), + candidate_id: input.candidate_id.clone(), + artifact_kind: input.artifact_kind, + schema_id: input.schema_id.clone(), + ciphertext_commitment, + retention_class: input.retention_class.clone(), + created_at_ms: input.created_at_ms, + expires_at_ms: input.expires_at_ms, + deleted_at_ms: None, + }) + } + + pub async fn trade_artifact( + &self, + artifact_id: &str, + ) -> Result<Option<SdkPrivateTradeArtifactRecord>, RadrootsSdkError> { + let row = sqlx::query( + r#" + SELECT artifact_id, trade_id, candidate_id, artifact_kind, schema_id, + ciphertext_commitment, ciphertext, retention_class, created_at_ms, + expires_at_ms, deleted_at_ms + FROM private_trade_artifacts + WHERE artifact_id = ?1 + "#, + ) + .bind(artifact_id) + .fetch_optional(&self.pool) + .await + .map_err(private_store_error)?; + row.map(|row| self.trade_artifact_from_row(row)).transpose() + } + + pub async fn trade_artifact_metadata_for_trade( + &self, + trade_id: &str, + ) -> Result<Vec<SdkPrivateTradeArtifactMetadata>, RadrootsSdkError> { + let rows = sqlx::query( + r#" + SELECT artifact_id, trade_id, candidate_id, artifact_kind, schema_id, + ciphertext_commitment, retention_class, created_at_ms, expires_at_ms, + deleted_at_ms + FROM private_trade_artifacts + WHERE trade_id = ?1 + ORDER BY created_at_ms, artifact_id + "#, + ) + .bind(trade_id) + .fetch_all(&self.pool) + .await + .map_err(private_store_error)?; + rows.into_iter() + .map(trade_artifact_metadata_from_row) + .collect() + } + + pub async fn delete_trade_artifact( + &self, + artifact_id: &str, + deleted_at_ms: i64, + ) -> Result<bool, RadrootsSdkError> { + sqlx::query( + r#" + UPDATE private_trade_artifacts + SET deleted_at_ms = ?2 + WHERE artifact_id = ?1 AND deleted_at_ms IS NULL + "#, + ) + .bind(artifact_id) + .bind(deleted_at_ms) + .execute(&self.pool) + .await + .map(|receipt| receipt.rows_affected() > 0) + .map_err(private_store_error) + } + + pub async fn private_terms_evidence( + &self, + trade_id: &str, + candidate_id: &str, + artifact_id: &str, + schema_id: &str, + ciphertext_commitment: &str, + ) -> Result<RadrootsTradePrivateTermsEvidenceV1, RadrootsSdkError> { + let state = match self.trade_artifact(artifact_id).await? { + None => RadrootsTradePrivateTermsStateV1::Missing, + Some(record) => { + if record.deleted_at_ms.is_some() + || record.trade_id != trade_id + || record.artifact_kind != SdkPrivateTradeArtifactKind::BindingTerms + { + RadrootsTradePrivateTermsStateV1::Missing + } else if record + .candidate_id + .as_deref() + .is_some_and(|stored_candidate_id| stored_candidate_id != candidate_id) + { + RadrootsTradePrivateTermsStateV1::Missing + } else if record.schema_id != schema_id + || record.ciphertext_commitment != ciphertext_commitment + { + RadrootsTradePrivateTermsStateV1::CommitmentMismatch + } else { + RadrootsTradePrivateTermsStateV1::AvailableVerified + } + } + }; + Ok(RadrootsTradePrivateTermsEvidenceV1 { + candidate_id: candidate_id.parse().map_err(|error| { + RadrootsSdkError::InvalidRequest { + message: format!("private terms candidate id is invalid: {error}"), + } + })?, + state, + }) + } + async fn configure_connection(&self, file_backed: bool) -> Result<(), RadrootsSdkError> { sqlx::query("PRAGMA foreign_keys = ON") .execute(&self.pool) @@ -473,6 +738,29 @@ impl SdkPrivateStore { .map_err(private_store_error) } + fn seal_trade_artifact( + &self, + input: &SdkPrivateTradeArtifactInput, + ) -> Result<RadrootsProtectedStoreEnvelope, RadrootsSdkError> { + let payload = SdkPrivateTradeArtifactPayload { + artifact_id: input.artifact_id.clone(), + trade_id: input.trade_id.clone(), + artifact_kind: input.artifact_kind, + schema_id: input.schema_id.clone(), + plaintext: input.plaintext.clone(), + retention_class: input.retention_class.clone(), + created_at_ms: input.created_at_ms, + expires_at_ms: input.expires_at_ms, + }; + let plaintext = serde_json::to_vec(&payload).map_err(private_store_error)?; + RadrootsProtectedStoreEnvelope::seal_with_wrapped_key( + &self.key_source, + trade_artifact_key_slot(input.artifact_id.as_str()).as_str(), + plaintext.as_slice(), + ) + .map_err(private_store_error) + } + fn private_farm_location_from_row( &self, farm_addr: RadrootsAddressableCoordinate, @@ -510,6 +798,65 @@ impl SdkPrivateStore { updated_at_ms: payload.updated_at_ms, }) } + + fn trade_artifact_from_row( + &self, + row: sqlx::sqlite::SqliteRow, + ) -> Result<SdkPrivateTradeArtifactRecord, RadrootsSdkError> { + let artifact_id: String = row.try_get("artifact_id").map_err(private_store_error)?; + let trade_id: String = row.try_get("trade_id").map_err(private_store_error)?; + let candidate_id: Option<String> = + row.try_get("candidate_id").map_err(private_store_error)?; + let artifact_kind = SdkPrivateTradeArtifactKind::from_str( + row.try_get::<String, _>("artifact_kind") + .map_err(private_store_error)? + .as_str(), + )?; + let schema_id: String = row.try_get("schema_id").map_err(private_store_error)?; + let ciphertext_commitment: String = row + .try_get("ciphertext_commitment") + .map_err(private_store_error)?; + let ciphertext: Vec<u8> = row.try_get("ciphertext").map_err(private_store_error)?; + if hex::encode(Sha256::digest(ciphertext.as_slice())) != ciphertext_commitment { + return Err(RadrootsSdkError::PrivateStore { + message: + "private trade artifact ciphertext commitment does not match row commitment" + .to_owned(), + }); + } + let envelope = RadrootsProtectedStoreEnvelope::decode_json(ciphertext.as_slice()) + .map_err(private_store_error)?; + let plaintext = envelope + .open_with_wrapped_key(&self.key_source) + .map_err(private_store_error)?; + let payload: SdkPrivateTradeArtifactPayload = + serde_json::from_slice(plaintext.as_slice()).map_err(private_store_error)?; + if payload.artifact_id != artifact_id + || payload.trade_id != trade_id + || payload.artifact_kind != artifact_kind + || payload.schema_id != schema_id + { + return Err(RadrootsSdkError::PrivateStore { + message: "private trade artifact envelope metadata does not match row metadata" + .to_owned(), + }); + } + Ok(SdkPrivateTradeArtifactRecord { + artifact_id, + trade_id, + candidate_id, + artifact_kind, + schema_id, + ciphertext_commitment, + plaintext: payload.plaintext, + retention_class: row + .try_get("retention_class") + .map_err(private_store_error)?, + created_at_ms: row.try_get("created_at_ms").map_err(private_store_error)?, + expires_at_ms: row.try_get("expires_at_ms").map_err(private_store_error)?, + deleted_at_ms: row.try_get("deleted_at_ms").map_err(private_store_error)?, + }) + } } impl RadrootsSecretKeyWrapping for SdkPrivateStoreKeySource { @@ -587,6 +934,10 @@ fn farm_location_key_slot(farm_addr: &str) -> String { format!("private_farm_location:{farm_addr}") } +fn trade_artifact_key_slot(artifact_id: &str) -> String { + format!("private_trade_artifact:{artifact_id}") +} + fn validate_location_record(record: &SdkPrivateFarmLocationRecord) -> Result<(), RadrootsSdkError> { if !record.latitude.is_finite() || !record.longitude.is_finite() @@ -633,6 +984,81 @@ fn validate_location_record(record: &SdkPrivateFarmLocationRecord) -> Result<(), Ok(()) } +fn validate_trade_artifact_input( + input: &SdkPrivateTradeArtifactInput, +) -> Result<(), RadrootsSdkError> { + for (field, value) in [ + ("artifact_id", input.artifact_id.as_str()), + ("trade_id", input.trade_id.as_str()), + ("schema_id", input.schema_id.as_str()), + ("retention_class", input.retention_class.as_str()), + ] { + if value.trim().is_empty() { + return Err(RadrootsSdkError::InvalidRequest { + message: format!("private trade artifact {field} must not be empty"), + }); + } + } + if input.plaintext.is_empty() { + return Err(RadrootsSdkError::InvalidRequest { + message: "private trade artifact plaintext must not be empty".to_owned(), + }); + } + if input.plaintext.len() > RADROOTS_TRADE_MAX_PRIVATE_ARTIFACT_BYTES { + return Err(RadrootsSdkError::InvalidRequest { + message: format!( + "private trade artifact plaintext exceeds {RADROOTS_TRADE_MAX_PRIVATE_ARTIFACT_BYTES} bytes" + ), + }); + } + if input + .expires_at_ms + .is_some_and(|expires| expires <= input.created_at_ms) + { + return Err(RadrootsSdkError::InvalidRequest { + message: "private trade artifact expiration must be after creation time".to_owned(), + }); + } + if input.trade_id.len() != 32 || !input.trade_id.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(RadrootsSdkError::InvalidRequest { + message: "private trade artifact trade_id must be 32 hex characters".to_owned(), + }); + } + if input.candidate_id.as_deref().is_some_and(|candidate_id| { + candidate_id.len() != 64 || !candidate_id.chars().all(|c| c.is_ascii_hexdigit()) + }) { + return Err(RadrootsSdkError::InvalidRequest { + message: "private trade artifact candidate_id must be 64 hex characters".to_owned(), + }); + } + Ok(()) +} + +fn trade_artifact_metadata_from_row( + row: sqlx::sqlite::SqliteRow, +) -> Result<SdkPrivateTradeArtifactMetadata, RadrootsSdkError> { + Ok(SdkPrivateTradeArtifactMetadata { + artifact_id: row.try_get("artifact_id").map_err(private_store_error)?, + trade_id: row.try_get("trade_id").map_err(private_store_error)?, + candidate_id: row.try_get("candidate_id").map_err(private_store_error)?, + artifact_kind: SdkPrivateTradeArtifactKind::from_str( + row.try_get::<String, _>("artifact_kind") + .map_err(private_store_error)? + .as_str(), + )?, + schema_id: row.try_get("schema_id").map_err(private_store_error)?, + ciphertext_commitment: row + .try_get("ciphertext_commitment") + .map_err(private_store_error)?, + retention_class: row + .try_get("retention_class") + .map_err(private_store_error)?, + created_at_ms: row.try_get("created_at_ms").map_err(private_store_error)?, + expires_at_ms: row.try_get("expires_at_ms").map_err(private_store_error)?, + deleted_at_ms: row.try_get("deleted_at_ms").map_err(private_store_error)?, + }) +} + fn private_store_error(error: impl ToString) -> RadrootsSdkError { RadrootsSdkError::PrivateStore { message: error.to_string(), diff --git a/crates/sdk/src/product_clients.rs b/crates/sdk/src/product_clients.rs @@ -56,13 +56,13 @@ impl<'client> GeoNamesClient<'client> { #[cfg(feature = "runtime")] #[derive(Clone, Copy)] pub struct TradesClient<'client> { - pub(crate) _sdk: &'client RadrootsClient, + pub(crate) sdk: &'client RadrootsClient, } #[cfg(feature = "runtime")] impl<'client> TradesClient<'client> { pub(crate) fn new(sdk: &'client RadrootsClient) -> Self { - Self { _sdk: sdk } + Self { sdk } } } diff --git a/crates/sdk/src/runtime.rs b/crates/sdk/src/runtime.rs @@ -1227,6 +1227,12 @@ async fn private_store_status_summary_from_pool( SqliteStoreRole::PrivateStore, ) .await?, + trade_private_artifacts: sqlite_query_i64( + pool, + "SELECT COUNT(*) FROM private_trade_artifacts WHERE deleted_at_ms IS NULL", + SqliteStoreRole::PrivateStore, + ) + .await?, }) } diff --git a/crates/sdk/src/trade_runtime.rs b/crates/sdk/src/trade_runtime.rs @@ -0,0 +1,2058 @@ +#[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::{ + 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_authority::{RadrootsActorContext, RadrootsEventSigner}; +#[cfg(feature = "runtime")] +use radroots_event::{ + draft::RadrootsEventDraft, + ids::{RadrootsEventId, RadrootsTradeCandidateId, RadrootsTradeId, RadrootsTradeMutationId}, + trade::{ + RadrootsTradeDecisionV1, RadrootsTradeMutationBodyV1, RadrootsTradeMutationEnvelopeV1, + RadrootsTradePrivateTermsRefV1, trade_mutation_from_canonical_content, + }, +}; +#[cfg(feature = "runtime")] +use radroots_event_codec::trade::trade_mutation_event_build; +#[cfg(feature = "runtime")] +use radroots_event_store::{RadrootsStoredTradeMutation, RadrootsTradeProjectionCheckpoint}; +#[cfg(feature = "runtime")] +use radroots_trade::workflow::{ + RADROOTS_TRADE_REDUCER_CONTRACT_ID, RADROOTS_TRADE_REDUCER_VERSION, + RadrootsTradeAgreementStateV1, RadrootsTradeAttestationStateV1, RadrootsTradeConflictStateV1, + RadrootsTradeEvidenceStateV1, RadrootsTradeFulfillmentStateV1, RadrootsTradeMutationRecordV1, + RadrootsTradeNegotiationStateV1, RadrootsTradePaymentStateV1, + RadrootsTradePrivateTermsEvidenceV1, RadrootsTradePrivateTermsStateV1, + RadrootsTradeProjectionV1, RadrootsTradeReductionInputV1, reduce_trade_records, +}; +#[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")] +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 RadrootsEventSigner, + ) -> 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 RadrootsEventSigner, + ) -> 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 RadrootsEventSigner, + ) -> 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 RadrootsEventSigner, + ) -> 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 RadrootsEventSigner, + ) -> 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.clone(), + reducer_contract_id: RADROOTS_TRADE_REDUCER_CONTRACT_ID.to_owned(), + reducer_version: RADROOTS_TRADE_REDUCER_VERSION, + projection_digest: view.projection.projection_digest.clone(), + root_mutation_id: view.projection.root_mutation_id.clone(), + 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, + 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 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: RadrootsActorContext, + pub envelope: RadrootsTradeMutationEnvelopeV1, + pub target_policy: TargetPolicy, + pub satisfaction_policy: SatisfactionPolicy, + pub idempotency_key: Option<SdkIdempotencyKey>, +} + +#[cfg(feature = "runtime")] +impl SubmitProposalRequest { + pub fn new( + actor: RadrootsActorContext, + envelope: RadrootsTradeMutationEnvelopeV1, + 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: RadrootsActorContext, + pub envelope: RadrootsTradeMutationEnvelopeV1, + pub target_policy: TargetPolicy, + pub satisfaction_policy: SatisfactionPolicy, + pub idempotency_key: Option<SdkIdempotencyKey>, +} + +#[cfg(feature = "runtime")] +impl ProposeRevisionRequest { + pub fn new( + actor: RadrootsActorContext, + envelope: RadrootsTradeMutationEnvelopeV1, + 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: RadrootsActorContext, + pub envelope: RadrootsTradeMutationEnvelopeV1, + 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: RadrootsActorContext, + envelope: RadrootsTradeMutationEnvelopeV1, + 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: RadrootsActorContext, + pub envelope: RadrootsTradeMutationEnvelopeV1, + pub target_policy: TargetPolicy, + pub satisfaction_policy: SatisfactionPolicy, + pub idempotency_key: Option<SdkIdempotencyKey>, +} + +#[cfg(feature = "runtime")] +impl CancelTradeRequest { + pub fn new( + actor: RadrootsActorContext, + envelope: RadrootsTradeMutationEnvelopeV1, + 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: RadrootsActorContext, + pub envelope: RadrootsTradeMutationEnvelopeV1, + 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: RadrootsActorContext, + envelope: RadrootsTradeMutationEnvelopeV1, + 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: RadrootsTradeId, + pub mutation_id: RadrootsTradeMutationId, + pub expected_event_id: RadrootsEventId, + pub signed_event_id: RadrootsEventId, + pub local_event_seq: i64, + pub outbox_operation_id: i64, + pub outbox_event_id: i64, + pub delivery_state: SdkMutationState, + pub projection_state: Option<RadrootsTradeProjectionV1>, + 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: RadrootsTradeId, + pub candidate_id: Option<RadrootsTradeCandidateId>, + 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: RadrootsTradeId, + 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: RadrootsTradeCandidateId) -> 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: RadrootsTradeId, + pub candidate_id: Option<RadrootsTradeCandidateId>, + pub artifact_kind: TradePrivateArtifactKind, + pub schema_id: String, + pub ciphertext_commitment: String, + pub private_terms_ref: Option<RadrootsTradePrivateTermsRefV1>, + 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: RadrootsTradeId, + pub candidate_id: Option<RadrootsTradeCandidateId>, + 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, Serialize)] +#[non_exhaustive] +pub struct GetTradeRequest { + pub trade_id: RadrootsTradeId, +} + +#[cfg(feature = "runtime")] +impl GetTradeRequest { + pub fn new(trade_id: RadrootsTradeId) -> Self { + Self { trade_id } + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, Serialize)] +#[non_exhaustive] +pub struct RefreshTradeEvidenceRequest { + pub trade_id: RadrootsTradeId, +} + +#[cfg(feature = "runtime")] +impl RefreshTradeEvidenceRequest { + pub fn new(trade_id: RadrootsTradeId) -> Self { + Self { trade_id } + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug, Serialize)] +#[non_exhaustive] +pub struct InspectEvidenceRequest { + pub trade_id: RadrootsTradeId, + pub limit: Option<u32>, + pub cursor: Option<String>, +} + +#[cfg(feature = "runtime")] +impl InspectEvidenceRequest { + pub fn new(trade_id: RadrootsTradeId) -> 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, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TradeListSort { + UpdatedDesc, +} + +#[cfg(feature = "runtime")] +impl Default for TradeListSort { + fn default() -> Self { + Self::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: RadrootsTradeId, + pub projection: RadrootsTradeProjectionV1, + 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: RadrootsTradeId, + pub root_mutation_id: Option<RadrootsTradeMutationId>, + 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: RadrootsTradeCandidateId, + 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: RadrootsTradeId, + 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: RadrootsTradeId, + pub candidate_id: Option<RadrootsTradeCandidateId>, + 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: RadrootsActorContext, + frozen_draft: RadrootsEventDraft, + trade_id: RadrootsTradeId, + mutation_id: RadrootsTradeMutationId, + 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, + ) -> ( + RadrootsActorContext, + RadrootsTradeMutationEnvelopeV1, + 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: RadrootsTradeId, + updated_event_seq: i64, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Debug)] +struct LastTradeMutationSnapshot { + mutation_id: Option<RadrootsTradeMutationId>, + 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 RadrootsEventSigner, +) -> 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.clone().ok_or_else(|| { + trade_command_error( + RadrootsSdkTradeErrorKind::InvalidEnvelope, + operation_kind, + "canonical trade mutation is missing mutation id", + ) + })?; + let frozen_draft = RadrootsEventDraft::new( + canonical.contract_id.as_str(), + wire.kind, + canonical.authored_at_unix_s, + wire.tags, + wire.content, + actor.pubkey().as_str(), + ) + .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.clone(), + 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: RadrootsEventId::parse(plan.frozen_draft.expected_event_id_str()) + .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: &RadrootsTradeMutationEnvelopeV1, +) -> Result<(), RadrootsSdkError> { + let valid = match operation_kind { + TRADE_SUBMIT_PROPOSAL_OPERATION_KIND => { + matches!(envelope.body, RadrootsTradeMutationBodyV1::Proposal { .. }) + } + TRADE_PROPOSE_REVISION_OPERATION_KIND => { + matches!( + envelope.body, + RadrootsTradeMutationBodyV1::RevisionProposal { .. } + ) + } + TRADE_DECIDE_CANDIDATE_OPERATION_KIND | TRADE_RESUME_OPERATION_KIND => matches!( + envelope.body, + RadrootsTradeMutationBodyV1::Decision { .. } + | RadrootsTradeMutationBodyV1::RevisionDecision { .. } + ), + TRADE_CANCEL_OPERATION_KIND => { + matches!( + envelope.body, + RadrootsTradeMutationBodyV1::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: &RadrootsActorContext, + envelope: &RadrootsTradeMutationEnvelopeV1, +) -> Result<(), RadrootsSdkError> { + if actor.pubkey().as_str() == envelope.author_pubkey.as_str() { + 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: &RadrootsTradeMutationEnvelopeV1, + private_terms_acknowledged: bool, +) -> Result<(), RadrootsSdkError> { + match &envelope.body { + RadrootsTradeMutationBodyV1::Proposal { candidate } + | RadrootsTradeMutationBodyV1::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(()) + } + RadrootsTradeMutationBodyV1::Decision { + proposal_mutation_id, + candidate_id, + decision, + } + | RadrootsTradeMutationBodyV1::RevisionDecision { + proposal_mutation_id, + candidate_id, + decision, + } => { + if !matches!(decision, RadrootsTradeDecisionV1::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 + } + RadrootsTradeMutationBodyV1::Cancellation { .. } => Ok(()), + } +} + +#[cfg(feature = "runtime")] +async fn referenced_candidate_for_decision( + sdk: &RadrootsClient, + operation_kind: &'static str, + proposal_mutation_id: &RadrootsTradeMutationId, + candidate_id: &RadrootsTradeCandidateId, +) -> Result<Option<radroots_event::trade::RadrootsTradeCandidateTermsV1>, 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 { + RadrootsTradeMutationBodyV1::Proposal { candidate } + | RadrootsTradeMutationBodyV1::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: &RadrootsTradeId, + candidate_id: &RadrootsTradeCandidateId, + private_ref: &RadrootsTradePrivateTermsRefV1, +) -> Result<(), RadrootsSdkError> { + let evidence = sdk + ._private_store + .private_terms_evidence( + trade_id.as_str(), + candidate_id.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.as_str().to_owned(), + candidate_id: request + .candidate_id + .as_ref() + .map(|candidate_id| candidate_id.as_str().to_owned()), + 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(RadrootsTradePrivateTermsRefV1 { + 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: RadrootsTradeId::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: RadrootsTradeId::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: &RadrootsTradeId, +) -> 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.clone(), + projection, + source_event_count, + private_terms, + }) +} + +#[cfg(feature = "runtime")] +async fn trade_projection_for_trade( + sdk: &RadrootsClient, + trade_id: &RadrootsTradeId, +) -> Result<RadrootsTradeProjectionV1, 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 mut input = RadrootsTradeReductionInputV1::new(trade_id.clone()); + input.mutations = stored + .iter() + .map(stored_trade_mutation_record) + .collect::<Result<Vec<_>, _>>()?; + input.private_terms = + private_terms_evidence_for_mutations(sdk, trade_id, &input.mutations).await?; + input.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: &RadrootsTradeId, +) -> 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.clone(), item.state)) + .collect::<BTreeMap<_, _>>(); + let mut views = BTreeMap::<RadrootsTradeCandidateId, 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.clone(), + 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: &RadrootsTradeId, + mutations: &[RadrootsTradeMutationRecordV1], +) -> Result<Vec<RadrootsTradePrivateTermsEvidenceV1>, RadrootsSdkError> { + let mut evidence = Vec::new(); + let mut seen = BTreeSet::new(); + for record in mutations { + if let Some((candidate_id, private_ref)) = candidate_private_ref(&record.mutation) { + if !seen.insert(candidate_id.clone()) { + continue; + } + evidence.push( + sdk._private_store + .private_terms_evidence( + trade_id.as_str(), + candidate_id.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: &RadrootsTradeMutationEnvelopeV1, +) -> Option<(RadrootsTradeCandidateId, RadrootsTradePrivateTermsRefV1)> { + match &envelope.body { + RadrootsTradeMutationBodyV1::Proposal { candidate } + | RadrootsTradeMutationBodyV1::RevisionProposal { candidate } => { + let candidate_id = candidate.candidate_id.clone()?; + 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 { + transport_event_id: Some(stored.first_transport_event_id.clone()), + mutation: stored_trade_envelope(stored)?, + }) +} + +#[cfg(feature = "runtime")] +fn stored_trade_envelope( + stored: &RadrootsStoredTradeMutation, +) -> Result<RadrootsTradeMutationEnvelopeV1, 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, + buyer_pubkey: view.projection.buyer_pubkey.map(|value| value.to_string()), + seller_pubkey: view.projection.seller_pubkey.map(|value| value.to_string()), + farm_id: view.projection.farm_id.map(|value| value.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, + 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: RadrootsTradeId::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.as_str()) + .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: RadrootsTradeId::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: &RadrootsTradeId, +) -> 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.as_str()) + .fetch_optional(sdk._event_store.pool()) + .await + .map_err(trade_query_store_error)?; + match row { + Some(row) => Ok(LastTradeMutationSnapshot { + mutation_id: Some( + RadrootsTradeMutationId::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<RadrootsTradeCandidateId> { + value.map(|candidate_id| { + RadrootsTradeCandidateId::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/src/workflow_runtime.rs b/crates/sdk/src/workflow_runtime.rs @@ -15,7 +15,7 @@ use radroots_event_store::{ }; use radroots_outbox::{ RadrootsOutboxDeliveryPlanInput, RadrootsOutboxEnqueueStatus, RadrootsOutboxReticulumBehavior, - RadrootsOutboxSignedOperationInput, + RadrootsOutboxSignedOperationInput, RadrootsOutboxSignedTradeMutationInput, }; use radroots_transport::{ RADROOTS_RETICULUM_ENDPOINT_URI, RadrootsTransportKind, RadrootsTransportTarget, @@ -133,6 +133,11 @@ async fn enqueue_signed_workflow_event( signed_event: RadrootsSignedEvent, delivery_plan: SdkResolvedDeliveryPlan, ) -> Result<SdkWorkflowEnqueueReceipt, RadrootsSdkError> { + if radroots_event::kinds::TRADE_MUTATION_EVENT_KINDS.contains(&request.frozen_draft.kind_u32()) + { + return enqueue_signed_trade_workflow_event(sdk, request, signed_event, delivery_plan) + .await; + } let idempotency_key = request .idempotency_key @@ -201,6 +206,93 @@ async fn enqueue_signed_workflow_event( Ok(receipt) } +async fn enqueue_signed_trade_workflow_event( + sdk: &RadrootsClient, + request: &SdkWorkflowEnqueueRequest<'_>, + signed_event: RadrootsSignedEvent, + delivery_plan: SdkResolvedDeliveryPlan, +) -> Result<SdkWorkflowEnqueueReceipt, RadrootsSdkError> { + let idempotency_key = + request + .idempotency_key + .clone() + .ok_or_else(|| RadrootsSdkError::InvalidRequest { + message: format!( + "{} requires an explicit UUIDv7 idempotency key", + request.operation_kind + ), + })?; + let observed_at_ms = sdk_now_ms(sdk)?; + let signed_event_id = RadrootsEventId::parse(request.frozen_draft.expected_event_id_str()) + .expect("frozen workflow draft has a valid expected event id"); + let delivery_plan_value = delivery_plan.delivery_plan; + let mut tx = + sdk._event_store + .pool() + .begin() + .await + .map_err(|error| RadrootsSdkError::EventStore { + message: error.to_string(), + })?; + ensure_runtime_operation_can_commit(&mut tx, request, &idempotency_key).await?; + let local_import_observation = RadrootsTransportObservation::new( + RadrootsTransportKind::Local, + SDK_LOCAL_EVENT_ENDPOINT_URI, + RadrootsTransportObservationType::LocalImport, + observed_at_ms, + )?; + let ingest = RadrootsEventIngest::new(signed_event.clone(), observed_at_ms) + .with_observation(local_import_observation); + let ingest_receipt = sdk + ._event_store + .ingest_event_in_transaction(&mut tx, ingest) + .await?; + tx.commit() + .await + .map_err(|error| RadrootsSdkError::EventStore { + message: error.to_string(), + })?; + let outbox_input = signed_trade_outbox_input( + request.operation_kind, + request.frozen_draft, + signed_event, + delivery_plan_value, + idempotency_key.clone(), + ingest_receipt.inserted, + observed_at_ms, + )?; + let outbox_receipt = sdk + ._outbox + .enqueue_signed_trade_mutation_operation(outbox_input) + .await?; + let idempotency_digest_prefix = + digest_prefix(outbox_receipt.operation_idempotency_digest.as_str()); + let receipt = SdkWorkflowEnqueueReceipt { + signed_event_id, + local_event_seq: ingest_receipt.seq, + outbox_operation_id: outbox_receipt.operation_id, + outbox_event_id: outbox_receipt.outbox_event_id, + state: outbox_receipt.status, + idempotency_digest_prefix, + }; + let mut tx = + sdk._event_store + .pool() + .begin() + .await + .map_err(|error| RadrootsSdkError::EventStore { + message: error.to_string(), + })?; + commit_runtime_operation_journal(&mut tx, request, &idempotency_key, &receipt, observed_at_ms) + .await?; + tx.commit() + .await + .map_err(|error| RadrootsSdkError::EventStore { + message: error.to_string(), + })?; + Ok(receipt) +} + struct SdkResolvedDeliveryPlan { delivery_plan: RadrootsOutboxDeliveryPlanInput, } @@ -393,6 +485,40 @@ fn signed_outbox_input( .with_idempotency_key(idempotency_key.into_string()) } +fn signed_trade_outbox_input( + operation_kind: &'static str, + frozen_draft: &RadrootsEventDraft, + signed_event: RadrootsSignedEvent, + delivery_plan: RadrootsOutboxDeliveryPlanInput, + idempotency_key: SdkIdempotencyKey, + event_store_inserted: bool, + observed_at_ms: i64, +) -> Result<RadrootsOutboxSignedTradeMutationInput, RadrootsSdkError> { + let envelope = + radroots_event::trade::trade_mutation_from_canonical_content(frozen_draft.content()) + .map_err(|error| RadrootsSdkError::InvalidRequest { + message: format!("trade mutation draft content is invalid: {error}"), + })?; + let mutation_id = envelope + .mutation_id + .ok_or_else(|| RadrootsSdkError::InvalidRequest { + message: "trade mutation draft content is missing mutation id".to_owned(), + })?; + Ok(RadrootsOutboxSignedTradeMutationInput::new( + operation_kind, + envelope.trade_id, + mutation_id, + hex::encode(Sha256::digest(frozen_draft.content().as_bytes())), + frozen_draft.clone(), + signed_event, + delivery_plan, + event_store_inserted, + observed_at_ms, + observed_at_ms, + ) + .with_idempotency_key(idempotency_key.into_string())) +} + async fn prepare_runtime_operation_journal( sdk: &RadrootsClient, request: &SdkWorkflowEnqueueRequest<'_>, diff --git a/crates/sdk/tests/runtime_foundation.rs b/crates/sdk/tests/runtime_foundation.rs @@ -1,19 +1,17 @@ #![cfg(feature = "runtime")] -use radroots_event::ids::RadrootsOrderId; use radroots_sdk::{ BackupRequest, IntegrityRequest, LISTING_PUBLISH_OPERATION_KIND, NostrProfile, NostrRelayUrlPolicy, RadrootsClient, RadrootsSdkClock, RadrootsSdkError, RadrootsSdkErrorClass, RadrootsSdkGeoNamesErrorKind, RadrootsSdkRecoveryAction, RadrootsSdkStorageConfig, - RadrootsSdkTimestamp, RestoreRequest, ReticulumBehavior, SDK_IDEMPOTENCY_KEY_MAX_LEN, - SDK_TRANSPORT_TARGET_MAX_COUNT, SdkBackupState, SdkBackupVerification, - SdkEventStoreStorageStatus, SdkIdempotencyKey, SdkOutboxStorageStatus, + RadrootsSdkTimestamp, RadrootsSdkTradeErrorKind, RestoreRequest, ReticulumBehavior, + SDK_IDEMPOTENCY_KEY_MAX_LEN, SDK_TRANSPORT_TARGET_MAX_COUNT, SdkBackupState, + SdkBackupVerification, SdkEventStoreStorageStatus, SdkIdempotencyKey, SdkOutboxStorageStatus, SdkPrivateStoreStorageStatus, SdkRestoreState, SdkSqliteStoreStatus, SdkSqliteWalCheckpointReceipt, SdkSqliteWalStatus, SdkStorageKind, SdkStudioStoreStorageStatus, StorageCheckpointReceipt, StorageCheckpointRequest, StorageStatusReceipt, StorageStatusRequest, TargetPolicy, TargetSet, TransportProfile, }; -use radroots_trade::identity::RadrootsTradeLocator; use sqlx::Row; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use std::path::{Path, PathBuf}; @@ -431,40 +429,37 @@ fn sdk_error_contract_methods_cover_all_variants() { vec![RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey], ), ( - RadrootsSdkError::TradeStatusLimitInvalid { - limit: 0, - min: 1, - max: 1000, + RadrootsSdkError::Trade { + kind: RadrootsSdkTradeErrorKind::QueryLimitInvalid, + operation: "trade.list".to_owned(), + message: "limit out of range".to_owned(), }, - "trade_status_limit_invalid", + "trade_query_limit_invalid", RadrootsSdkErrorClass::Request, false, vec![RadrootsSdkRecoveryAction::FixRequest], ), ( - RadrootsSdkError::InvalidTradeId { - value: "bad".to_owned(), - message: "invalid".to_owned(), + RadrootsSdkError::Trade { + kind: RadrootsSdkTradeErrorKind::TradeNotFound, + operation: "trade.get".to_owned(), + message: "not found".to_owned(), }, - "invalid_trade_id", + "trade_not_found", RadrootsSdkErrorClass::Request, false, vec![RadrootsSdkRecoveryAction::FixRequest], ), ( - RadrootsSdkError::TradeAmbiguous { - operation: "trade.accept".to_owned(), - locator: Box::new(RadrootsTradeLocator::from_order_id( - RadrootsOrderId::parse("trade-error").expect("order id"), - )), - candidates: vec![RadrootsTradeLocator::from_order_id( - RadrootsOrderId::parse("trade-error").expect("order id"), - )], + RadrootsSdkError::Trade { + kind: RadrootsSdkTradeErrorKind::PrivateArtifactAcknowledgementMissing, + operation: "trade.decide_candidate".to_owned(), + message: "acknowledgement missing".to_owned(), }, - "trade_ambiguous", + "trade_private_artifact_acknowledgement_missing", RadrootsSdkErrorClass::Request, false, - vec![RadrootsSdkRecoveryAction::SelectTradeRoot], + vec![RadrootsSdkRecoveryAction::FixRequest], ), ( RadrootsSdkError::ProductSyncUnsupported { diff --git a/crates/sdk/tests/unit/error_tests.rs b/crates/sdk/tests/unit/error_tests.rs @@ -1,4 +1,6 @@ -use super::{RadrootsSdkError, RadrootsSdkGeoNamesErrorKind, redacted_relay_url}; +use super::{ + RadrootsSdkError, RadrootsSdkGeoNamesErrorKind, RadrootsSdkTradeErrorKind, redacted_relay_url, +}; use crate::privacy::{PrivacyPreflightStatus, ProductSensitivityField}; use crate::transport::ReticulumBehavior; use radroots_authority::RadrootsAuthorityError; @@ -295,14 +297,15 @@ fn sdk_error_contract_methods_cover_representative_classes_and_details() { existing_digest_prefix: "existing".to_owned(), new_digest_prefix: "new".to_owned(), }, - RadrootsSdkError::TradeStatusLimitInvalid { - limit: 0, - min: 1, - max: 100, + RadrootsSdkError::Trade { + kind: RadrootsSdkTradeErrorKind::QueryLimitInvalid, + operation: "trade.list".to_owned(), + message: "limit out of range".to_owned(), }, - RadrootsSdkError::InvalidTradeId { - value: "bad order".to_owned(), - message: "bad id".to_owned(), + RadrootsSdkError::Trade { + kind: RadrootsSdkTradeErrorKind::PrivateArtifactMissing, + operation: "trade.submit_proposal".to_owned(), + message: "private artifact missing".to_owned(), }, RadrootsSdkError::PrivacyPreflight { operation: "trade.cancel".to_owned(), diff --git a/crates/sdk/tests/unit/private_store_tests.rs b/crates/sdk/tests/unit/private_store_tests.rs @@ -206,13 +206,12 @@ async fn private_store_schema_uses_v1_private_authority_tables() { .collect::<Vec<_>>(); for table in [ - "buyer_contact_private", "cursor_hmac_key", "key_rotation_progress", "nip46_session_private", "private_farm_location", "private_metadata", - "trade_private_thread", + "private_trade_artifacts", "wrapped_profile_key", "wrapped_signing_secret", ] { diff --git a/crates/sdk/tests/unit/trade_runtime_tests.rs b/crates/sdk/tests/unit/trade_runtime_tests.rs @@ -0,0 +1,313 @@ +use super::*; +use crate::{ + RadrootsClient, RadrootsSdkError, RadrootsSdkTimestamp, RadrootsSdkTradeErrorKind, + SatisfactionPolicy, SdkIdempotencyKey, TargetPolicy, +}; +use radroots_authority::{RadrootsActorContext, RadrootsLocalEventSigner}; +use radroots_event::{ + contract::RadrootsActorRole, + ids::{ + RadrootsAddressableCoordinate, RadrootsDTag, RadrootsEventId, RadrootsInventoryBinId, + RadrootsPublicKey, RadrootsTradeId, + }, + trade::{ + RADROOTS_TRADE_DECISION_CONTRACT_ID, RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, + RADROOTS_TRADE_SCHEMA_VERSION, RadrootsFulfillmentProfileV1, + RadrootsSellerReservationAssertionV1, RadrootsSellerReservationLineV1, + RadrootsTradeCancellationProfileV1, RadrootsTradeCandidateLineV1, + RadrootsTradeCandidateTermsV1, RadrootsTradeDecisionV1, RadrootsTradeEconomicAdjustmentV1, + RadrootsTradeEconomicsProfileV1, RadrootsTradeMutationBodyV1, + RadrootsTradeMutationEnvelopeV1, canonical_trade_mutation_content, + }, +}; +use radroots_nostr::prelude::{RadrootsNostrKeys, RadrootsNostrSecretKey}; +use radroots_trade::workflow::RadrootsTradePrivateTermsStateV1; + +const BUYER_SECRET_KEY_HEX: &str = + "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; +const SELLER_SECRET_KEY_HEX: &str = + "59392e9068f66431b12f70218fb61281cb6b433d7f27c55d61f1a63fe1a96ff8"; + +fn pubkey(value: &str) -> RadrootsPublicKey { + RadrootsPublicKey::parse(value).expect("pubkey") +} + +fn event_id(marker: char) -> RadrootsEventId { + RadrootsEventId::parse(std::iter::repeat_n(marker, 64).collect::<String>()).expect("event id") +} + +fn trade_id() -> RadrootsTradeId { + RadrootsTradeId::parse("11111111111111111111111111111111").expect("trade id") +} + +fn local_signer(secret_key_hex: &str) -> (String, RadrootsLocalEventSigner) { + let secret_key = RadrootsNostrSecretKey::from_hex(secret_key_hex).expect("secret key"); + let keys = RadrootsNostrKeys::new(secret_key); + let pubkey = keys.public_key().to_hex(); + ( + pubkey, + RadrootsLocalEventSigner::new(keys).expect("local event signer"), + ) +} + +fn buyer_actor(buyer_pubkey: &str) -> RadrootsActorContext { + RadrootsActorContext::test(buyer_pubkey, [RadrootsActorRole::Buyer]).expect("buyer") +} + +fn seller_actor(seller_pubkey: &str) -> RadrootsActorContext { + RadrootsActorContext::test(seller_pubkey, [RadrootsActorRole::Seller]).expect("seller") +} + +fn candidate(buyer_pubkey: &str, seller_pubkey: &str) -> RadrootsTradeCandidateTermsV1 { + RadrootsTradeCandidateTermsV1 { + 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: RadrootsDTag::parse("farm-1").expect("farm id"), + lines: vec![RadrootsTradeCandidateLineV1 { + line_id: RadrootsDTag::parse("line-1").expect("line id"), + listing_addr: RadrootsAddressableCoordinate::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: RadrootsInventoryBinId::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: RadrootsTradeEconomicsProfileV1 { + 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::<RadrootsTradeEconomicAdjustmentV1>::new(), + }, + fulfillment: RadrootsFulfillmentProfileV1 { + 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: RadrootsTradeCancellationProfileV1 { + 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: RadrootsTradeCandidateTermsV1, + buyer_pubkey: &str, + seller_pubkey: &str, +) -> RadrootsTradeMutationEnvelopeV1 { + RadrootsTradeMutationEnvelopeV1 { + 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: RadrootsDTag::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: RadrootsTradeMutationBodyV1::Proposal { candidate }, + } +} + +fn reservation( + candidate: &RadrootsTradeCandidateTermsV1, + seller_pubkey: &str, +) -> RadrootsSellerReservationAssertionV1 { + RadrootsSellerReservationAssertionV1 { + reservation_id: RadrootsDTag::parse("reservation-1").expect("reservation id"), + inventory_authority_id: pubkey(seller_pubkey), + inventory_epoch: 42, + candidate_id: candidate.candidate_id.clone().expect("candidate id"), + commitments: candidate + .lines + .iter() + .map(|line| RadrootsSellerReservationLineV1 { + 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: &RadrootsTradeMutationEnvelopeV1, + buyer_pubkey: &str, + seller_pubkey: &str, +) -> RadrootsTradeMutationEnvelopeV1 { + let proposal_id = proposal.mutation_id.clone().expect("proposal id"); + let candidate = match &proposal.body { + RadrootsTradeMutationBodyV1::Proposal { candidate } => candidate.clone(), + _ => unreachable!(), + }; + RadrootsTradeMutationEnvelopeV1 { + mutation_id: None, + contract_id: RADROOTS_TRADE_DECISION_CONTRACT_ID.to_owned(), + schema_version: RADROOTS_TRADE_SCHEMA_VERSION, + trade_id: proposal.trade_id.clone(), + root_mutation_id: Some(proposal_id.clone()), + buyer_pubkey: pubkey(buyer_pubkey), + seller_pubkey: pubkey(seller_pubkey), + farm_id: RadrootsDTag::parse("farm-1").expect("farm id"), + parent_mutation_ids: vec![proposal_id.clone()], + author_pubkey: pubkey(seller_pubkey), + counterparty_pubkey: pubkey(buyer_pubkey), + authored_at_unix_s: 1_799_000_060, + body: RadrootsTradeMutationBodyV1::Decision { + proposal_mutation_id: proposal_id, + candidate_id: candidate.candidate_id.clone().expect("candidate id"), + decision: RadrootsTradeDecisionV1::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(BUYER_SECRET_KEY_HEX); + let (seller_pubkey, seller_signer) = local_signer(SELLER_SECRET_KEY_HEX); + 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, + .. + } + )); +}