commit 731029342394b7253a20818d07806f06a7c68f29
parent c63dae0f1db19f4662f758f4f9bc7cd025af23d3
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:
12 files changed, 3058 insertions(+), 117 deletions(-)
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,
+ ..
+ }
+ ));
+}