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 }