lib

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

trade.rs (38431B)


      1 //! Canonical trade planning, commit, query, and private-evidence operations.
      2 
      3 use std::{error, fmt};
      4 
      5 use radroots_event::{
      6     contract::AuthorRole,
      7     trade::{TradeMutationEnvelopeV1, TradeProtocolError, canonical_trade_mutation_content},
      8 };
      9 use radroots_event_codec::authoring::{AuthoredEventPlan, AuthoredPlanError};
     10 use radroots_signing::Actor;
     11 use radroots_trade::{Projection, ReductionInput, WorkflowPlan, reducer::reduce_trade_records};
     12 
     13 pub use radroots_event_codec::decode::rhi::{
     14     RadrootsRhiEvidenceAttestationError, RadrootsRhiEvidenceAttestationOutcomeV1,
     15     RadrootsRhiEvidenceAttestationSupersessionV1, RadrootsRhiEvidenceAttestationV1,
     16 };
     17 pub use radroots_trade::evidence::{
     18     RadrootsRhiEvidenceReasonCodeV1, RadrootsRhiEvidenceReportError, RadrootsRhiEvidenceReportV1,
     19     RadrootsRhiEvidenceStatementDigestV1, RadrootsRhiEvidenceSupersessionV1,
     20     RadrootsTradeEvidenceCoverageError, RadrootsTradeEvidenceCoverageV1,
     21     RadrootsTradeEvidenceManifestDigestV1, RadrootsTradeEvidenceManifestError,
     22     RadrootsTradeEvidenceManifestObservationV1, RadrootsTradeEvidenceManifestSourceResultV1,
     23     RadrootsTradeEvidenceManifestV1, RadrootsTradeEvidenceOutcomeV1,
     24     RadrootsTradeEvidencePolicyDigestV1, RadrootsTradeEvidenceProjectionDigestV1,
     25     RadrootsTradeEvidenceProvenanceDigestV1, RadrootsTradeEvidenceScopePrerequisitesV1,
     26     RadrootsTradeEvidenceSourceCompletionV1, RadrootsTradeEvidenceSourceIdV1,
     27     RadrootsTradeEvidenceSourceRequirementV1, RadrootsTradeEvidenceSourceResultDigestV1,
     28     RadrootsTradeEvidenceSourceResultV1, RadrootsTradeSignedEventDigestV1,
     29     classify_trade_evidence_coverage_v1,
     30 };
     31 
     32 /// Parses one bounded canonical trade-evidence manifest.
     33 pub fn parse_evidence_manifest(
     34     canonical_bytes: &[u8],
     35 ) -> Result<RadrootsTradeEvidenceManifestV1, RadrootsTradeEvidenceManifestError> {
     36     RadrootsTradeEvidenceManifestV1::from_canonical_bytes(canonical_bytes)
     37 }
     38 
     39 /// Parses one bounded canonical RHI evidence report.
     40 pub fn parse_rhi_evidence_report(
     41     canonical_content: &[u8],
     42 ) -> Result<RadrootsRhiEvidenceReportV1, RadrootsRhiEvidenceReportError> {
     43     RadrootsRhiEvidenceReportV1::from_canonical_content(canonical_content)
     44 }
     45 
     46 /// Builds one immutable typed RHI attestation plan without signing or I/O.
     47 pub fn prepare_rhi_evidence_attestation(
     48     report: &RadrootsRhiEvidenceReportV1,
     49     created_at: u64,
     50 ) -> Result<AuthoredEventPlan, AuthoredPlanError> {
     51     let attestation = RadrootsRhiEvidenceAttestationV1::from_canonical_content(
     52         report.canonical_content().as_bytes(),
     53     )
     54     .map_err(AuthoredPlanError::Rhi)?;
     55     AuthoredEventPlan::from_rhi_evidence_attestation(&attestation, created_at)
     56 }
     57 
     58 /// Verifies NIP-01 identity/signature and then validates the exact RHI event.
     59 pub fn validate_rhi_evidence_attestation(
     60     event: radroots_event::envelope::EventEnvelope,
     61 ) -> Result<RadrootsRhiEvidenceAttestationV1, EvidenceAttestationValidationError> {
     62     let verified =
     63         radroots_event_codec::verify::id(radroots_event::admission::RawEvent::new(event))
     64             .and_then(|event| {
     65                 radroots_event_codec::verify::signature(
     66                     event,
     67                     &radroots_event_codec::verify::Nip01SignatureVerifier,
     68                 )
     69             })
     70             .map_err(|_| EvidenceAttestationValidationError::Signature)?;
     71     radroots_event_codec::decode::rhi::rhi_evidence_attestation_from_verified_event(&verified)
     72         .map_err(|_| EvidenceAttestationValidationError::Contract)
     73 }
     74 
     75 /// Stable, value-free failure for SDK signed-attestation validation.
     76 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     77 pub enum EvidenceAttestationValidationError {
     78     /// NIP-01 event identity or signature verification failed.
     79     Signature,
     80     /// The verified event does not satisfy the RHI attestation contract.
     81     Contract,
     82 }
     83 
     84 impl fmt::Display for EvidenceAttestationValidationError {
     85     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     86         formatter.write_str(match self {
     87             Self::Signature => "evidence attestation signature validation failed",
     88             Self::Contract => "evidence attestation contract validation failed",
     89         })
     90     }
     91 }
     92 
     93 impl error::Error for EvidenceAttestationValidationError {}
     94 
     95 /// Pure inputs for one frozen trade command.
     96 #[derive(Clone, Debug)]
     97 pub struct PrepareRequest {
     98     actor: Actor,
     99     mutation: TradeMutationEnvelopeV1,
    100 }
    101 
    102 impl PrepareRequest {
    103     /// Creates explicit inputs for any canonical proposal, revision, decision,
    104     /// cancellation, or resumable mutation.
    105     #[must_use]
    106     pub const fn new(actor: Actor, mutation: TradeMutationEnvelopeV1) -> Self {
    107         Self { actor, mutation }
    108     }
    109 }
    110 
    111 /// Frozen, replay-stable trade workflow plan.
    112 #[derive(Clone, Debug)]
    113 pub struct Plan {
    114     actor: Actor,
    115     workflow: WorkflowPlan,
    116     authored_event: AuthoredEventPlan,
    117 }
    118 
    119 impl Plan {
    120     /// Returns the exact authorized actor carried into signing.
    121     #[must_use]
    122     pub const fn actor(&self) -> &Actor {
    123         &self.actor
    124     }
    125 
    126     /// Returns the lower-owned validated workflow and required host actions.
    127     pub const fn workflow(&self) -> &WorkflowPlan {
    128         &self.workflow
    129     }
    130 
    131     /// Returns the immutable canonical authored event plan.
    132     #[must_use]
    133     pub const fn authored_event(&self) -> &AuthoredEventPlan {
    134         &self.authored_event
    135     }
    136 }
    137 
    138 /// Trade planning failure stage.
    139 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    140 #[non_exhaustive]
    141 pub enum PrepareErrorKind {
    142     /// The actor identity or role cannot author the supplied mutation.
    143     UnauthorizedActor,
    144     /// Event-domain canonicalization rejected the mutation.
    145     CanonicalMutation,
    146     /// The lower trade workflow rejected the canonical mutation.
    147     Workflow,
    148     /// The canonical event codec rejected the mutation.
    149     Encode,
    150     /// The canonical event draft rejected the encoded mutation.
    151     Draft,
    152 }
    153 
    154 /// One secret-safe trade planning failure retaining its lower source.
    155 pub struct PrepareError {
    156     kind: PrepareErrorKind,
    157     source: Option<Box<dyn error::Error + Send + Sync>>,
    158 }
    159 
    160 impl PrepareError {
    161     /// Returns the stable client-level planning stage.
    162     #[must_use]
    163     pub const fn kind(&self) -> PrepareErrorKind {
    164         self.kind
    165     }
    166 
    167     fn unauthorized_actor() -> Self {
    168         Self {
    169             kind: PrepareErrorKind::UnauthorizedActor,
    170             source: None,
    171         }
    172     }
    173 
    174     fn canonical(source: TradeProtocolError) -> Self {
    175         Self::with_source(PrepareErrorKind::CanonicalMutation, source)
    176     }
    177 
    178     fn workflow(source: radroots_trade::Error) -> Self {
    179         Self::with_source(PrepareErrorKind::Workflow, source)
    180     }
    181 
    182     fn encode(source: AuthoredPlanError) -> Self {
    183         Self::with_source(PrepareErrorKind::Encode, source)
    184     }
    185 
    186     fn with_source(
    187         kind: PrepareErrorKind,
    188         source: impl error::Error + Send + Sync + 'static,
    189     ) -> Self {
    190         Self {
    191             kind,
    192             source: Some(Box::new(source)),
    193         }
    194     }
    195 }
    196 
    197 impl fmt::Display for PrepareError {
    198     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    199         formatter.write_str(match self.kind {
    200             PrepareErrorKind::UnauthorizedActor => "trade actor is not authorized",
    201             PrepareErrorKind::CanonicalMutation => "trade mutation is not canonical",
    202             PrepareErrorKind::Workflow => "trade workflow is invalid",
    203             PrepareErrorKind::Encode => "trade event encoding failed",
    204             PrepareErrorKind::Draft => "trade event draft is invalid",
    205         })
    206     }
    207 }
    208 
    209 impl fmt::Debug for PrepareError {
    210     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    211         formatter
    212             .debug_struct("PrepareError")
    213             .field("kind", &self.kind)
    214             .finish_non_exhaustive()
    215     }
    216 }
    217 
    218 impl error::Error for PrepareError {
    219     fn source(&self) -> Option<&(dyn error::Error + 'static)> {
    220         self.source
    221             .as_deref()
    222             .map(|source| source as &(dyn error::Error + 'static))
    223     }
    224 }
    225 
    226 /// Canonicalizes, authorizes, validates, and freezes one trade command.
    227 ///
    228 /// This operation performs no signing, persistence, private-artifact access,
    229 /// scheduling, or delivery. Every proposal, revision, decision, cancellation,
    230 /// and resumed command uses the same lower-owned `TradeId` and workflow law.
    231 pub fn prepare(request: PrepareRequest) -> Result<Plan, PrepareError> {
    232     let canonical = canonical_trade_mutation_content(request.mutation)
    233         .map_err(PrepareError::canonical)?
    234         .envelope;
    235     let required_role = match (
    236         canonical.author_pubkey == canonical.buyer_pubkey,
    237         canonical.author_pubkey == canonical.seller_pubkey,
    238     ) {
    239         (true, _) => AuthorRole::Buyer,
    240         (false, true) => AuthorRole::Seller,
    241         (false, false) => return Err(PrepareError::unauthorized_actor()),
    242     };
    243     if (
    244         request.actor.public_key() == canonical.author_pubkey,
    245         request.actor.satisfies(required_role),
    246     ) != (true, true)
    247     {
    248         return Err(PrepareError::unauthorized_actor());
    249     }
    250     let workflow = WorkflowPlan::prepare(canonical.clone()).map_err(PrepareError::workflow)?;
    251     let authored_event =
    252         AuthoredEventPlan::from_trade_mutation(canonical.clone()).map_err(PrepareError::encode)?;
    253     Ok(Plan {
    254         actor: request.actor,
    255         workflow,
    256         authored_event,
    257     })
    258 }
    259 
    260 /// Deterministically reduces caller-supplied canonical evidence.
    261 #[must_use]
    262 pub fn project(input: ReductionInput) -> Projection {
    263     reduce_trade_records(input)
    264 }
    265 
    266 #[cfg(feature = "sync")]
    267 use radroots_signing::request::CancellationPolicy;
    268 #[cfg(feature = "sync")]
    269 use radroots_storage::{
    270     event::{EventPage, EventQuery, StoredVisibleEvent},
    271     journal::IdempotencyKey,
    272     private_artifact::{PrivateArtifactId, PrivateArtifactMetadata, PrivateArtifactStage},
    273 };
    274 #[cfg(feature = "sync")]
    275 use radroots_sync::{
    276     policy::{Error as SyncError, SyncId},
    277     push::PushStatus,
    278 };
    279 
    280 /// Explicit commit inputs for one prepared trade command.
    281 #[cfg(feature = "sync")]
    282 #[derive(Clone, Debug)]
    283 pub struct EnqueueRequest {
    284     operation_id: SyncId,
    285     idempotency_key: IdempotencyKey,
    286     plan: Plan,
    287     profile: crate::transport::Profile,
    288     delivery_deadline_unix_ms: u64,
    289     cancellation: CancellationPolicy,
    290 }
    291 
    292 #[cfg(feature = "sync")]
    293 impl EnqueueRequest {
    294     /// Creates a command request whose transport selection has no fallback.
    295     #[must_use]
    296     pub const fn new(
    297         operation_id: SyncId,
    298         idempotency_key: IdempotencyKey,
    299         plan: Plan,
    300         profile: crate::transport::Profile,
    301         delivery_deadline_unix_ms: u64,
    302         cancellation: CancellationPolicy,
    303     ) -> Self {
    304         Self {
    305             operation_id,
    306             idempotency_key,
    307             plan,
    308             profile,
    309             delivery_deadline_unix_ms,
    310             cancellation,
    311         }
    312     }
    313 }
    314 
    315 /// Private-term metadata verification failure.
    316 #[cfg(feature = "sync")]
    317 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    318 #[non_exhaustive]
    319 pub enum PrivateTermsError {
    320     /// Canonical storage failed to inspect the requested metadata.
    321     Storage,
    322     /// The workflow does not require private terms.
    323     NotRequired,
    324     /// Metadata is absent, inactive, or does not match the public commitment.
    325     EvidenceMismatch,
    326 }
    327 
    328 /// Borrowed trade operations over canonical storage and sync capabilities.
    329 #[cfg(feature = "sync")]
    330 #[derive(Clone, Copy)]
    331 pub struct Operations<'a> {
    332     storage: &'a dyn radroots_storage::Storage,
    333     sync: crate::sync::Operations<'a>,
    334 }
    335 
    336 #[cfg(feature = "sync")]
    337 impl fmt::Debug for Operations<'_> {
    338     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    339         formatter
    340             .debug_struct("Operations")
    341             .field("storage", &"<borrowed canonical storage>")
    342             .field("sync", &self.sync)
    343             .finish()
    344     }
    345 }
    346 
    347 #[cfg(feature = "sync")]
    348 impl<'a> Operations<'a> {
    349     pub(crate) const fn new(
    350         storage: &'a dyn radroots_storage::Storage,
    351         sync: crate::sync::Operations<'a>,
    352     ) -> Self {
    353         Self { storage, sync }
    354     }
    355 
    356     /// Durably prepares, signs, and locally admits a command. Reusing the same
    357     /// idempotency input is the canonical resume/replay operation.
    358     pub async fn enqueue(&self, request: EnqueueRequest) -> Result<PushStatus, SyncError> {
    359         let targets = request
    360             .profile
    361             .targets()
    362             .cloned()
    363             .ok_or(SyncError::InvalidPushRequest)?;
    364         let satisfaction = request
    365             .profile
    366             .satisfaction()
    367             .cloned()
    368             .ok_or(SyncError::InvalidPushRequest)?;
    369         self.sync
    370             .submit_push(radroots_sync::PushRequest::new(
    371                 request.operation_id,
    372                 request.idempotency_key,
    373                 request.plan.actor,
    374                 request.plan.authored_event,
    375                 targets,
    376                 satisfaction,
    377                 request.delivery_deadline_unix_ms,
    378                 request.cancellation,
    379             )?)
    380             .await
    381     }
    382 
    383     /// Returns one native, bounded, generation-bound page of visible evidence.
    384     pub async fn query_visible(
    385         &self,
    386         query: EventQuery,
    387     ) -> Result<EventPage<StoredVisibleEvent>, radroots_storage::Error> {
    388         radroots_storage::event::EventStore::query_visible(self.storage, query).await
    389     }
    390 
    391     /// Returns native private-artifact metadata without reading secret material.
    392     pub async fn private_artifact(
    393         &self,
    394         artifact_id: PrivateArtifactId,
    395     ) -> Result<Option<PrivateArtifactMetadata>, radroots_storage::Error> {
    396         radroots_storage::private_artifact::PrivateArtifactStore::metadata(
    397             self.storage,
    398             artifact_id,
    399         )
    400         .await
    401     }
    402 
    403     /// Verifies that canonical active metadata matches a plan's public schema
    404     /// and ciphertext commitment. Plaintext, ciphertext, and keys never cross
    405     /// the SDK boundary.
    406     pub async fn verify_private_terms(
    407         &self,
    408         plan: &Plan,
    409         artifact_id: PrivateArtifactId,
    410     ) -> Result<PrivateArtifactMetadata, PrivateTermsError> {
    411         let expected = plan
    412             .workflow
    413             .private_terms()
    414             .ok_or(PrivateTermsError::NotRequired)?;
    415         let metadata = self
    416             .private_artifact(artifact_id)
    417             .await
    418             .map_err(|_| PrivateTermsError::Storage)?
    419             .ok_or(PrivateTermsError::EvidenceMismatch)?;
    420         let commitment = hex_lower(metadata.commitment().as_bytes());
    421         let evidence_matches = [
    422             metadata.stage() == PrivateArtifactStage::Active,
    423             metadata.schema_id().as_str() == expected.schema_id(),
    424             commitment == expected.ciphertext_commitment(),
    425         ];
    426         if evidence_matches != [true; 3] {
    427             return Err(PrivateTermsError::EvidenceMismatch);
    428         }
    429         Ok(metadata)
    430     }
    431 }
    432 
    433 #[cfg(feature = "sync")]
    434 fn hex_lower(bytes: &[u8]) -> String {
    435     const HEX: &[u8; 16] = b"0123456789abcdef";
    436     let mut encoded = String::with_capacity(bytes.len() * 2);
    437     for byte in bytes {
    438         encoded.push(char::from(HEX[usize::from(byte >> 4)]));
    439         encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
    440     }
    441     encoded
    442 }
    443 
    444 #[cfg(test)]
    445 mod tests {
    446     use radroots_event::{
    447         id::{ClassifiedListingAddress, DTag, EventId, InventoryBinId, MutationId, TradeId},
    448         trade::{
    449             FulfillmentProfileV1, RADROOTS_TRADE_CANCELLATION_CONTRACT_ID,
    450             RADROOTS_TRADE_DECISION_CONTRACT_ID, RADROOTS_TRADE_PROPOSAL_CONTRACT_ID,
    451             RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID,
    452             RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID, RADROOTS_TRADE_SCHEMA_VERSION,
    453             TradeCancellationProfileV1, TradeCandidateLineV1, TradeCandidateTermsV1,
    454             TradeDecisionV1, TradeEconomicAdjustmentV1, TradeEconomicsProfileV1,
    455             TradeLineTombstoneV1, TradeMutationBodyV1, TradeMutationKindV1, TradePrivateTermsRefV1,
    456         },
    457     };
    458     use radroots_identity::PublicKey;
    459     use radroots_signing::actor::ActorSource;
    460     use radroots_trade::workflow::WorkflowAction;
    461 
    462     use super::*;
    463 
    464     const BUYER: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df";
    465     const SELLER: &str = "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af";
    466 
    467     fn pubkey(value: &str) -> PublicKey {
    468         PublicKey::from_hex(value).expect("public key")
    469     }
    470 
    471     fn actor(public_key: &str, role: AuthorRole) -> Actor {
    472         Actor::from_public_key_hex(public_key, ActorSource::ExplicitPublicKey, [role])
    473             .expect("actor")
    474     }
    475 
    476     fn rhi_attestation_fixture() -> serde_json::Value {
    477         let fixture: serde_json::Value = serde_json::from_str(include_str!(
    478             "../../../contracts/conformance/vectors/event/authored_operations.v1.json"
    479         ))
    480         .expect("authored corpus");
    481         fixture["vectors"]
    482             .as_array()
    483             .expect("operations")
    484             .iter()
    485             .find(|entry| entry["id"] == "typed_rhi_evidence_attestation_017")
    486             .expect("RHI operation")
    487             .get("expected")
    488             .expect("expected")
    489             .clone()
    490     }
    491 
    492     fn mutation_id(marker: char) -> MutationId {
    493         MutationId::parse(std::iter::repeat_n(marker, 64).collect::<String>()).expect("mutation id")
    494     }
    495 
    496     fn candidate(suffix: &str) -> TradeCandidateTermsV1 {
    497         TradeCandidateTermsV1 {
    498             candidate_id: None,
    499             schema_version: RADROOTS_TRADE_SCHEMA_VERSION,
    500             base_candidate_id: None,
    501             supersession_intent: None,
    502             buyer_pubkey: pubkey(BUYER),
    503             seller_pubkey: pubkey(SELLER),
    504             farm_id: DTag::parse("farm-1").expect("farm id"),
    505             lines: vec![TradeCandidateLineV1 {
    506                 line_id: DTag::parse(format!("line-{suffix}")).expect("line id"),
    507                 listing_addr: ClassifiedListingAddress::parse(format!(
    508                     "30402:{SELLER}:listing-{suffix}"
    509                 ))
    510                 .expect("listing address"),
    511                 listing_event_id: EventId::parse("cc".repeat(32)).expect("event id"),
    512                 listing_snapshot_sha256: "dd".repeat(32),
    513                 product_id: format!("carrots-{suffix}"),
    514                 option_id: None,
    515                 bin_id: InventoryBinId::parse(format!("bin-{suffix}")).expect("bin id"),
    516                 quantity_mantissa: "2".into(),
    517                 quantity_scale: 0,
    518                 unit_code: "count".into(),
    519                 unit_profile: "mvp-count".into(),
    520                 unit_price_mantissa: "500".into(),
    521                 currency_code: "USD".into(),
    522                 line_subtotal_mantissa: "1000".into(),
    523                 replaces_line_id: None,
    524             }],
    525             line_tombstones: Vec::<TradeLineTombstoneV1>::new(),
    526             economics: TradeEconomicsProfileV1 {
    527                 profile_id: "mvp-fixed".into(),
    528                 currency_code: "USD".into(),
    529                 currency_exponent: 2,
    530                 rounding_profile: "half-even".into(),
    531                 subtotal_mantissa: "1000".into(),
    532                 discount_total_mantissa: "0".into(),
    533                 adjustment_total_mantissa: "0".into(),
    534                 total_mantissa: "1000".into(),
    535                 adjustments: Vec::<TradeEconomicAdjustmentV1>::new(),
    536             },
    537             fulfillment: FulfillmentProfileV1 {
    538                 profile_id: "market-pickup".into(),
    539                 method: "pickup".into(),
    540                 starts_at_unix_s: 1_800_000_000,
    541                 ends_at_unix_s: 1_800_003_600,
    542                 timezone: "America/New_York".into(),
    543                 utc_offset_seconds: -18_000,
    544                 fold: 0,
    545                 location_class: "farmstand".into(),
    546                 requires_private_terms: true,
    547             },
    548             cancellation: TradeCancellationProfileV1 {
    549                 profile_id: "buyer-pre-agreement".into(),
    550                 buyer_pre_agreement: true,
    551                 post_agreement_cutoff_unix_s: None,
    552             },
    553             private_terms: Some(TradePrivateTermsRefV1 {
    554                 artifact_id: "artifact-1".into(),
    555                 schema_id: "radroots.private.fulfillment.v1".into(),
    556                 ciphertext_commitment: "ee".repeat(32),
    557                 required_acknowledgement: true,
    558             }),
    559             proposal_expires_at_unix_s: 1_800_010_000,
    560         }
    561     }
    562 
    563     fn envelope(contract_id: &str, body: TradeMutationBodyV1) -> TradeMutationEnvelopeV1 {
    564         let initial = body.mutation_kind() == TradeMutationKindV1::Proposal;
    565         TradeMutationEnvelopeV1 {
    566             mutation_id: None,
    567             contract_id: contract_id.into(),
    568             schema_version: RADROOTS_TRADE_SCHEMA_VERSION,
    569             trade_id: TradeId::parse("11".repeat(16)).expect("trade id"),
    570             root_mutation_id: (!initial).then(|| mutation_id('1')),
    571             buyer_pubkey: pubkey(BUYER),
    572             seller_pubkey: pubkey(SELLER),
    573             farm_id: DTag::parse("farm-1").expect("farm id"),
    574             parent_mutation_ids: if initial {
    575                 vec![]
    576             } else {
    577                 vec![mutation_id('1')]
    578             },
    579             author_pubkey: pubkey(BUYER),
    580             counterparty_pubkey: pubkey(SELLER),
    581             authored_at_unix_s: 1_800_000_000,
    582             body,
    583         }
    584     }
    585 
    586     fn all_commands() -> Vec<TradeMutationEnvelopeV1> {
    587         let proposal = canonical_trade_mutation_content(envelope(
    588             RADROOTS_TRADE_PROPOSAL_CONTRACT_ID,
    589             TradeMutationBodyV1::Proposal {
    590                 candidate: candidate("1"),
    591             },
    592         ))
    593         .expect("proposal")
    594         .envelope;
    595         let proposal_id = proposal.mutation_id.expect("proposal id");
    596         let candidate_id = match &proposal.body {
    597             TradeMutationBodyV1::Proposal { candidate } => {
    598                 candidate.candidate_id.expect("candidate id")
    599             }
    600             _ => unreachable!(),
    601         };
    602         vec![
    603             proposal,
    604             envelope(
    605                 RADROOTS_TRADE_REVISION_PROPOSAL_CONTRACT_ID,
    606                 TradeMutationBodyV1::RevisionProposal {
    607                     candidate: candidate("2"),
    608                 },
    609             ),
    610             envelope(
    611                 RADROOTS_TRADE_DECISION_CONTRACT_ID,
    612                 TradeMutationBodyV1::Decision {
    613                     proposal_mutation_id: proposal_id,
    614                     candidate_id,
    615                     decision: TradeDecisionV1::Declined {
    616                         reason: "unavailable".into(),
    617                     },
    618                 },
    619             ),
    620             envelope(
    621                 RADROOTS_TRADE_REVISION_DECISION_CONTRACT_ID,
    622                 TradeMutationBodyV1::RevisionDecision {
    623                     proposal_mutation_id: mutation_id('2'),
    624                     candidate_id,
    625                     decision: TradeDecisionV1::Declined {
    626                         reason: "unavailable".into(),
    627                     },
    628                 },
    629             ),
    630             envelope(
    631                 RADROOTS_TRADE_CANCELLATION_CONTRACT_ID,
    632                 TradeMutationBodyV1::Cancellation {
    633                     target_candidate_id: Some(candidate_id),
    634                     target_claim_mutation_id: None,
    635                     reason: "cancelled".into(),
    636                 },
    637             ),
    638         ]
    639     }
    640 
    641     #[test]
    642     fn prepare_covers_every_command_with_one_trade_identity_and_private_plan() {
    643         let plans = all_commands()
    644             .into_iter()
    645             .map(|mutation| {
    646                 prepare(PrepareRequest::new(
    647                     actor(BUYER, AuthorRole::Buyer),
    648                     mutation,
    649                 ))
    650             })
    651             .collect::<Result<Vec<_>, _>>()
    652             .expect("plans");
    653 
    654         assert_eq!(
    655             plans
    656                 .iter()
    657                 .map(|plan| plan.workflow().kind())
    658                 .collect::<Vec<_>>(),
    659             [
    660                 TradeMutationKindV1::Proposal,
    661                 TradeMutationKindV1::RevisionProposal,
    662                 TradeMutationKindV1::Decision,
    663                 TradeMutationKindV1::RevisionDecision,
    664                 TradeMutationKindV1::Cancellation,
    665             ]
    666         );
    667         assert!(
    668             plans
    669                 .iter()
    670                 .all(|plan| plan.workflow().trade_id() == plans[0].workflow().trade_id())
    671         );
    672         assert_eq!(
    673             plans[0].workflow().required_actions()[0],
    674             WorkflowAction::VerifyPrivateTerms
    675         );
    676         assert_eq!(
    677             plans[0]
    678                 .workflow()
    679                 .private_terms()
    680                 .expect("private terms")
    681                 .artifact_id(),
    682             "artifact-1"
    683         );
    684         for plan in plans {
    685             assert_eq!(
    686                 plan.authored_event()
    687                     .body()
    688                     .contract()
    689                     .contract_id()
    690                     .as_str(),
    691                 plan.workflow().kind().contract_id()
    692             );
    693             assert_eq!(
    694                 plan.authored_event().body().kind(),
    695                 plan.workflow().kind().nostr_kind()
    696             );
    697         }
    698     }
    699 
    700     #[test]
    701     fn prepare_rejects_wrong_identity_role_and_invalid_protocol_once() {
    702         let mutation = all_commands().remove(0);
    703         let wrong_role = prepare(PrepareRequest::new(
    704             actor(BUYER, AuthorRole::Seller),
    705             mutation.clone(),
    706         ))
    707         .expect_err("wrong role");
    708         assert_eq!(wrong_role.kind(), PrepareErrorKind::UnauthorizedActor);
    709         assert!(std::error::Error::source(&wrong_role).is_none());
    710 
    711         let wrong_identity = prepare(PrepareRequest::new(
    712             actor(SELLER, AuthorRole::Buyer),
    713             mutation,
    714         ))
    715         .expect_err("wrong identity");
    716         assert_eq!(wrong_identity.kind(), PrepareErrorKind::UnauthorizedActor);
    717 
    718         let mut invalid = all_commands().remove(0);
    719         invalid.contract_id = "radroots.trade.cancellation.v1".into();
    720         let canonical = prepare(PrepareRequest::new(
    721             actor(BUYER, AuthorRole::Buyer),
    722             invalid,
    723         ))
    724         .expect_err("invalid contract");
    725         assert_eq!(canonical.kind(), PrepareErrorKind::CanonicalMutation);
    726         assert!(std::error::Error::source(&canonical).is_some());
    727         assert!(!format!("{canonical:?}").contains("artifact-1"));
    728 
    729         let mut seller_authored = all_commands().remove(0);
    730         seller_authored.mutation_id = None;
    731         seller_authored.author_pubkey = pubkey(SELLER);
    732         seller_authored.counterparty_pubkey = pubkey(BUYER);
    733         let seller_plan = prepare(PrepareRequest::new(
    734             actor(SELLER, AuthorRole::Seller),
    735             seller_authored,
    736         ))
    737         .expect("seller-authored command");
    738         assert_eq!(
    739             seller_plan.workflow().trade_id(),
    740             &TradeId::parse("11".repeat(16)).unwrap()
    741         );
    742 
    743         let outsider = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
    744         let mut unauthorized = all_commands().remove(0);
    745         unauthorized.mutation_id = None;
    746         unauthorized.author_pubkey = pubkey(outsider);
    747         assert_eq!(
    748             prepare(PrepareRequest::new(
    749                 actor(outsider, AuthorRole::Any),
    750                 unauthorized,
    751             ))
    752             .expect_err("author must be a governed party")
    753             .kind(),
    754             PrepareErrorKind::UnauthorizedActor
    755         );
    756     }
    757 
    758     #[test]
    759     fn query_projection_returns_the_lower_projection_and_conflict_evidence_types() {
    760         let trade_id = TradeId::parse("22".repeat(16)).expect("trade id");
    761         let projection = project(ReductionInput::new(trade_id));
    762         assert_eq!(projection.trade_id(), &trade_id);
    763         assert!(projection.candidate_heads().is_empty());
    764         assert!(!projection.projection_digest().is_empty());
    765     }
    766 
    767     #[test]
    768     fn evidence_adapters_parse_plan_and_verify_the_authored_corpus_event() {
    769         let expected = rhi_attestation_fixture();
    770         let content = expected["content"].as_str().expect("content");
    771         let report = parse_rhi_evidence_report(content.as_bytes()).expect("report");
    772         assert_eq!(
    773             report.outcome(),
    774             RadrootsTradeEvidenceOutcomeV1::Indeterminate
    775         );
    776 
    777         let plan = prepare_rhi_evidence_attestation(&report, 1_784_347_200).expect("plan");
    778         assert_eq!(plan.body().kind(), 3_441);
    779         assert_eq!(
    780             plan.expected_event_id().to_hex(),
    781             expected["event_id"].as_str().expect("event id")
    782         );
    783 
    784         let raw: serde_json::Value =
    785             serde_json::from_str(expected["raw_json"].as_str().expect("raw event"))
    786                 .expect("raw event JSON");
    787         let event = radroots_event::envelope::EventEnvelope::new(
    788             radroots_event::envelope::EventEnvelopeParts {
    789                 id: raw["id"].as_str().expect("id").to_owned(),
    790                 author: raw["pubkey"].as_str().expect("pubkey").to_owned(),
    791                 created_at: raw["created_at"].as_u64().expect("created_at"),
    792                 kind: u32::try_from(raw["kind"].as_u64().expect("kind")).expect("u32 kind"),
    793                 tags: serde_json::from_value(raw["tags"].clone()).expect("tags"),
    794                 content: raw["content"].as_str().expect("content").to_owned(),
    795                 sig: raw["sig"].as_str().expect("signature").to_owned(),
    796             },
    797         )
    798         .expect("event");
    799         let attestation = validate_rhi_evidence_attestation(event).expect("attestation");
    800         assert_eq!(attestation.trade_generation().get(), 7);
    801     }
    802 
    803     #[test]
    804     fn evidence_validation_error_is_stable_and_value_free() {
    805         let expected = rhi_attestation_fixture();
    806         let raw: serde_json::Value =
    807             serde_json::from_str(expected["raw_json"].as_str().expect("raw event"))
    808                 .expect("raw event JSON");
    809         let event = radroots_event::envelope::EventEnvelope::new(
    810             radroots_event::envelope::EventEnvelopeParts {
    811                 id: raw["id"].as_str().expect("id").to_owned(),
    812                 author: raw["pubkey"].as_str().expect("pubkey").to_owned(),
    813                 created_at: raw["created_at"].as_u64().expect("created_at"),
    814                 kind: u32::try_from(raw["kind"].as_u64().expect("kind")).expect("u32 kind"),
    815                 tags: serde_json::from_value(raw["tags"].clone()).expect("tags"),
    816                 content: "private-tamper".to_owned(),
    817                 sig: raw["sig"].as_str().expect("signature").to_owned(),
    818             },
    819         )
    820         .expect("event");
    821         let error = validate_rhi_evidence_attestation(event).expect_err("signature mismatch");
    822         assert_eq!(error, EvidenceAttestationValidationError::Signature);
    823         assert!(std::error::Error::source(&error).is_none());
    824         assert!(!error.to_string().contains("private-tamper"));
    825         assert!(!format!("{error:?}").contains("private-tamper"));
    826     }
    827 
    828     #[cfg(all(feature = "sync", feature = "memory", feature = "local-signing"))]
    829     mod operations {
    830         use std::sync::{
    831             Arc,
    832             atomic::{AtomicU8, Ordering},
    833         };
    834 
    835         use radroots_nostr::key::SecretKey;
    836         use radroots_signing::request::CancellationPolicy;
    837         use radroots_storage::{
    838             Outbox,
    839             event::{EventQuery, EventQueryBounds, SourceGeneration},
    840             journal::IdempotencyKey,
    841             memory::MemoryStorage,
    842             private_artifact::{
    843                 ArtifactCommitment, ArtifactKind, ArtifactSchemaId, DurableSecretReference,
    844                 PrivateArtifactId, PrivateArtifactMetadata, PrivateArtifactStore, RetentionPolicy,
    845             },
    846         };
    847         use radroots_sync::{
    848             Engine,
    849             policy::{Clock, DeadlinePolicy, Error, IdSource, OperationKind, SyncId, SyncStorage},
    850         };
    851         use radroots_transport::{
    852             DeliveryReceipt, DeliveryRequest, Error as TransportError, EventSink, SinkFailure,
    853             SinkStatus, Target, TargetSet, TransportId,
    854             capability::{Availability, Maturity, SinkCapabilities},
    855             outcome::Retryability,
    856             policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy},
    857         };
    858 
    859         use super::*;
    860         use crate::{ClientBuilder, transport::Profile};
    861 
    862         const BUYER_SECRET: &str =
    863             "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5";
    864 
    865         struct HostClock;
    866         struct SequenceIds(AtomicU8);
    867         struct NoopSink;
    868 
    869         impl Clock for HostClock {
    870             fn now_unix_ms(&self) -> Result<u64, Error> {
    871                 std::time::SystemTime::now()
    872                     .duration_since(std::time::UNIX_EPOCH)
    873                     .ok()
    874                     .and_then(|duration| u64::try_from(duration.as_millis()).ok())
    875                     .filter(|value| *value != 0)
    876                     .ok_or(Error::ClockUnavailable)
    877             }
    878         }
    879         impl IdSource for SequenceIds {
    880             fn next_id(&self, _operation: OperationKind) -> Result<SyncId, Error> {
    881                 SyncId::new([self.0.fetch_add(1, Ordering::Relaxed); 16])
    882             }
    883         }
    884         impl EventSink for NoopSink {
    885             fn status(
    886                 &self,
    887             ) -> radroots_transport::BoxFuture<'_, Result<SinkStatus, TransportError>> {
    888                 Box::pin(async {
    889                     Ok(SinkStatus::new(
    890                         TransportId::NOSTR,
    891                         true,
    892                         Maturity::Stable,
    893                         Availability::Available,
    894                         SinkCapabilities::DELIVER,
    895                         "ready",
    896                     ))
    897                 })
    898             }
    899             fn deliver(
    900                 &self,
    901                 request: DeliveryRequest,
    902             ) -> radroots_transport::BoxFuture<'_, Result<DeliveryReceipt, SinkFailure>>
    903             {
    904                 Box::pin(async move {
    905                     Err(SinkFailure::for_request(
    906                         &request,
    907                         "test_sink_unavailable",
    908                         Retryability::Terminal,
    909                         None,
    910                         None,
    911                         Vec::new(),
    912                     )
    913                     .expect("test sink failure"))
    914                 })
    915             }
    916         }
    917 
    918         #[tokio::test]
    919         async fn operations_cover_private_evidence_pagination_commit_replay_and_cancellation() {
    920             let storage = Arc::new(MemoryStorage::new(
    921                 SourceGeneration::new([6; 32]).expect("generation"),
    922             ));
    923             let artifact_id = PrivateArtifactId::new([7; 16]).expect("artifact id");
    924             let metadata = PrivateArtifactMetadata::new(
    925                 artifact_id,
    926                 ArtifactKind::parse("trade.private_terms").expect("kind"),
    927                 ArtifactSchemaId::parse("radroots.private.fulfillment.v1").expect("schema"),
    928                 ArtifactCommitment::new([0xee; 32]),
    929                 64,
    930                 DurableSecretReference::new("memory", "trade-artifact-1", 1).expect("reference"),
    931                 RetentionPolicy::indefinite(),
    932                 1_800_000_000_000,
    933             )
    934             .expect("metadata");
    935             PrivateArtifactStore::put_metadata(storage.as_ref(), metadata)
    936                 .await
    937                 .expect("store metadata");
    938 
    939             let signer = Arc::new(
    940                 radroots_nostr::signing::LocalSigner::new(
    941                     SecretKey::parse(BUYER_SECRET).expect("secret"),
    942                 )
    943                 .expect("signer"),
    944             );
    945             let capability: Arc<dyn SyncStorage> = storage.clone();
    946             let engine = Engine::builder(
    947                 capability,
    948                 Arc::new(HostClock),
    949                 Arc::new(SequenceIds(AtomicU8::new(1))),
    950                 DeadlinePolicy::new(30_000, 30_000, 30_000).expect("deadlines"),
    951             )
    952             .sink(Arc::new(NoopSink))
    953             .signer(signer)
    954             .build()
    955             .expect("engine");
    956             let client = ClientBuilder::new()
    957                 .storage(storage.clone())
    958                 .sync_engine(engine)
    959                 .build()
    960                 .expect("client");
    961             let operations = client.trade().expect("open").expect("trade operations");
    962             let plan = prepare(PrepareRequest::new(
    963                 actor(BUYER, AuthorRole::Buyer),
    964                 all_commands().remove(0),
    965             ))
    966             .expect("plan");
    967 
    968             let verified = operations
    969                 .verify_private_terms(&plan, artifact_id)
    970                 .await
    971                 .expect("private evidence");
    972             assert_eq!(verified.artifact_id(), artifact_id);
    973             let page = operations
    974                 .query_visible(EventQuery::all(EventQueryBounds::first(1).expect("bounds")))
    975                 .await
    976                 .expect("page");
    977             assert!(page.items().is_empty());
    978             assert!(page.next_cursor().is_none());
    979 
    980             let targets = TargetSet::new(vec![
    981                 Target::nostr_relay("wss://trade.example").expect("target"),
    982             ])
    983             .expect("targets");
    984             let satisfaction =
    985                 SatisfactionPolicy::new(SatisfactionClass::Delivered, TargetPolicy::all());
    986             let request = EnqueueRequest::new(
    987                 SyncId::new([11; 16]).expect("operation id"),
    988                 IdempotencyKey::parse("trade-proposal-a").expect("idempotency"),
    989                 plan,
    990                 Profile::delivery(targets.clone(), satisfaction.clone()).expect("profile"),
    991                 2_000_000_001_000,
    992                 CancellationPolicy::PreservePublishedRequest,
    993             );
    994             drop(operations.enqueue(request.clone()));
    995             assert_eq!(
    996                 Outbox::status(storage.as_ref())
    997                     .await
    998                     .expect("status")
    999                     .pending,
   1000                 0
   1001             );
   1002             let committed = operations.enqueue(request.clone()).await.expect("commit");
   1003             assert_eq!(committed.delivery_plan().intent().target_set(), &targets);
   1004             let replay = operations.enqueue(request).await.expect("resume replay");
   1005             assert_eq!(replay, committed);
   1006         }
   1007     }
   1008 }