commit 578e51ab61e163919088615681f490d62ff1a679
parent 1149f142c5453e0d73a510ad28cc0965b32836d1
Author: triesap <tyson@radroots.org>
Date: Tue, 30 Jun 2026 02:31:23 +0000
trade: add SDK adoption contract roots
Diffstat:
8 files changed, 1360 insertions(+), 12 deletions(-)
diff --git a/crates/events/src/kinds.rs b/crates/events/src/kinds.rs
@@ -99,11 +99,6 @@ pub const KIND_TRADE_FULFILLMENT_UPDATE: u32 = 3433;
pub const KIND_TRADE_RECEIPT: u32 = 3434;
pub const KIND_TRADE_VALIDATION_RECEIPT: u32 = 3440;
-pub const KIND_TRADE_LISTING_VALIDATE_REQ: u32 = KIND_TRADE_LISTING_VALIDATION_REQUEST;
-pub const KIND_TRADE_LISTING_VALIDATE_RES: u32 = KIND_TRADE_LISTING_VALIDATION_RESULT;
-pub const KIND_WORKER_TRADE_TRANSITION_PROOF_REQ: u32 = KIND_TRADE_TRANSITION_PROOF_REQUEST;
-pub const KIND_WORKER_TRADE_TRANSITION_PROOF_RES: u32 = KIND_TRADE_TRANSITION_PROOF_RESULT;
-
pub const LISTING_EVENT_KINDS: [u32; 2] = [KIND_LISTING, KIND_LISTING_DRAFT];
pub const ORDER_EVENT_KINDS: [u32; 5] = [
diff --git a/crates/trade/src/dvm.rs b/crates/trade/src/dvm.rs
@@ -0,0 +1,888 @@
+#![forbid(unsafe_code)]
+
+#[cfg(not(feature = "std"))]
+use alloc::{
+ string::{String, ToString},
+ vec,
+ vec::Vec,
+};
+#[cfg(feature = "std")]
+use std::{
+ string::{String, ToString},
+ vec::Vec,
+};
+
+use radroots_events::{
+ RadrootsNostrEvent,
+ ids::{
+ RadrootsEventId, RadrootsIdParseError, RadrootsInventoryBinId, RadrootsListingAddress,
+ RadrootsOrderId, RadrootsPublicKey,
+ },
+ kinds::{
+ KIND_JOB_FEEDBACK, KIND_TRADE_TRANSITION_PROOF_REQUEST, KIND_TRADE_TRANSITION_PROOF_RESULT,
+ },
+ tags::{TAG_A, TAG_E, TAG_I, TAG_P, TAG_STATUS},
+};
+use thiserror::Error;
+
+pub const RADROOTS_DVM_TAG_REQUEST: &str = "request";
+pub const RADROOTS_DVM_TAG_LISTING_EVENT: &str = "radroots:listing_event";
+pub const RADROOTS_DVM_TAG_ROOT_EVENT: &str = "radroots:root_event";
+pub const RADROOTS_DVM_TAG_TARGET_EVENT: &str = "radroots:target_event";
+pub const RADROOTS_DVM_TAG_VALIDATION_RECEIPT: &str = "radroots:validation_receipt";
+pub const RADROOTS_DVM_INPUT_TYPE_EVENT: &str = "event";
+
+#[derive(Debug, Error)]
+pub enum RadrootsTradeDvmError {
+ #[error("unsupported DVM event kind: expected {expected}, received {actual}")]
+ UnsupportedKind { expected: u32, actual: u32 },
+ #[error("missing required {tag} tag")]
+ MissingTag { tag: &'static str },
+ #[error("invalid {tag} tag value {value}: {source}")]
+ InvalidTag {
+ tag: &'static str,
+ value: String,
+ source: RadrootsIdParseError,
+ },
+ #[error("invalid DVM content JSON: {0}")]
+ InvalidContent(serde_json::Error),
+ #[error("failed to serialize DVM request event: {0}")]
+ SerializeRequestEvent(serde_json::Error),
+ #[error("invalid stringified DVM request event: {0}")]
+ InvalidRequestEvent(serde_json::Error),
+ #[error("stringified DVM request event has unsupported kind: {kind}")]
+ RequestEventKind { kind: u32 },
+ #[error("DVM content field {field} does not match required tags")]
+ ContentMismatch { field: &'static str },
+ #[error("DVM request event id does not match the result e tag")]
+ RequestEventIdMismatch,
+ #[error("invalid proof mode: {value}")]
+ InvalidProofMode { value: String },
+ #[error("invalid DVM input role: {value}")]
+ InvalidInputRole { value: String },
+ #[error("invalid hash in {field}")]
+ InvalidHash { field: &'static str },
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RadrootsTradeProofMode {
+ None,
+ Core,
+ Compressed,
+ Groth16,
+ Plonk,
+}
+
+impl RadrootsTradeProofMode {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::None => "none",
+ Self::Core => "core",
+ Self::Compressed => "compressed",
+ Self::Groth16 => "groth16",
+ Self::Plonk => "plonk",
+ }
+ }
+
+ pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsTradeDvmError> {
+ match value.as_ref() {
+ "none" => Ok(Self::None),
+ "core" => Ok(Self::Core),
+ "compressed" => Ok(Self::Compressed),
+ "groth16" => Ok(Self::Groth16),
+ "plonk" => Ok(Self::Plonk),
+ value => Err(RadrootsTradeDvmError::InvalidProofMode {
+ value: value.to_string(),
+ }),
+ }
+ }
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RadrootsTradeDvmInputRole {
+ Listing,
+ OrderRequest,
+ OrderDecision,
+}
+
+impl RadrootsTradeDvmInputRole {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Listing => "radroots:listing_event",
+ Self::OrderRequest => "radroots:order_request_event",
+ Self::OrderDecision => "radroots:order_decision_event",
+ }
+ }
+
+ pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsTradeDvmError> {
+ match value.as_ref() {
+ "radroots:listing_event" => Ok(Self::Listing),
+ "radroots:order_request_event" => Ok(Self::OrderRequest),
+ "radroots:order_decision_event" => Ok(Self::OrderDecision),
+ value => Err(RadrootsTradeDvmError::InvalidInputRole {
+ value: value.to_string(),
+ }),
+ }
+ }
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTradeDvmInputTag {
+ pub event_id: RadrootsEventId,
+ pub role: RadrootsTradeDvmInputRole,
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RadrootsTradeCanonicalEventEvidenceRole {
+ Buyer,
+ Seller,
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RadrootsTradeCanonicalEventWorkflowPosition {
+ Listing,
+ OrderRequest,
+ OrderDecision,
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTradeCanonicalEventEvidenceDto {
+ pub event_id: RadrootsEventId,
+ pub signer_pubkey: RadrootsPublicKey,
+ pub kind: u32,
+ pub canonical_event_hash: String,
+ pub signature_hash: String,
+ pub preverified_signature: bool,
+ pub role: RadrootsTradeCanonicalEventEvidenceRole,
+ pub workflow_position: RadrootsTradeCanonicalEventWorkflowPosition,
+ pub content_hash: String,
+ pub tags_hash: String,
+ pub ordering_key: String,
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTradeInventoryBinWitnessDto {
+ pub bin_id: RadrootsInventoryBinId,
+ pub listing_capacity: u64,
+ pub previous_reserved: u64,
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTradeOrderItemWitnessDto {
+ pub bin_id: RadrootsInventoryBinId,
+ pub bin_count: u32,
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTradeOrderRequestWitnessDto {
+ pub order_id: RadrootsOrderId,
+ pub listing_addr: RadrootsListingAddress,
+ pub buyer_pubkey: RadrootsPublicKey,
+ pub seller_pubkey: RadrootsPublicKey,
+ pub items: Vec<RadrootsTradeOrderItemWitnessDto>,
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsTradeOrderDecisionWitnessDto {
+ Accepted {
+ inventory_commitments: Vec<RadrootsTradeInventoryCommitmentWitnessDto>,
+ },
+ Declined {
+ reason: String,
+ },
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTradeInventoryCommitmentWitnessDto {
+ pub bin_id: RadrootsInventoryBinId,
+ pub bin_count: u32,
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTradeOrderDecisionEventWitnessDto {
+ pub order_id: RadrootsOrderId,
+ pub listing_addr: RadrootsListingAddress,
+ pub buyer_pubkey: RadrootsPublicKey,
+ pub seller_pubkey: RadrootsPublicKey,
+ pub decision: RadrootsTradeOrderDecisionWitnessDto,
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTradeTransitionProofRequestV1 {
+ pub witness_version: u32,
+ pub proof_target: String,
+ pub listing_event_id: RadrootsEventId,
+ pub request_event_id: RadrootsEventId,
+ pub decision_event_id: RadrootsEventId,
+ pub event_evidence: Vec<RadrootsTradeCanonicalEventEvidenceDto>,
+ pub request: RadrootsTradeOrderRequestWitnessDto,
+ pub decision: RadrootsTradeOrderDecisionEventWitnessDto,
+ pub inventory_bins: Vec<RadrootsTradeInventoryBinWitnessDto>,
+ pub inventory_sequence: u128,
+ pub previous_state_root: Option<String>,
+ pub proof_mode: RadrootsTradeProofMode,
+ pub reducer_program_hash: String,
+ pub radroots_protocol_version: String,
+ pub sp1_program_hash: Option<String>,
+ pub sp1_verifying_key_hash: Option<String>,
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTradeTransitionProofResultV1 {
+ pub version: u32,
+ pub listing_event_id: RadrootsEventId,
+ pub root_event_id: RadrootsEventId,
+ pub target_event_id: RadrootsEventId,
+ pub validation_receipt_event_id: Option<RadrootsEventId>,
+ pub proof_mode: RadrootsTradeProofMode,
+ pub proof_reference: Option<String>,
+ pub inline_proof_base64: Option<String>,
+ pub program_hash: Option<String>,
+ pub verifying_key_hash: Option<String>,
+ pub public_values_hash: String,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTradeTransitionProofRequestTags {
+ pub worker_pubkey: RadrootsPublicKey,
+ pub listing_addr: RadrootsListingAddress,
+ pub inputs: Vec<RadrootsTradeDvmInputTag>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTradeTransitionProofRequestEnvelope {
+ pub tags: RadrootsTradeTransitionProofRequestTags,
+ pub content: RadrootsTradeTransitionProofRequestV1,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTradeTransitionProofResultBinding {
+ pub listing_event_id: RadrootsEventId,
+ pub root_event_id: RadrootsEventId,
+ pub target_event_id: RadrootsEventId,
+ pub validation_receipt_event_id: Option<RadrootsEventId>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTradeTransitionProofResultTags {
+ pub request_event: RadrootsNostrEvent,
+ pub request_event_id: RadrootsEventId,
+ pub customer_pubkey: RadrootsPublicKey,
+ pub inputs: Vec<RadrootsTradeDvmInputTag>,
+ pub binding: RadrootsTradeTransitionProofResultBinding,
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RadrootsTradeDvmFeedbackStatus {
+ PaymentRequired,
+ Processing,
+ Error,
+ Success,
+ Partial,
+}
+
+impl RadrootsTradeDvmFeedbackStatus {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::PaymentRequired => "payment-required",
+ Self::Processing => "processing",
+ Self::Error => "error",
+ Self::Success => "success",
+ Self::Partial => "partial",
+ }
+ }
+
+ pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsTradeDvmError> {
+ match value.as_ref() {
+ "payment-required" => Ok(Self::PaymentRequired),
+ "processing" => Ok(Self::Processing),
+ "error" => Ok(Self::Error),
+ "success" => Ok(Self::Success),
+ "partial" => Ok(Self::Partial),
+ value => Err(RadrootsTradeDvmError::InvalidTag {
+ tag: TAG_STATUS,
+ value: value.to_string(),
+ source: RadrootsIdParseError::InvalidFormat,
+ }),
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTradeDvmFeedbackTags {
+ pub status: RadrootsTradeDvmFeedbackStatus,
+ pub request_event_id: RadrootsEventId,
+ pub customer_pubkey: RadrootsPublicKey,
+}
+
+pub fn build_transition_proof_request_tags(
+ worker_pubkey: &RadrootsPublicKey,
+ request: &RadrootsTradeTransitionProofRequestV1,
+) -> Vec<Vec<String>> {
+ vec![
+ vec![TAG_P.to_string(), worker_pubkey.as_str().to_string()],
+ vec![
+ TAG_A.to_string(),
+ request.request.listing_addr.as_str().to_string(),
+ ],
+ input_event_tag(
+ RadrootsTradeDvmInputRole::Listing,
+ &request.listing_event_id,
+ ),
+ input_event_tag(
+ RadrootsTradeDvmInputRole::OrderRequest,
+ &request.request_event_id,
+ ),
+ input_event_tag(
+ RadrootsTradeDvmInputRole::OrderDecision,
+ &request.decision_event_id,
+ ),
+ ]
+}
+
+pub fn parse_transition_proof_request_event(
+ event: &RadrootsNostrEvent,
+) -> Result<RadrootsTradeTransitionProofRequestEnvelope, RadrootsTradeDvmError> {
+ if event.kind != KIND_TRADE_TRANSITION_PROOF_REQUEST {
+ return Err(RadrootsTradeDvmError::UnsupportedKind {
+ expected: KIND_TRADE_TRANSITION_PROOF_REQUEST,
+ actual: event.kind,
+ });
+ }
+ let tags = parse_transition_proof_request_tags(&event.tags)?;
+ let content: RadrootsTradeTransitionProofRequestV1 =
+ serde_json::from_str(&event.content).map_err(RadrootsTradeDvmError::InvalidContent)?;
+ validate_transition_proof_request_binding(&tags, &content)?;
+ validate_transition_proof_request_content(&content)?;
+ Ok(RadrootsTradeTransitionProofRequestEnvelope { tags, content })
+}
+
+pub fn parse_transition_proof_request_tags(
+ tags: &[Vec<String>],
+) -> Result<RadrootsTradeTransitionProofRequestTags, RadrootsTradeDvmError> {
+ let worker_pubkey = parse_pubkey_tag(TAG_P, required_tag_value(tags, TAG_P)?)?;
+ let listing_addr = parse_listing_addr_tag(TAG_A, required_tag_value(tags, TAG_A)?)?;
+ let inputs = parse_input_tags(tags)?;
+ if inputs.is_empty() {
+ return Err(RadrootsTradeDvmError::MissingTag { tag: TAG_I });
+ }
+ Ok(RadrootsTradeTransitionProofRequestTags {
+ worker_pubkey,
+ listing_addr,
+ inputs,
+ })
+}
+
+pub fn build_transition_proof_result_tags(
+ request_event: &RadrootsNostrEvent,
+ customer_pubkey: &RadrootsPublicKey,
+ inputs: &[RadrootsTradeDvmInputTag],
+ binding: &RadrootsTradeTransitionProofResultBinding,
+) -> Result<Vec<Vec<String>>, RadrootsTradeDvmError> {
+ let request_event_id = RadrootsEventId::parse(request_event.id.as_str()).map_err(|source| {
+ RadrootsTradeDvmError::InvalidTag {
+ tag: TAG_E,
+ value: request_event.id.clone(),
+ source,
+ }
+ })?;
+ let request_json = serde_json::to_string(request_event)
+ .map_err(RadrootsTradeDvmError::SerializeRequestEvent)?;
+ let mut tags = vec![
+ vec![RADROOTS_DVM_TAG_REQUEST.to_string(), request_json],
+ vec![TAG_E.to_string(), request_event_id.as_str().to_string()],
+ vec![TAG_P.to_string(), customer_pubkey.as_str().to_string()],
+ vec![
+ RADROOTS_DVM_TAG_LISTING_EVENT.to_string(),
+ binding.listing_event_id.as_str().to_string(),
+ ],
+ vec![
+ RADROOTS_DVM_TAG_ROOT_EVENT.to_string(),
+ binding.root_event_id.as_str().to_string(),
+ ],
+ vec![
+ RADROOTS_DVM_TAG_TARGET_EVENT.to_string(),
+ binding.target_event_id.as_str().to_string(),
+ ],
+ ];
+ if let Some(receipt_event_id) = binding.validation_receipt_event_id.as_ref() {
+ tags.push(vec![
+ RADROOTS_DVM_TAG_VALIDATION_RECEIPT.to_string(),
+ receipt_event_id.as_str().to_string(),
+ ]);
+ }
+ for input in inputs {
+ tags.push(input_event_tag(input.role, &input.event_id));
+ }
+ Ok(tags)
+}
+
+pub fn parse_transition_proof_result_tags(
+ kind: u32,
+ tags: &[Vec<String>],
+) -> Result<RadrootsTradeTransitionProofResultTags, RadrootsTradeDvmError> {
+ if kind != KIND_TRADE_TRANSITION_PROOF_RESULT {
+ return Err(RadrootsTradeDvmError::UnsupportedKind {
+ expected: KIND_TRADE_TRANSITION_PROOF_RESULT,
+ actual: kind,
+ });
+ }
+ let request_event_json = required_tag_value(tags, RADROOTS_DVM_TAG_REQUEST)?;
+ let request_event: RadrootsNostrEvent = serde_json::from_str(request_event_json)
+ .map_err(RadrootsTradeDvmError::InvalidRequestEvent)?;
+ if request_event.kind != KIND_TRADE_TRANSITION_PROOF_REQUEST {
+ return Err(RadrootsTradeDvmError::RequestEventKind {
+ kind: request_event.kind,
+ });
+ }
+ let request_event_id = parse_event_id_tag(TAG_E, required_tag_value(tags, TAG_E)?)?;
+ if request_event.id != request_event_id.as_str() {
+ return Err(RadrootsTradeDvmError::RequestEventIdMismatch);
+ }
+ let customer_pubkey = parse_pubkey_tag(TAG_P, required_tag_value(tags, TAG_P)?)?;
+ let binding = RadrootsTradeTransitionProofResultBinding {
+ listing_event_id: parse_event_id_tag(
+ RADROOTS_DVM_TAG_LISTING_EVENT,
+ required_tag_value(tags, RADROOTS_DVM_TAG_LISTING_EVENT)?,
+ )?,
+ root_event_id: parse_event_id_tag(
+ RADROOTS_DVM_TAG_ROOT_EVENT,
+ required_tag_value(tags, RADROOTS_DVM_TAG_ROOT_EVENT)?,
+ )?,
+ target_event_id: parse_event_id_tag(
+ RADROOTS_DVM_TAG_TARGET_EVENT,
+ required_tag_value(tags, RADROOTS_DVM_TAG_TARGET_EVENT)?,
+ )?,
+ validation_receipt_event_id: optional_event_id_tag(
+ RADROOTS_DVM_TAG_VALIDATION_RECEIPT,
+ tag_value(tags, RADROOTS_DVM_TAG_VALIDATION_RECEIPT),
+ )?,
+ };
+ Ok(RadrootsTradeTransitionProofResultTags {
+ request_event,
+ request_event_id,
+ customer_pubkey,
+ inputs: parse_input_tags(tags)?,
+ binding,
+ })
+}
+
+pub fn build_job_feedback_tags(
+ status: RadrootsTradeDvmFeedbackStatus,
+ request_event_id: &RadrootsEventId,
+ customer_pubkey: &RadrootsPublicKey,
+) -> Vec<Vec<String>> {
+ vec![
+ vec![TAG_STATUS.to_string(), status.as_str().to_string()],
+ vec![TAG_E.to_string(), request_event_id.as_str().to_string()],
+ vec![TAG_P.to_string(), customer_pubkey.as_str().to_string()],
+ ]
+}
+
+pub fn parse_job_feedback_tags(
+ kind: u32,
+ tags: &[Vec<String>],
+) -> Result<RadrootsTradeDvmFeedbackTags, RadrootsTradeDvmError> {
+ if kind != KIND_JOB_FEEDBACK {
+ return Err(RadrootsTradeDvmError::UnsupportedKind {
+ expected: KIND_JOB_FEEDBACK,
+ actual: kind,
+ });
+ }
+ Ok(RadrootsTradeDvmFeedbackTags {
+ status: RadrootsTradeDvmFeedbackStatus::parse(required_tag_value(tags, TAG_STATUS)?)?,
+ request_event_id: parse_event_id_tag(TAG_E, required_tag_value(tags, TAG_E)?)?,
+ customer_pubkey: parse_pubkey_tag(TAG_P, required_tag_value(tags, TAG_P)?)?,
+ })
+}
+
+fn validate_transition_proof_request_binding(
+ tags: &RadrootsTradeTransitionProofRequestTags,
+ content: &RadrootsTradeTransitionProofRequestV1,
+) -> Result<(), RadrootsTradeDvmError> {
+ if tags.listing_addr != content.request.listing_addr {
+ return Err(RadrootsTradeDvmError::ContentMismatch {
+ field: "request.listing_addr",
+ });
+ }
+ require_input(
+ &tags.inputs,
+ RadrootsTradeDvmInputRole::Listing,
+ &content.listing_event_id,
+ )?;
+ require_input(
+ &tags.inputs,
+ RadrootsTradeDvmInputRole::OrderRequest,
+ &content.request_event_id,
+ )?;
+ require_input(
+ &tags.inputs,
+ RadrootsTradeDvmInputRole::OrderDecision,
+ &content.decision_event_id,
+ )?;
+ Ok(())
+}
+
+fn validate_transition_proof_request_content(
+ content: &RadrootsTradeTransitionProofRequestV1,
+) -> Result<(), RadrootsTradeDvmError> {
+ if content.request.order_id != content.decision.order_id {
+ return Err(RadrootsTradeDvmError::ContentMismatch { field: "order_id" });
+ }
+ if content.request.listing_addr != content.decision.listing_addr {
+ return Err(RadrootsTradeDvmError::ContentMismatch {
+ field: "listing_addr",
+ });
+ }
+ if content.request.buyer_pubkey != content.decision.buyer_pubkey {
+ return Err(RadrootsTradeDvmError::ContentMismatch {
+ field: "buyer_pubkey",
+ });
+ }
+ if content.request.seller_pubkey != content.decision.seller_pubkey {
+ return Err(RadrootsTradeDvmError::ContentMismatch {
+ field: "seller_pubkey",
+ });
+ }
+ validate_hash32(&content.reducer_program_hash, "reducer_program_hash")?;
+ if let Some(previous_state_root) = content.previous_state_root.as_ref() {
+ validate_hash32(previous_state_root, "previous_state_root")?;
+ }
+ if let Some(sp1_program_hash) = content.sp1_program_hash.as_ref() {
+ validate_hash32(sp1_program_hash, "sp1_program_hash")?;
+ }
+ if let Some(sp1_verifying_key_hash) = content.sp1_verifying_key_hash.as_ref() {
+ validate_hash32(sp1_verifying_key_hash, "sp1_verifying_key_hash")?;
+ }
+ Ok(())
+}
+
+fn require_input(
+ inputs: &[RadrootsTradeDvmInputTag],
+ role: RadrootsTradeDvmInputRole,
+ expected_event_id: &RadrootsEventId,
+) -> Result<(), RadrootsTradeDvmError> {
+ if inputs
+ .iter()
+ .any(|input| input.role == role && &input.event_id == expected_event_id)
+ {
+ return Ok(());
+ }
+ Err(RadrootsTradeDvmError::ContentMismatch {
+ field: role.as_str(),
+ })
+}
+
+fn parse_input_tags(
+ tags: &[Vec<String>],
+) -> Result<Vec<RadrootsTradeDvmInputTag>, RadrootsTradeDvmError> {
+ let mut inputs = Vec::new();
+ for tag in tags
+ .iter()
+ .filter(|tag| tag.first().map(String::as_str) == Some(TAG_I))
+ {
+ let Some(value) = tag.get(1) else {
+ return Err(RadrootsTradeDvmError::MissingTag { tag: TAG_I });
+ };
+ let input_type = tag.get(2).map(String::as_str).unwrap_or("");
+ if input_type != RADROOTS_DVM_INPUT_TYPE_EVENT {
+ return Err(RadrootsTradeDvmError::InvalidTag {
+ tag: TAG_I,
+ value: input_type.to_string(),
+ source: RadrootsIdParseError::InvalidFormat,
+ });
+ }
+ let Some(role_value) = tag.get(3) else {
+ return Err(RadrootsTradeDvmError::MissingTag { tag: TAG_I });
+ };
+ inputs.push(RadrootsTradeDvmInputTag {
+ event_id: parse_event_id_tag(TAG_I, value)?,
+ role: RadrootsTradeDvmInputRole::parse(role_value)?,
+ });
+ }
+ Ok(inputs)
+}
+
+fn input_event_tag(role: RadrootsTradeDvmInputRole, event_id: &RadrootsEventId) -> Vec<String> {
+ vec![
+ TAG_I.to_string(),
+ event_id.as_str().to_string(),
+ RADROOTS_DVM_INPUT_TYPE_EVENT.to_string(),
+ role.as_str().to_string(),
+ ]
+}
+
+fn required_tag_value<'a>(
+ tags: &'a [Vec<String>],
+ tag: &'static str,
+) -> Result<&'a str, RadrootsTradeDvmError> {
+ tag_value(tags, tag).ok_or(RadrootsTradeDvmError::MissingTag { tag })
+}
+
+fn tag_value<'a>(tags: &'a [Vec<String>], tag: &str) -> Option<&'a str> {
+ tags.iter()
+ .find(|candidate| candidate.first().map(String::as_str) == Some(tag))
+ .and_then(|candidate| candidate.get(1))
+ .map(String::as_str)
+}
+
+fn optional_event_id_tag(
+ tag: &'static str,
+ value: Option<&str>,
+) -> Result<Option<RadrootsEventId>, RadrootsTradeDvmError> {
+ value
+ .map(|value| parse_event_id_tag(tag, value))
+ .transpose()
+}
+
+fn parse_event_id_tag(
+ tag: &'static str,
+ value: &str,
+) -> Result<RadrootsEventId, RadrootsTradeDvmError> {
+ RadrootsEventId::parse(value).map_err(|source| RadrootsTradeDvmError::InvalidTag {
+ tag,
+ value: value.to_string(),
+ source,
+ })
+}
+
+fn parse_pubkey_tag(
+ tag: &'static str,
+ value: &str,
+) -> Result<RadrootsPublicKey, RadrootsTradeDvmError> {
+ RadrootsPublicKey::parse(value).map_err(|source| RadrootsTradeDvmError::InvalidTag {
+ tag,
+ value: value.to_string(),
+ source,
+ })
+}
+
+fn parse_listing_addr_tag(
+ tag: &'static str,
+ value: &str,
+) -> Result<RadrootsListingAddress, RadrootsTradeDvmError> {
+ RadrootsListingAddress::parse(value).map_err(|source| RadrootsTradeDvmError::InvalidTag {
+ tag,
+ value: value.to_string(),
+ source,
+ })
+}
+
+fn validate_hash32(value: &str, field: &'static str) -> Result<(), RadrootsTradeDvmError> {
+ if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
+ return Err(RadrootsTradeDvmError::InvalidHash { field });
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use radroots_events::kinds::KIND_LISTING;
+
+ const BUYER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+ const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+ const WORKER: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
+
+ fn event_id(raw: u8) -> RadrootsEventId {
+ RadrootsEventId::parse(format!("{raw:064x}")).expect("event id")
+ }
+
+ fn public_key(raw: &str) -> RadrootsPublicKey {
+ RadrootsPublicKey::parse(raw).expect("public key")
+ }
+
+ fn listing_addr() -> RadrootsListingAddress {
+ RadrootsListingAddress::parse(format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"))
+ .expect("listing address")
+ }
+
+ fn hash(raw: char) -> String {
+ raw.to_string().repeat(64)
+ }
+
+ fn request_content() -> RadrootsTradeTransitionProofRequestV1 {
+ RadrootsTradeTransitionProofRequestV1 {
+ witness_version: 1,
+ proof_target: "trade_transition".to_string(),
+ listing_event_id: event_id(1),
+ request_event_id: event_id(2),
+ decision_event_id: event_id(3),
+ event_evidence: Vec::new(),
+ request: RadrootsTradeOrderRequestWitnessDto {
+ order_id: RadrootsOrderId::parse("order-1").expect("order id"),
+ listing_addr: listing_addr(),
+ buyer_pubkey: public_key(BUYER),
+ seller_pubkey: public_key(SELLER),
+ items: vec![RadrootsTradeOrderItemWitnessDto {
+ bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"),
+ bin_count: 2,
+ }],
+ },
+ decision: RadrootsTradeOrderDecisionEventWitnessDto {
+ order_id: RadrootsOrderId::parse("order-1").expect("order id"),
+ listing_addr: listing_addr(),
+ buyer_pubkey: public_key(BUYER),
+ seller_pubkey: public_key(SELLER),
+ decision: RadrootsTradeOrderDecisionWitnessDto::Accepted {
+ inventory_commitments: vec![RadrootsTradeInventoryCommitmentWitnessDto {
+ bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"),
+ bin_count: 2,
+ }],
+ },
+ },
+ inventory_bins: vec![RadrootsTradeInventoryBinWitnessDto {
+ bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"),
+ listing_capacity: 10,
+ previous_reserved: 1,
+ }],
+ inventory_sequence: 7,
+ previous_state_root: Some(hash('b')),
+ proof_mode: RadrootsTradeProofMode::None,
+ reducer_program_hash: hash('a'),
+ radroots_protocol_version: "radroots-trade-v1".to_string(),
+ sp1_program_hash: None,
+ sp1_verifying_key_hash: None,
+ }
+ }
+
+ fn request_event(content: &RadrootsTradeTransitionProofRequestV1) -> RadrootsNostrEvent {
+ RadrootsNostrEvent {
+ id: event_id(10).into_string(),
+ author: BUYER.to_string(),
+ created_at: 1,
+ kind: KIND_TRADE_TRANSITION_PROOF_REQUEST,
+ tags: build_transition_proof_request_tags(&public_key(WORKER), content),
+ content: serde_json::to_string(content).expect("content"),
+ sig: "sig".to_string(),
+ }
+ }
+
+ #[test]
+ fn transition_proof_request_tags_and_content_parse_together() {
+ let content = request_content();
+ let envelope =
+ parse_transition_proof_request_event(&request_event(&content)).expect("request");
+
+ assert_eq!(envelope.tags.worker_pubkey, public_key(WORKER));
+ assert_eq!(envelope.tags.listing_addr, listing_addr());
+ assert_eq!(envelope.content.request_event_id, event_id(2));
+ assert_eq!(envelope.tags.inputs.len(), 3);
+ }
+
+ #[test]
+ fn transition_proof_result_tags_bind_stringified_request() {
+ let content = request_content();
+ let request_event = request_event(&content);
+ let request_tags =
+ parse_transition_proof_request_tags(&request_event.tags).expect("request tags");
+ let binding = RadrootsTradeTransitionProofResultBinding {
+ listing_event_id: content.listing_event_id.clone(),
+ root_event_id: content.request_event_id.clone(),
+ target_event_id: content.decision_event_id.clone(),
+ validation_receipt_event_id: Some(event_id(11)),
+ };
+ let tags = build_transition_proof_result_tags(
+ &request_event,
+ &public_key(BUYER),
+ &request_tags.inputs,
+ &binding,
+ )
+ .expect("result tags");
+
+ let parsed = parse_transition_proof_result_tags(KIND_TRADE_TRANSITION_PROOF_RESULT, &tags)
+ .expect("result tags");
+
+ assert_eq!(parsed.request_event_id, event_id(10));
+ assert_eq!(parsed.customer_pubkey, public_key(BUYER));
+ assert_eq!(parsed.inputs, request_tags.inputs);
+ assert_eq!(parsed.binding, binding);
+ }
+
+ #[test]
+ fn job_feedback_tags_parse_required_status_event_and_customer() {
+ let tags = build_job_feedback_tags(
+ RadrootsTradeDvmFeedbackStatus::Processing,
+ &event_id(10),
+ &public_key(BUYER),
+ );
+
+ let parsed = parse_job_feedback_tags(KIND_JOB_FEEDBACK, &tags).expect("feedback tags");
+
+ assert_eq!(parsed.status, RadrootsTradeDvmFeedbackStatus::Processing);
+ assert_eq!(parsed.request_event_id, event_id(10));
+ assert_eq!(parsed.customer_pubkey, public_key(BUYER));
+ }
+
+ #[test]
+ fn transition_proof_request_rejects_missing_input_tags() {
+ let content = request_content();
+ let mut event = request_event(&content);
+ event
+ .tags
+ .retain(|tag| tag.first().map(String::as_str) != Some(TAG_I));
+
+ assert!(matches!(
+ parse_transition_proof_request_event(&event),
+ Err(RadrootsTradeDvmError::MissingTag { tag: TAG_I })
+ ));
+ }
+}
diff --git a/crates/trade/src/identity.rs b/crates/trade/src/identity.rs
@@ -0,0 +1,135 @@
+#![forbid(unsafe_code)]
+
+use core::str::FromStr;
+
+use radroots_events::ids::{
+ RadrootsEventId, RadrootsIdParseError, RadrootsListingAddress, RadrootsOrderId,
+ RadrootsPublicKey,
+};
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(as = "string"))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(transparent))]
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct RadrootsTradeId(RadrootsOrderId);
+
+impl RadrootsTradeId {
+ pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsIdParseError> {
+ RadrootsOrderId::parse(value).map(Self)
+ }
+
+ pub fn as_order_id(&self) -> &RadrootsOrderId {
+ &self.0
+ }
+
+ pub fn into_order_id(self) -> RadrootsOrderId {
+ self.0
+ }
+
+ pub fn as_str(&self) -> &str {
+ self.0.as_str()
+ }
+}
+
+impl From<RadrootsOrderId> for RadrootsTradeId {
+ fn from(order_id: RadrootsOrderId) -> Self {
+ Self(order_id)
+ }
+}
+
+impl From<RadrootsTradeId> for RadrootsOrderId {
+ fn from(trade_id: RadrootsTradeId) -> Self {
+ trade_id.into_order_id()
+ }
+}
+
+impl AsRef<str> for RadrootsTradeId {
+ fn as_ref(&self) -> &str {
+ self.as_str()
+ }
+}
+
+impl FromStr for RadrootsTradeId {
+ type Err = RadrootsIdParseError;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ Self::parse(value)
+ }
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTradeLocator {
+ pub trade_id: RadrootsTradeId,
+ pub root_event_id: Option<RadrootsEventId>,
+ pub listing_addr: Option<RadrootsListingAddress>,
+ pub buyer_pubkey: Option<RadrootsPublicKey>,
+ pub seller_pubkey: Option<RadrootsPublicKey>,
+}
+
+impl RadrootsTradeLocator {
+ pub fn new(trade_id: impl Into<RadrootsTradeId>) -> Self {
+ Self {
+ trade_id: trade_id.into(),
+ root_event_id: None,
+ listing_addr: None,
+ buyer_pubkey: None,
+ seller_pubkey: None,
+ }
+ }
+
+ pub fn from_order_id(order_id: RadrootsOrderId) -> Self {
+ Self::new(order_id)
+ }
+
+ pub fn order_id(&self) -> &RadrootsOrderId {
+ self.trade_id.as_order_id()
+ }
+
+ pub fn with_root_event_id(mut self, root_event_id: RadrootsEventId) -> Self {
+ self.root_event_id = Some(root_event_id);
+ self
+ }
+
+ pub fn with_listing_addr(mut self, listing_addr: RadrootsListingAddress) -> Self {
+ self.listing_addr = Some(listing_addr);
+ self
+ }
+
+ pub fn with_buyer_pubkey(mut self, buyer_pubkey: RadrootsPublicKey) -> Self {
+ self.buyer_pubkey = Some(buyer_pubkey);
+ self
+ }
+
+ pub fn with_seller_pubkey(mut self, seller_pubkey: RadrootsPublicKey) -> Self {
+ self.seller_pubkey = Some(seller_pubkey);
+ self
+ }
+}
+
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTradeLocatorCandidate {
+ pub trade_id: RadrootsTradeId,
+ pub root_event_id: RadrootsEventId,
+ pub listing_addr: RadrootsListingAddress,
+ pub buyer_pubkey: RadrootsPublicKey,
+ pub seller_pubkey: RadrootsPublicKey,
+}
+
+impl RadrootsTradeLocatorCandidate {
+ pub fn locator(&self) -> RadrootsTradeLocator {
+ RadrootsTradeLocator {
+ trade_id: self.trade_id.clone(),
+ root_event_id: Some(self.root_event_id.clone()),
+ listing_addr: Some(self.listing_addr.clone()),
+ buyer_pubkey: Some(self.buyer_pubkey.clone()),
+ seller_pubkey: Some(self.seller_pubkey.clone()),
+ }
+ }
+}
diff --git a/crates/trade/src/lib.rs b/crates/trade/src/lib.rs
@@ -5,6 +5,9 @@ extern crate alloc;
#[cfg(feature = "dto-bindgen")]
pub mod dto;
+#[cfg(feature = "serde_json")]
+pub mod dvm;
+pub mod identity;
pub mod listing;
pub mod order;
pub mod prelude;
diff --git a/crates/trade/src/order.rs b/crates/trade/src/order.rs
@@ -34,6 +34,7 @@ use radroots_events_codec::order::{
use sha2::{Digest, Sha256};
use thiserror::Error;
+use crate::identity::{RadrootsTradeLocator, RadrootsTradeLocatorCandidate};
use crate::listing::{
RadrootsPublicListingAddress, RadrootsPublicListingAddressError, parse_public_listing_address,
};
@@ -270,6 +271,30 @@ pub struct RadrootsOrderProjectionQueryResult {
pub event_ids: Vec<RadrootsEventId>,
}
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsTradeLocatorProjectionResolution {
+ Missing {
+ locator: RadrootsTradeLocator,
+ },
+ Ambiguous {
+ locator: RadrootsTradeLocator,
+ candidates: Vec<RadrootsTradeLocatorCandidate>,
+ },
+ Projected {
+ locator: RadrootsTradeLocator,
+ projection: RadrootsOrderProjection,
+ },
+}
+
+#[cfg(feature = "event_store")]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsTradeLocatorProjectionQueryResult {
+ pub resolution: RadrootsTradeLocatorProjectionResolution,
+ pub event_count: usize,
+ pub limit_applied: u32,
+ pub event_ids: Vec<RadrootsEventId>,
+}
+
#[cfg(feature = "event_store")]
pub async fn order_events_for_order_id(
store: &RadrootsEventStore,
@@ -318,6 +343,17 @@ pub async fn order_projection_query_for_order_id(
}
#[cfg(feature = "event_store")]
+pub async fn order_projection_query_for_trade_locator(
+ store: &RadrootsEventStore,
+ locator: &RadrootsTradeLocator,
+ limit: u32,
+) -> Result<RadrootsTradeLocatorProjectionQueryResult, RadrootsOrderStoreQueryError> {
+ crate::projection::trade_projection_query_for_trade_locator(store, locator, limit)
+ .await
+ .map_err(Into::into)
+}
+
+#[cfg(feature = "event_store")]
fn stored_order_event_to_nostr_event(
stored_event: &RadrootsStoredEvent,
) -> Result<RadrootsNostrEvent, RadrootsOrderStoreQueryError> {
@@ -725,6 +761,21 @@ pub struct RadrootsGroupedOrderEventRecords {
}
#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) enum RadrootsTradeLocatorGroupedOrderEventRecordsResolution {
+ Missing {
+ locator: RadrootsTradeLocator,
+ },
+ Ambiguous {
+ locator: RadrootsTradeLocator,
+ candidates: Vec<RadrootsTradeLocatorCandidate>,
+ },
+ Matched {
+ locator: RadrootsTradeLocator,
+ records: RadrootsGroupedOrderEventRecords,
+ },
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsListingInventoryAccountingInputs<I, J, K, L, M, N> {
pub bins: I,
pub requests: J,
@@ -774,6 +825,13 @@ pub fn reduce_order_event_records<I>(
where
I: IntoIterator<Item = RadrootsOrderEventRecord>,
{
+ reduce_grouped_order_event_records(order_id, group_order_event_records(records))
+}
+
+fn group_order_event_records<I>(records: I) -> RadrootsGroupedOrderEventRecords
+where
+ I: IntoIterator<Item = RadrootsOrderEventRecord>,
+{
let mut seen_event_ids = Vec::new();
let mut grouped = RadrootsGroupedOrderEventRecords::default();
@@ -796,7 +854,153 @@ where
}
}
- reduce_grouped_order_event_records(order_id, grouped)
+ grouped
+}
+
+pub fn reduce_order_event_records_for_trade_locator<I>(
+ locator: &RadrootsTradeLocator,
+ records: I,
+) -> RadrootsTradeLocatorProjectionResolution
+where
+ I: IntoIterator<Item = RadrootsOrderEventRecord>,
+{
+ match grouped_order_event_records_for_trade_locator(locator, group_order_event_records(records))
+ {
+ RadrootsTradeLocatorGroupedOrderEventRecordsResolution::Missing { locator } => {
+ RadrootsTradeLocatorProjectionResolution::Missing { locator }
+ }
+ RadrootsTradeLocatorGroupedOrderEventRecordsResolution::Ambiguous {
+ locator,
+ candidates,
+ } => RadrootsTradeLocatorProjectionResolution::Ambiguous {
+ locator,
+ candidates,
+ },
+ RadrootsTradeLocatorGroupedOrderEventRecordsResolution::Matched { locator, records } => {
+ let projection = reduce_grouped_order_event_records(locator.order_id(), records);
+ RadrootsTradeLocatorProjectionResolution::Projected {
+ locator,
+ projection,
+ }
+ }
+ }
+}
+
+pub(crate) fn grouped_order_event_records_for_trade_locator(
+ locator: &RadrootsTradeLocator,
+ records: RadrootsGroupedOrderEventRecords,
+) -> RadrootsTradeLocatorGroupedOrderEventRecordsResolution {
+ let candidates = trade_locator_candidates(locator, &records);
+ match candidates.as_slice() {
+ [] => RadrootsTradeLocatorGroupedOrderEventRecordsResolution::Missing {
+ locator: locator.clone(),
+ },
+ [candidate] => RadrootsTradeLocatorGroupedOrderEventRecordsResolution::Matched {
+ locator: candidate.locator(),
+ records: filter_grouped_order_records_for_trade_candidate(records, candidate),
+ },
+ _ => RadrootsTradeLocatorGroupedOrderEventRecordsResolution::Ambiguous {
+ locator: locator.clone(),
+ candidates,
+ },
+ }
+}
+
+fn trade_locator_candidates(
+ locator: &RadrootsTradeLocator,
+ records: &RadrootsGroupedOrderEventRecords,
+) -> Vec<RadrootsTradeLocatorCandidate> {
+ let mut candidates = records
+ .requests
+ .iter()
+ .filter(|request| request_matches_trade_locator(locator, request))
+ .map(|request| RadrootsTradeLocatorCandidate {
+ trade_id: request.payload.order_id.clone().into(),
+ root_event_id: request.event_id.clone(),
+ listing_addr: request.payload.listing_addr.clone(),
+ buyer_pubkey: request.payload.buyer_pubkey.clone(),
+ seller_pubkey: request.payload.seller_pubkey.clone(),
+ })
+ .collect::<Vec<_>>();
+ candidates.sort_by(|left, right| {
+ left.root_event_id
+ .cmp(&right.root_event_id)
+ .then_with(|| left.trade_id.cmp(&right.trade_id))
+ .then_with(|| left.listing_addr.cmp(&right.listing_addr))
+ .then_with(|| left.buyer_pubkey.cmp(&right.buyer_pubkey))
+ .then_with(|| left.seller_pubkey.cmp(&right.seller_pubkey))
+ });
+ candidates.dedup_by(|left, right| left.root_event_id == right.root_event_id);
+ candidates
+}
+
+fn request_matches_trade_locator(
+ locator: &RadrootsTradeLocator,
+ request: &RadrootsOrderRequestRecord,
+) -> bool {
+ request.payload.order_id == *locator.order_id()
+ && optional_match(locator.root_event_id.as_ref(), &request.event_id)
+ && optional_match(locator.listing_addr.as_ref(), &request.payload.listing_addr)
+ && optional_match(locator.buyer_pubkey.as_ref(), &request.payload.buyer_pubkey)
+ && optional_match(
+ locator.seller_pubkey.as_ref(),
+ &request.payload.seller_pubkey,
+ )
+}
+
+fn optional_match<T>(expected: Option<&T>, actual: &T) -> bool
+where
+ T: PartialEq,
+{
+ expected.map(|expected| expected == actual).unwrap_or(true)
+}
+
+fn filter_grouped_order_records_for_trade_candidate(
+ records: RadrootsGroupedOrderEventRecords,
+ candidate: &RadrootsTradeLocatorCandidate,
+) -> RadrootsGroupedOrderEventRecords {
+ RadrootsGroupedOrderEventRecords {
+ requests: records
+ .requests
+ .into_iter()
+ .filter(|request| {
+ request.payload.order_id == *candidate.trade_id.as_order_id()
+ && request.event_id == candidate.root_event_id
+ })
+ .collect(),
+ decisions: records
+ .decisions
+ .into_iter()
+ .filter(|decision| {
+ decision.payload.order_id == *candidate.trade_id.as_order_id()
+ && decision.root_event_id == candidate.root_event_id
+ })
+ .collect(),
+ revision_proposals: records
+ .revision_proposals
+ .into_iter()
+ .filter(|proposal| {
+ proposal.payload.order_id == *candidate.trade_id.as_order_id()
+ && proposal.root_event_id == candidate.root_event_id
+ })
+ .collect(),
+ revision_decisions: records
+ .revision_decisions
+ .into_iter()
+ .filter(|decision| {
+ decision.payload.order_id == *candidate.trade_id.as_order_id()
+ && decision.root_event_id == candidate.root_event_id
+ })
+ .collect(),
+ cancellations: records
+ .cancellations
+ .into_iter()
+ .filter(|cancellation| {
+ cancellation.payload.order_id == *candidate.trade_id.as_order_id()
+ && cancellation.root_event_id == candidate.root_event_id
+ })
+ .collect(),
+ }
}
pub(crate) fn reduce_grouped_order_event_records(
@@ -2437,9 +2641,12 @@ mod tests {
RadrootsOrderDecisionRecord, RadrootsOrderEventRecord, RadrootsOrderIssue,
RadrootsOrderReductionInputs, RadrootsOrderRequestRecord,
RadrootsOrderRevisionDecisionRecord, RadrootsOrderRevisionProposalRecord,
- RadrootsTradeOrderWorkflowProjection, RadrootsTradeWorkflowState,
- reduce_listing_inventory_accounting, reduce_order_event_records, reduce_order_events,
+ RadrootsTradeLocatorProjectionResolution, RadrootsTradeOrderWorkflowProjection,
+ RadrootsTradeWorkflowState, reduce_listing_inventory_accounting,
+ reduce_order_event_records, reduce_order_event_records_for_trade_locator,
+ reduce_order_events,
};
+ use crate::identity::RadrootsTradeLocator;
use core::mem::discriminant;
use radroots_core::{
RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit,
@@ -2844,6 +3051,53 @@ mod tests {
assert_eq!(order_ids, vec![order_id("order-1"); 5]);
}
+ #[test]
+ fn trade_locator_reports_ambiguous_roots_for_duplicate_order_id() {
+ let mut second_request = request_record();
+ second_request.event_id = event_id(9);
+ let locator = RadrootsTradeLocator::from_order_id(order_id("order-1"));
+
+ let resolution = reduce_order_event_records_for_trade_locator(
+ &locator,
+ vec![
+ RadrootsOrderEventRecord::Request(request_record()),
+ RadrootsOrderEventRecord::Request(second_request),
+ ],
+ );
+
+ assert!(matches!(
+ resolution,
+ RadrootsTradeLocatorProjectionResolution::Ambiguous { ref candidates, .. }
+ if candidates.len() == 2
+ && candidates.iter().any(|candidate| candidate.root_event_id == event_id(1))
+ && candidates.iter().any(|candidate| candidate.root_event_id == event_id(9))
+ ));
+ }
+
+ #[test]
+ fn trade_locator_root_selects_exact_trade_projection() {
+ let mut second_request = request_record();
+ second_request.event_id = event_id(9);
+ let locator = RadrootsTradeLocator::from_order_id(order_id("order-1"))
+ .with_root_event_id(event_id(9));
+
+ let resolution = reduce_order_event_records_for_trade_locator(
+ &locator,
+ vec![
+ RadrootsOrderEventRecord::Request(request_record()),
+ RadrootsOrderEventRecord::Request(second_request),
+ RadrootsOrderEventRecord::Decision(accepted_decision()),
+ ],
+ );
+
+ assert!(matches!(
+ resolution,
+ RadrootsTradeLocatorProjectionResolution::Projected { projection, .. }
+ if projection.request_event_id == Some(event_id(9))
+ && projection.decision_event_id.is_none()
+ ));
+ }
+
#[cfg(feature = "serde_json")]
#[test]
fn order_event_records_decode_wire_events_and_decode_errors() {
diff --git a/crates/trade/src/prelude.rs b/crates/trade/src/prelude.rs
@@ -1,3 +1,6 @@
+#[cfg(feature = "serde_json")]
+pub use crate::dvm::*;
+pub use crate::identity::*;
pub use crate::listing::*;
pub use crate::order::*;
#[cfg(feature = "event_store")]
diff --git a/crates/trade/src/projection.rs b/crates/trade/src/projection.rs
@@ -21,15 +21,18 @@ use sqlx::Row;
use thiserror::Error;
use crate::{
+ identity::RadrootsTradeLocator,
listing::validation::{RadrootsTradeListing, validate_listing_event},
order::{
RadrootsGroupedOrderEventRecords, RadrootsOrderEventDecodeError, RadrootsOrderEventRecord,
- RadrootsOrderProjectionQueryResult, order_event_record_from_event,
+ RadrootsOrderProjectionQueryResult, RadrootsTradeLocatorProjectionQueryResult,
+ order_event_record_from_event,
},
validation_receipt::{RadrootsValidationReceiptError, validation_receipt_from_event},
workflow::{
RadrootsTradeWorkflowRecords, RadrootsTradeWorkflowState,
RadrootsTradeWorkflowValidationReceiptRecord, reduce_trade_workflow_records,
+ reduce_trade_workflow_records_for_trade_locator,
},
};
@@ -315,6 +318,27 @@ pub async fn trade_projection_query_for_order_id(
})
}
+pub async fn trade_projection_query_for_trade_locator(
+ store: &RadrootsEventStore,
+ locator: &RadrootsTradeLocator,
+ limit: u32,
+) -> Result<RadrootsTradeLocatorProjectionQueryResult, RadrootsTradeProjectionError> {
+ if limit == 0 || limit > RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX {
+ return Err(RadrootsTradeProjectionError::InvalidLimit {
+ max: RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX,
+ });
+ }
+ let inputs = trade_projection_inputs_for_order_id(store, locator.order_id(), limit).await?;
+ let resolution =
+ reduce_trade_workflow_records_for_trade_locator(locator, inputs.workflow_records);
+ Ok(RadrootsTradeLocatorProjectionQueryResult {
+ resolution,
+ event_count: inputs.event_ids.len(),
+ limit_applied: limit,
+ event_ids: inputs.event_ids,
+ })
+}
+
async fn upsert_listing_projection(
store: &RadrootsEventStore,
stored_event: &RadrootsStoredEvent,
diff --git a/crates/trade/src/workflow.rs b/crates/trade/src/workflow.rs
@@ -7,9 +7,14 @@ use alloc::{
use radroots_events::ids::{RadrootsEventId, RadrootsOrderId};
use radroots_events::order::RadrootsOrderInventoryCommitment;
-use crate::order::{
- RadrootsGroupedOrderEventRecords, RadrootsOrderIssue, RadrootsOrderProjection,
- reduce_grouped_order_event_records,
+use crate::{
+ identity::RadrootsTradeLocator,
+ order::{
+ RadrootsGroupedOrderEventRecords, RadrootsOrderIssue, RadrootsOrderProjection,
+ RadrootsTradeLocatorGroupedOrderEventRecordsResolution,
+ RadrootsTradeLocatorProjectionResolution, grouped_order_event_records_for_trade_locator,
+ reduce_grouped_order_event_records,
+ },
};
#[cfg(feature = "serde_json")]
@@ -111,6 +116,47 @@ pub fn reduce_trade_workflow_records(
projection
}
+pub fn reduce_trade_workflow_records_for_trade_locator(
+ locator: &RadrootsTradeLocator,
+ mut records: RadrootsTradeWorkflowRecords,
+) -> RadrootsTradeLocatorProjectionResolution {
+ let order_events = core::mem::take(&mut records.order_events);
+ match grouped_order_event_records_for_trade_locator(locator, order_events) {
+ RadrootsTradeLocatorGroupedOrderEventRecordsResolution::Missing { locator } => {
+ RadrootsTradeLocatorProjectionResolution::Missing { locator }
+ }
+ RadrootsTradeLocatorGroupedOrderEventRecordsResolution::Ambiguous {
+ locator,
+ candidates,
+ } => RadrootsTradeLocatorProjectionResolution::Ambiguous {
+ locator,
+ candidates,
+ },
+ RadrootsTradeLocatorGroupedOrderEventRecordsResolution::Matched {
+ locator,
+ records: order_events,
+ } => {
+ #[cfg(feature = "serde_json")]
+ {
+ let root_event_id = locator
+ .root_event_id
+ .as_ref()
+ .map(RadrootsEventId::as_str)
+ .unwrap_or("");
+ records
+ .validation_receipts
+ .retain(|receipt| receipt.tags.root_event_id == root_event_id);
+ }
+ records.order_events = order_events;
+ let projection = reduce_trade_workflow_records(locator.order_id(), records);
+ RadrootsTradeLocatorProjectionResolution::Projected {
+ locator,
+ projection,
+ }
+ }
+ }
+}
+
#[cfg(feature = "serde_json")]
fn apply_validation_receipts(
order_id: &RadrootsOrderId,