lib

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

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:
Mcrates/events/src/kinds.rs | 5-----
Acrates/trade/src/dvm.rs | 888+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/trade/src/identity.rs | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/trade/src/lib.rs | 3+++
Mcrates/trade/src/order.rs | 260++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/trade/src/prelude.rs | 3+++
Mcrates/trade/src/projection.rs | 26+++++++++++++++++++++++++-
Mcrates/trade/src/workflow.rs | 52+++++++++++++++++++++++++++++++++++++++++++++++++---
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,