cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit d9e323d3adf8c7436baf831b83e4e2121de41b9e
parent fb4b11e7f00721b20abdce710c217e259317b59f
Author: triesap <tyson@radroots.org>
Date:   Sun, 17 May 2026 01:19:25 +0000

validation: add receipt inspection commands

Diffstat:
Msrc/main.rs | 11+++++++++++
Msrc/operation_adapter.rs | 16+++++++++++++++-
Msrc/operation_registry.rs | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Asrc/operation_validation.rs | 191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/mod.rs | 1+
Asrc/runtime/validation_receipt.rs | 676+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/target_cli.rs | 101++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
7 files changed, 1061 insertions(+), 6 deletions(-)

diff --git a/src/main.rs b/src/main.rs @@ -11,6 +11,7 @@ mod operation_market; mod operation_order; mod operation_registry; mod operation_runtime; +mod operation_validation; mod output_contract; mod runtime; mod runtime_args; @@ -41,6 +42,7 @@ use crate::operation_registry::{ requires_nostr_relay_publish_mode, }; use crate::operation_runtime::RuntimeOperationService; +use crate::operation_validation::ValidationOperationService; use crate::output_contract::OutputEnvelope; use crate::runtime::config::{ PublishMode, RADROOTSD_PUBLISH_DEFERRED_REASON, RuntimeConfig, SignerBackend, @@ -335,6 +337,15 @@ fn execute_request( TargetOperationRequest::OrderEventWatch(request) => { execute_with(OrderOperationService::new(config), request) } + TargetOperationRequest::ValidationReceiptGet(request) => { + execute_with(ValidationOperationService::new(config), request) + } + TargetOperationRequest::ValidationReceiptList(request) => { + execute_with(ValidationOperationService::new(config), request) + } + TargetOperationRequest::ValidationReceiptVerify(request) => { + execute_with(ValidationOperationService::new(config), request) + } } } diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -1287,7 +1287,8 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati FarmLocationCommand, FarmProfileCommand, ListingCommand, MarketCommand, MarketListingCommand, MarketProductCommand, OrderCommand, OrderEventCommand, OrderFulfillmentCommand, OrderPaymentCommand, OrderReceiptCommand, OrderRevisionCommand, - OrderSettlementCommand, OrderStatusCommand, TargetCommand, + OrderSettlementCommand, OrderStatusCommand, TargetCommand, ValidationCommand, + ValidationReceiptCommand, }; let mut input = OperationData::new(); @@ -1548,6 +1549,16 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati }, OrderCommand::List => {} }, + TargetCommand::Validation(args) => match &args.command { + ValidationCommand::Receipt(receipt) => match &receipt.command { + ValidationReceiptCommand::Get(args) | ValidationReceiptCommand::Verify(args) => { + insert_string(&mut input, "receipt_event_id", &args.receipt_event_id); + } + ValidationReceiptCommand::List(args) => { + insert_string(&mut input, "order_id", &args.order_id); + } + }, + }, _ => {} } input @@ -1657,6 +1668,9 @@ target_operation_contracts! { OrderStatusGet => (OrderStatusGetRequest, OrderStatusGetResult, "order.status.get"), OrderEventList => (OrderEventListRequest, OrderEventListResult, "order.event.list"), OrderEventWatch => (OrderEventWatchRequest, OrderEventWatchResult, "order.event.watch"), + ValidationReceiptGet => (ValidationReceiptGetRequest, ValidationReceiptGetResult, "validation.receipt.get"), + ValidationReceiptList => (ValidationReceiptListRequest, ValidationReceiptListResult, "validation.receipt.list"), + ValidationReceiptVerify => (ValidationReceiptVerifyRequest, ValidationReceiptVerifyResult, "validation.receipt.verify"), } pub fn adapter_registry_linkage_is_valid() -> bool { diff --git a/src/operation_registry.rs b/src/operation_registry.rs @@ -1147,6 +1147,51 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ false, false ), + operation!( + "validation.receipt.get", + "radroots validation receipt get", + "validation", + "validation_receipt_get", + "ValidationReceiptGetRequest", + "ValidationReceiptGetResult", + "Fetch and inspect one validation receipt event.", + Any, + false, + None, + Low, + false, + false + ), + operation!( + "validation.receipt.list", + "radroots validation receipt list", + "validation", + "validation_receipt_list", + "ValidationReceiptListRequest", + "ValidationReceiptListResult", + "List validation receipts for one order.", + Any, + false, + None, + Low, + true, + false + ), + operation!( + "validation.receipt.verify", + "radroots validation receipt verify", + "validation", + "validation_receipt_verify", + "ValidationReceiptVerifyRequest", + "ValidationReceiptVerifyResult", + "Verify validation receipt tags, payload, and proof binding.", + Any, + false, + None, + Low, + false, + false + ), ]; pub fn get_operation(operation_id: &str) -> Option<&'static OperationSpec> { @@ -1157,9 +1202,20 @@ pub fn get_operation(operation_id: &str) -> Option<&'static OperationSpec> { pub fn network_requirement(operation_id: &str) -> NetworkRequirement { match operation_id { - "sync.pull" | "sync.push" | "sync.watch" | "market.refresh" | "farm.publish" - | "listing.publish" | "listing.update" | "listing.archive" | "order.submit" - | "order.status.get" | "order.event.list" => NetworkRequirement::External { + "sync.pull" + | "sync.push" + | "sync.watch" + | "market.refresh" + | "farm.publish" + | "listing.publish" + | "listing.update" + | "listing.archive" + | "order.submit" + | "order.status.get" + | "order.event.list" + | "validation.receipt.get" + | "validation.receipt.list" + | "validation.receipt.verify" => NetworkRequirement::External { dry_run_requires_network: false, }, "order.accept" @@ -1308,6 +1364,9 @@ mod tests { "order.status.get", "order.event.list", "order.event.watch", + "validation.receipt.get", + "validation.receipt.list", + "validation.receipt.verify", ]; const SUPPORTED_MUTATING_DRY_RUN_OPERATION_IDS: &[&str] = &[ @@ -1363,7 +1422,7 @@ mod tests { .copied() .collect::<BTreeSet<_>>(); assert_eq!(actual, expected); - assert_eq!(OPERATION_REGISTRY.len(), 71); + assert_eq!(OPERATION_REGISTRY.len(), 74); } #[test] @@ -1491,6 +1550,7 @@ mod tests { "basket.list", "order.list", "order.event.list", + "validation.receipt.list", ] .into_iter() .collect::<BTreeSet<_>>(); @@ -1530,6 +1590,9 @@ mod tests { "order.receipt.record", "order.status.get", "order.event.list", + "validation.receipt.get", + "validation.receipt.list", + "validation.receipt.verify", ] .into_iter() .collect::<BTreeSet<_>>(); diff --git a/src/operation_validation.rs b/src/operation_validation.rs @@ -0,0 +1,191 @@ +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::domain::runtime::CommandDisposition; +use crate::operation_adapter::{ + OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, + OperationResult, OperationResultData, OperationService, ValidationReceiptGetRequest, + ValidationReceiptGetResult, ValidationReceiptListRequest, ValidationReceiptListResult, + ValidationReceiptVerifyRequest, ValidationReceiptVerifyResult, +}; +use crate::runtime::config::RuntimeConfig; +use crate::runtime::validation_receipt::{ + ValidationReceiptEventArgs, ValidationReceiptInspectionView, ValidationReceiptListArgs, + ValidationReceiptListView, +}; + +pub struct ValidationOperationService<'a> { + config: &'a RuntimeConfig, +} + +impl<'a> ValidationOperationService<'a> { + pub fn new(config: &'a RuntimeConfig) -> Self { + Self { config } + } +} + +impl OperationService<ValidationReceiptGetRequest> for ValidationOperationService<'_> { + type Result = ValidationReceiptGetResult; + + fn execute( + &self, + request: OperationRequest<ValidationReceiptGetRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let args = validation_receipt_event_args(&request)?; + let view = crate::runtime::validation_receipt::get(self.config, &args); + validation_receipt_inspection_result::<ValidationReceiptGetResult>( + "validation.receipt.get", + &view, + ) + } +} + +impl OperationService<ValidationReceiptListRequest> for ValidationOperationService<'_> { + type Result = ValidationReceiptListResult; + + fn execute( + &self, + request: OperationRequest<ValidationReceiptListRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let args = validation_receipt_list_args(&request)?; + let view = crate::runtime::validation_receipt::list(self.config, &args); + validation_receipt_list_result(&view) + } +} + +impl OperationService<ValidationReceiptVerifyRequest> for ValidationOperationService<'_> { + type Result = ValidationReceiptVerifyResult; + + fn execute( + &self, + request: OperationRequest<ValidationReceiptVerifyRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let args = validation_receipt_event_args(&request)?; + let view = crate::runtime::validation_receipt::verify(self.config, &args); + validation_receipt_inspection_result::<ValidationReceiptVerifyResult>( + "validation.receipt.verify", + &view, + ) + } +} + +fn validation_receipt_event_args<P>( + request: &OperationRequest<P>, +) -> Result<ValidationReceiptEventArgs, OperationAdapterError> +where + P: OperationRequestPayload + OperationRequestData, +{ + Ok(ValidationReceiptEventArgs { + receipt_event_id: required_string(request, "receipt_event_id")?, + }) +} + +fn validation_receipt_list_args<P>( + request: &OperationRequest<P>, +) -> Result<ValidationReceiptListArgs, OperationAdapterError> +where + P: OperationRequestPayload + OperationRequestData, +{ + Ok(ValidationReceiptListArgs { + order_id: required_string(request, "order_id")?, + }) +} + +fn validation_receipt_inspection_result<R>( + operation_id: &str, + view: &ValidationReceiptInspectionView, +) -> Result<OperationResult<R>, OperationAdapterError> +where + R: OperationResultData, +{ + match view.disposition() { + CommandDisposition::Success => serialized_operation_result::<R, _>(view), + disposition => Err(validation_receipt_view_error( + operation_id, + disposition, + view, + view.reason.as_deref(), + )), + } +} + +fn validation_receipt_list_result( + view: &ValidationReceiptListView, +) -> Result<OperationResult<ValidationReceiptListResult>, OperationAdapterError> { + match view.disposition() { + CommandDisposition::Success => { + serialized_operation_result::<ValidationReceiptListResult, _>(view) + } + disposition => Err(validation_receipt_view_error( + "validation.receipt.list", + disposition, + view, + view.reason.as_deref(), + )), + } +} + +fn validation_receipt_view_error<T>( + operation_id: &str, + disposition: CommandDisposition, + view: &T, + reason: Option<&str>, +) -> OperationAdapterError +where + T: Serialize, +{ + let detail = serde_json::to_value(view).unwrap_or_else(|_| json!({})); + let message = reason + .map(str::to_owned) + .unwrap_or_else(|| format!("`{operation_id}` validation receipt operation failed")); + match disposition { + CommandDisposition::NotFound => { + OperationAdapterError::not_found_with_detail(operation_id, message, detail) + } + CommandDisposition::ValidationFailed => { + OperationAdapterError::validation_failed_with_detail(operation_id, message, detail) + } + CommandDisposition::Unconfigured => { + OperationAdapterError::operation_unavailable_with_detail(operation_id, message, detail) + } + CommandDisposition::ExternalUnavailable => { + OperationAdapterError::network_unavailable_with_detail(operation_id, message, detail) + } + CommandDisposition::Unsupported => OperationAdapterError::InvalidInput { + operation_id: operation_id.to_owned(), + message, + }, + CommandDisposition::InternalError | CommandDisposition::Success => { + OperationAdapterError::Runtime(message) + } + } +} + +fn required_string<P>( + request: &OperationRequest<P>, + key: &str, +) -> Result<String, OperationAdapterError> +where + P: OperationRequestPayload + OperationRequestData, +{ + request + .payload + .input() + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .ok_or_else(|| OperationAdapterError::InvalidInput { + operation_id: request.operation_id().to_owned(), + message: format!("missing required `{key}` input"), + }) +} + +fn serialized_operation_result<R, T>(value: &T) -> Result<OperationResult<R>, OperationAdapterError> +where + R: OperationResultData, + T: Serialize, +{ + OperationResult::new(R::from_serializable(value)?) +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs @@ -14,6 +14,7 @@ pub mod paths; pub mod provider; pub mod signer; pub mod sync; +pub mod validation_receipt; use std::process::ExitCode; diff --git a/src/runtime/validation_receipt.rs b/src/runtime/validation_receipt.rs @@ -0,0 +1,676 @@ +use radroots_events::kinds::KIND_TRADE_VALIDATION_RECEIPT; +use radroots_nostr::prelude::{ + RadrootsNostrEvent, RadrootsNostrEventId, RadrootsNostrFilter, RadrootsNostrKind, + radroots_event_from_nostr, radroots_nostr_filter_tag, +}; +use radroots_trade::validation_receipt::{ + RadrootsTradeValidationReceipt, RadrootsValidationReceiptExpectedBinding, + RadrootsValidationReceiptProof, RadrootsValidationReceiptProofSystem, + RadrootsValidationReceiptResult, RadrootsValidationReceiptTags, RadrootsValidationReceiptType, + verify_validation_receipt_event, +}; +use serde::Serialize; + +use crate::domain::runtime::{CommandDisposition, RelayFailureView}; +use crate::runtime::config::RuntimeConfig; +use crate::runtime::direct_relay::{ + DirectRelayFailure, DirectRelayFetchError, DirectRelayFetchReceipt, fetch_events_from_relays, +}; + +#[derive(Debug, Clone)] +pub struct ValidationReceiptEventArgs { + pub receipt_event_id: String, +} + +#[derive(Debug, Clone)] +pub struct ValidationReceiptListArgs { + pub order_id: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ValidationReceiptInspectionView { + pub state: String, + pub resource: Option<ValidationReceiptResourceView>, + pub receipt_event_id: Option<String>, + pub order_id: Option<String>, + pub validation_state: String, + pub proof_verification: Option<ValidationReceiptProofVerificationView>, + pub receipt: Option<RadrootsTradeValidationReceipt>, + pub receipt_tags: Option<ValidationReceiptTagsView>, + pub event: Option<ValidationReceiptEventView>, + pub target_relays: Vec<String>, + pub connected_relays: Vec<String>, + pub failed_relays: Vec<RelayFailureView>, + pub reason_code: Option<String>, + pub reason: Option<String>, + pub actions: Vec<String>, +} + +impl ValidationReceiptInspectionView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "valid" | "verified" => CommandDisposition::Success, + "missing" => CommandDisposition::NotFound, + "invalid" => CommandDisposition::ValidationFailed, + "unconfigured" => CommandDisposition::Unconfigured, + "network_unavailable" => CommandDisposition::ExternalUnavailable, + _ => CommandDisposition::InternalError, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct ValidationReceiptListView { + pub state: String, + pub order_id: String, + pub count: usize, + pub valid_count: usize, + pub invalid_count: usize, + pub receipts: Vec<ValidationReceiptSummaryView>, + pub invalid_receipts: Vec<ValidationReceiptInvalidCandidateView>, + pub target_relays: Vec<String>, + pub connected_relays: Vec<String>, + pub failed_relays: Vec<RelayFailureView>, + pub reason_code: Option<String>, + pub reason: Option<String>, + pub actions: Vec<String>, +} + +impl ValidationReceiptListView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "listed" | "empty" | "partial" => CommandDisposition::Success, + "invalid" => CommandDisposition::ValidationFailed, + "unconfigured" => CommandDisposition::Unconfigured, + "network_unavailable" => CommandDisposition::ExternalUnavailable, + _ => CommandDisposition::InternalError, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct ValidationReceiptResourceView { + pub kind: String, + pub id: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ValidationReceiptEventView { + pub id: String, + pub author: String, + pub created_at: u32, + pub kind: u32, + pub sig: String, + pub tags: Vec<Vec<String>>, + pub content: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ValidationReceiptTagsView { + pub order_id: String, + pub event_set_root: String, + pub reducer_output_root: String, + pub public_values_hash: String, + pub proof_system: String, + pub receipt_type: String, + pub root_event_id: String, + pub target_event_id: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ValidationReceiptProofVerificationView { + pub state: String, + pub verifier: String, + pub proof_system: String, + pub public_values_hash_binding: String, + pub proof_metadata_binding: String, + pub cryptographic_proof_required: bool, + pub cryptographic_proof_verified: bool, + pub mode: Option<String>, + pub program_hash: Option<String>, + pub verifying_key_hash: Option<String>, + pub proof_reference: Option<String>, + pub inline_proof_present: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ValidationReceiptSummaryView { + pub resource: ValidationReceiptResourceView, + pub receipt_event_id: String, + pub order_id: String, + pub author: String, + pub created_at: u32, + pub receipt_type: String, + pub result: String, + pub proof_system: String, + pub proof_verification_state: String, + pub event_set_root: String, + pub reducer_output_root: String, + pub public_values_hash: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ValidationReceiptInvalidCandidateView { + pub receipt_event_id: String, + pub kind: u32, + pub reason: String, +} + +pub fn get( + config: &RuntimeConfig, + args: &ValidationReceiptEventArgs, +) -> ValidationReceiptInspectionView { + inspect_event(config, &args.receipt_event_id, "valid") +} + +pub fn verify( + config: &RuntimeConfig, + args: &ValidationReceiptEventArgs, +) -> ValidationReceiptInspectionView { + inspect_event(config, &args.receipt_event_id, "verified") +} + +pub fn list(config: &RuntimeConfig, args: &ValidationReceiptListArgs) -> ValidationReceiptListView { + let order_id = args.order_id.trim(); + if order_id.is_empty() { + return invalid_list_view( + args.order_id.clone(), + "invalid_order_id", + "validation receipt list requires non-empty `order_id`", + ); + } + let filter = match validation_receipt_order_filter(order_id) { + Ok(filter) => filter, + Err(reason) => return invalid_list_view(order_id.to_owned(), "invalid_order_id", reason), + }; + let receipt = match fetch_events_from_relays(&config.relay.urls, filter) { + Ok(receipt) => receipt, + Err(error) => return list_fetch_error_view(order_id, error), + }; + list_from_fetch_receipt(order_id, receipt) +} + +fn inspect_event( + config: &RuntimeConfig, + receipt_event_id: &str, + success_state: &str, +) -> ValidationReceiptInspectionView { + let receipt_event_id = receipt_event_id.trim(); + if receipt_event_id.is_empty() { + return invalid_inspection_view( + None, + "invalid_receipt_event_id", + "validation receipt command requires non-empty `receipt_event_id`", + ); + } + let event_id = match RadrootsNostrEventId::parse(receipt_event_id) { + Ok(event_id) => event_id, + Err(error) => { + return invalid_inspection_view( + Some(receipt_event_id.to_owned()), + "invalid_receipt_event_id", + format!("invalid validation receipt event id `{receipt_event_id}`: {error}"), + ); + } + }; + let filter = RadrootsNostrFilter::new().id(event_id); + let receipt = match fetch_events_from_relays(&config.relay.urls, filter) { + Ok(receipt) => receipt, + Err(error) => return inspection_fetch_error_view(receipt_event_id, error), + }; + inspection_from_fetch_receipt(receipt_event_id, success_state, receipt) +} + +fn validation_receipt_order_filter(order_id: &str) -> Result<RadrootsNostrFilter, String> { + let filter = RadrootsNostrFilter::new().kind(RadrootsNostrKind::Custom( + KIND_TRADE_VALIDATION_RECEIPT as u16, + )); + radroots_nostr_filter_tag(filter, "d", vec![order_id.to_owned()]) + .map_err(|error| format!("build validation receipt order filter: {error}")) +} + +fn inspection_from_fetch_receipt( + receipt_event_id: &str, + success_state: &str, + fetch_receipt: DirectRelayFetchReceipt, +) -> ValidationReceiptInspectionView { + let DirectRelayFetchReceipt { + target_relays, + connected_relays, + failed_relays, + mut events, + } = fetch_receipt; + events.sort_by_key(|event| event.created_at.as_secs()); + let Some(event) = events.into_iter().next() else { + return ValidationReceiptInspectionView { + state: "missing".to_owned(), + resource: Some(validation_receipt_resource(receipt_event_id)), + receipt_event_id: Some(receipt_event_id.to_owned()), + order_id: None, + validation_state: "missing".to_owned(), + proof_verification: None, + receipt: None, + receipt_tags: None, + event: None, + target_relays, + connected_relays, + failed_relays: relay_failures(failed_relays), + reason_code: Some("validation_receipt_not_found".to_owned()), + reason: Some(format!( + "validation receipt event `{receipt_event_id}` was not found on configured relays" + )), + actions: Vec::new(), + }; + }; + inspected_event_view( + success_state, + event, + target_relays, + connected_relays, + failed_relays, + ) +} + +fn inspected_event_view( + success_state: &str, + event: RadrootsNostrEvent, + target_relays: Vec<String>, + connected_relays: Vec<String>, + failed_relays: Vec<DirectRelayFailure>, +) -> ValidationReceiptInspectionView { + let converted = radroots_event_from_nostr(&event); + match verify_validation_receipt_event( + &converted, + RadrootsValidationReceiptExpectedBinding::default(), + ) { + Ok(verified) => { + let event_id = converted.id.clone(); + let order_id = verified.tags.order_id.clone(); + let proof_verification = proof_verification_view(&verified.receipt.proof); + let reason_code = + (!failed_relays.is_empty()).then_some("relay_fetch_partial".to_owned()); + ValidationReceiptInspectionView { + state: success_state.to_owned(), + resource: Some(validation_receipt_resource(&event_id)), + receipt_event_id: Some(event_id), + order_id: Some(order_id), + validation_state: "valid".to_owned(), + proof_verification: Some(proof_verification), + receipt: Some(verified.receipt), + receipt_tags: Some(tags_view(&verified.tags)), + event: Some(event_view(converted)), + target_relays, + connected_relays, + failed_relays: relay_failures(failed_relays), + reason_code, + reason: None, + actions: Vec::new(), + } + } + Err(error) => ValidationReceiptInspectionView { + state: "invalid".to_owned(), + resource: Some(validation_receipt_resource(&converted.id)), + receipt_event_id: Some(converted.id.clone()), + order_id: None, + validation_state: "invalid".to_owned(), + proof_verification: None, + receipt: None, + receipt_tags: None, + event: Some(event_view(converted)), + target_relays, + connected_relays, + failed_relays: relay_failures(failed_relays), + reason_code: Some("validation_receipt_invalid".to_owned()), + reason: Some(error.to_string()), + actions: Vec::new(), + }, + } +} + +fn list_from_fetch_receipt( + order_id: &str, + fetch_receipt: DirectRelayFetchReceipt, +) -> ValidationReceiptListView { + let DirectRelayFetchReceipt { + target_relays, + connected_relays, + failed_relays, + mut events, + } = fetch_receipt; + events.sort_by(|left, right| { + left.created_at + .as_secs() + .cmp(&right.created_at.as_secs()) + .then_with(|| left.id.to_hex().cmp(&right.id.to_hex())) + }); + let mut receipts = Vec::new(); + let mut invalid_receipts = Vec::new(); + + for event in events { + let converted = radroots_event_from_nostr(&event); + match verify_validation_receipt_event( + &converted, + RadrootsValidationReceiptExpectedBinding { + order_id: Some(order_id), + ..RadrootsValidationReceiptExpectedBinding::default() + }, + ) { + Ok(verified) => { + receipts.push(summary_view(&converted, &verified.receipt, &verified.tags)) + } + Err(error) => invalid_receipts.push(ValidationReceiptInvalidCandidateView { + receipt_event_id: converted.id, + kind: converted.kind, + reason: error.to_string(), + }), + } + } + + let failed_relays = relay_failures(failed_relays); + let valid_count = receipts.len(); + let invalid_count = invalid_receipts.len(); + let state = if valid_count > 0 && invalid_count > 0 { + "partial" + } else if valid_count > 0 { + "listed" + } else if invalid_count > 0 { + "invalid" + } else { + "empty" + }; + let reason_code = if invalid_count > 0 { + Some("validation_receipt_candidates_invalid".to_owned()) + } else if !failed_relays.is_empty() { + Some("relay_fetch_partial".to_owned()) + } else { + None + }; + let reason = match state { + "invalid" => Some(format!( + "found {invalid_count} invalid validation receipt candidate(s) and no valid receipts" + )), + "partial" => Some(format!( + "found {valid_count} valid receipt(s) and {invalid_count} invalid candidate(s)" + )), + _ => None, + }; + + ValidationReceiptListView { + state: state.to_owned(), + order_id: order_id.to_owned(), + count: receipts.len(), + valid_count, + invalid_count, + receipts, + invalid_receipts, + target_relays, + connected_relays, + failed_relays, + reason_code, + reason, + actions: Vec::new(), + } +} + +fn inspection_fetch_error_view( + receipt_event_id: &str, + error: DirectRelayFetchError, +) -> ValidationReceiptInspectionView { + let (state, reason_code, reason, target_relays, connected_relays, failed_relays, actions) = + fetch_error_parts(error); + ValidationReceiptInspectionView { + state, + resource: Some(validation_receipt_resource(receipt_event_id)), + receipt_event_id: Some(receipt_event_id.to_owned()), + order_id: None, + validation_state: "unverified".to_owned(), + proof_verification: None, + receipt: None, + receipt_tags: None, + event: None, + target_relays, + connected_relays, + failed_relays, + reason_code: Some(reason_code), + reason: Some(reason), + actions, + } +} + +fn list_fetch_error_view( + order_id: &str, + error: DirectRelayFetchError, +) -> ValidationReceiptListView { + let (state, reason_code, reason, target_relays, connected_relays, failed_relays, actions) = + fetch_error_parts(error); + ValidationReceiptListView { + state, + order_id: order_id.to_owned(), + count: 0, + valid_count: 0, + invalid_count: 0, + receipts: Vec::new(), + invalid_receipts: Vec::new(), + target_relays, + connected_relays, + failed_relays, + reason_code: Some(reason_code), + reason: Some(reason), + actions, + } +} + +fn fetch_error_parts( + error: DirectRelayFetchError, +) -> ( + String, + String, + String, + Vec<String>, + Vec<String>, + Vec<RelayFailureView>, + Vec<String>, +) { + match error { + DirectRelayFetchError::MissingRelays => ( + "unconfigured".to_owned(), + "relay_unconfigured".to_owned(), + "validation receipt commands require at least one configured relay".to_owned(), + Vec::new(), + Vec::new(), + Vec::new(), + vec![ + "radroots --relay wss://relay.example.com validation receipt list --order-id <order-id>" + .to_owned(), + ], + ), + DirectRelayFetchError::Connect { + reason, + target_relays, + failed_relays, + } => ( + "network_unavailable".to_owned(), + "relay_fetch_failed".to_owned(), + reason, + target_relays, + Vec::new(), + relay_failures(failed_relays), + Vec::new(), + ), + DirectRelayFetchError::RelayConfig { relay, source } => ( + "network_unavailable".to_owned(), + "relay_config_failed".to_owned(), + format!("failed to configure relay `{relay}` for validation receipt fetch: {source}"), + vec![relay.clone()], + Vec::new(), + vec![RelayFailureView { + relay, + reason: source.to_string(), + }], + Vec::new(), + ), + DirectRelayFetchError::Fetch(source) => ( + "network_unavailable".to_owned(), + "relay_fetch_failed".to_owned(), + source.to_string(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + ), + DirectRelayFetchError::Runtime(reason) => ( + "network_unavailable".to_owned(), + "relay_fetch_runtime_failed".to_owned(), + reason, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + ), + } +} + +fn invalid_inspection_view( + receipt_event_id: Option<String>, + reason_code: &str, + reason: impl Into<String>, +) -> ValidationReceiptInspectionView { + ValidationReceiptInspectionView { + state: "invalid".to_owned(), + resource: receipt_event_id.as_deref().map(validation_receipt_resource), + receipt_event_id, + order_id: None, + validation_state: "invalid".to_owned(), + proof_verification: None, + receipt: None, + receipt_tags: None, + event: None, + target_relays: Vec::new(), + connected_relays: Vec::new(), + failed_relays: Vec::new(), + reason_code: Some(reason_code.to_owned()), + reason: Some(reason.into()), + actions: Vec::new(), + } +} + +fn invalid_list_view( + order_id: String, + reason_code: &str, + reason: impl Into<String>, +) -> ValidationReceiptListView { + ValidationReceiptListView { + state: "invalid".to_owned(), + order_id, + count: 0, + valid_count: 0, + invalid_count: 0, + receipts: Vec::new(), + invalid_receipts: Vec::new(), + target_relays: Vec::new(), + connected_relays: Vec::new(), + failed_relays: Vec::new(), + reason_code: Some(reason_code.to_owned()), + reason: Some(reason.into()), + actions: Vec::new(), + } +} + +fn validation_receipt_resource(id: &str) -> ValidationReceiptResourceView { + ValidationReceiptResourceView { + kind: "validation_receipt".to_owned(), + id: id.to_owned(), + } +} + +fn event_view(event: radroots_events::RadrootsNostrEvent) -> ValidationReceiptEventView { + ValidationReceiptEventView { + id: event.id, + author: event.author, + created_at: event.created_at, + kind: event.kind, + sig: event.sig, + tags: event.tags, + content: event.content, + } +} + +fn tags_view(tags: &RadrootsValidationReceiptTags) -> ValidationReceiptTagsView { + ValidationReceiptTagsView { + order_id: tags.order_id.clone(), + event_set_root: tags.event_set_root.clone(), + reducer_output_root: tags.reducer_output_root.clone(), + public_values_hash: tags.public_values_hash.clone(), + proof_system: tags.proof_system.as_str().to_owned(), + receipt_type: receipt_type_label(tags.receipt_type).to_owned(), + root_event_id: tags.root_event_id.clone(), + target_event_id: tags.target_event_id.clone(), + } +} + +fn proof_verification_view( + proof: &RadrootsValidationReceiptProof, +) -> ValidationReceiptProofVerificationView { + let cryptographic_proof_required = proof.system != RadrootsValidationReceiptProofSystem::None; + ValidationReceiptProofVerificationView { + state: if cryptographic_proof_required { + "proof_material_bound".to_owned() + } else { + "verified".to_owned() + }, + verifier: "radroots_validation_receipt_v1".to_owned(), + proof_system: proof.system.as_str().to_owned(), + public_values_hash_binding: "verified".to_owned(), + proof_metadata_binding: "verified".to_owned(), + cryptographic_proof_required, + cryptographic_proof_verified: !cryptographic_proof_required, + mode: proof.mode.clone(), + program_hash: proof.program_hash.clone(), + verifying_key_hash: proof.verifying_key_hash.clone(), + proof_reference: proof.proof_reference.clone(), + inline_proof_present: proof.inline_proof_base64.is_some(), + } +} + +fn summary_view( + event: &radroots_events::RadrootsNostrEvent, + receipt: &RadrootsTradeValidationReceipt, + tags: &RadrootsValidationReceiptTags, +) -> ValidationReceiptSummaryView { + let proof_verification = proof_verification_view(&receipt.proof); + ValidationReceiptSummaryView { + resource: validation_receipt_resource(&event.id), + receipt_event_id: event.id.clone(), + order_id: tags.order_id.clone(), + author: event.author.clone(), + created_at: event.created_at, + receipt_type: receipt_type_label(receipt.receipt_type).to_owned(), + result: receipt_result_label(receipt.result).to_owned(), + proof_system: receipt.proof.system.as_str().to_owned(), + proof_verification_state: proof_verification.state, + event_set_root: receipt.event_set_root.clone(), + reducer_output_root: receipt.new_state_root.clone(), + public_values_hash: receipt.public_values_hash.clone(), + } +} + +fn receipt_type_label(value: RadrootsValidationReceiptType) -> &'static str { + value.as_str() +} + +fn receipt_result_label(value: RadrootsValidationReceiptResult) -> &'static str { + match value { + RadrootsValidationReceiptResult::Valid => "valid", + RadrootsValidationReceiptResult::Invalid => "invalid", + } +} + +fn relay_failures(failures: Vec<DirectRelayFailure>) -> Vec<RelayFailureView> { + failures + .into_iter() + .map(|failure| RelayFailureView { + relay: failure.relay, + reason: failure.reason, + }) + .collect() +} diff --git a/src/target_cli.rs b/src/target_cli.rs @@ -102,6 +102,8 @@ pub enum TargetCommand { Basket(BasketArgs), #[command(about = "Coordinate order lifecycle events without payments.")] Order(OrderArgs), + #[command(about = "Inspect validation receipts and proof state.")] + Validation(ValidationArgs), } impl TargetCommand { @@ -250,6 +252,13 @@ impl TargetCommand { OrderEventCommand::Watch(_) => "order.event.watch", }, }, + Self::Validation(args) => match &args.command { + ValidationCommand::Receipt(receipt) => match &receipt.command { + ValidationReceiptCommand::Get(_) => "validation.receipt.get", + ValidationReceiptCommand::List(_) => "validation.receipt.list", + ValidationReceiptCommand::Verify(_) => "validation.receipt.verify", + }, + }, } } } @@ -1053,6 +1062,41 @@ pub enum OrderEventCommand { } #[derive(Debug, Clone, Args)] +pub struct ValidationArgs { + #[command(subcommand)] + pub command: ValidationCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum ValidationCommand { + Receipt(ValidationReceiptArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct ValidationReceiptArgs { + #[command(subcommand)] + pub command: ValidationReceiptCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum ValidationReceiptCommand { + Get(ValidationReceiptEventArgs), + List(ValidationReceiptListArgs), + Verify(ValidationReceiptEventArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct ValidationReceiptEventArgs { + pub receipt_event_id: Option<String>, +} + +#[derive(Debug, Clone, Args)] +pub struct ValidationReceiptListArgs { + #[arg(long)] + pub order_id: Option<String>, +} + +#[derive(Debug, Clone, Args)] pub struct PathOutputArgs { #[arg(long)] pub output: Option<PathBuf>, @@ -1067,7 +1111,8 @@ mod tests { use super::{ AccountCommand, FarmCommand, ListingCommand, OrderCommand, OrderFulfillmentCommand, OrderFulfillmentStateArg, OrderPaymentCommand, OrderReceiptCommand, OrderRevisionCommand, - OrderSettlementCommand, TargetCliArgs, TargetOutputFormat, + OrderSettlementCommand, TargetCliArgs, TargetOutputFormat, ValidationCommand, + ValidationReceiptCommand, }; use crate::operation_registry::OPERATION_REGISTRY; @@ -1102,6 +1147,7 @@ mod tests { "market", "basket", "order", + "validation", ] .into_iter() .map(str::to_owned) @@ -1426,6 +1472,59 @@ mod tests { } #[test] + fn target_parser_accepts_validation_receipt_commands() { + let get = TargetCliArgs::try_parse_from([ + "radroots", + "validation", + "receipt", + "get", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ]) + .expect("target args parse"); + assert_eq!(get.command.operation_id(), "validation.receipt.get"); + let crate::target_cli::TargetCommand::Validation(validation) = get.command else { + panic!("expected validation command") + }; + let ValidationCommand::Receipt(receipt) = validation.command; + let ValidationReceiptCommand::Get(args) = receipt.command else { + panic!("expected validation receipt get command") + }; + assert_eq!( + args.receipt_event_id.as_deref(), + Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + ); + + let list = TargetCliArgs::try_parse_from([ + "radroots", + "validation", + "receipt", + "list", + "--order-id", + "ord_1", + ]) + .expect("target args parse"); + assert_eq!(list.command.operation_id(), "validation.receipt.list"); + let crate::target_cli::TargetCommand::Validation(validation) = list.command else { + panic!("expected validation command") + }; + let ValidationCommand::Receipt(receipt) = validation.command; + let ValidationReceiptCommand::List(args) = receipt.command else { + panic!("expected validation receipt list command") + }; + assert_eq!(args.order_id.as_deref(), Some("ord_1")); + + let verify = TargetCliArgs::try_parse_from([ + "radroots", + "validation", + "receipt", + "verify", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ]) + .expect("target args parse"); + assert_eq!(verify.command.operation_id(), "validation.receipt.verify"); + } + + #[test] fn target_parser_accepts_order_payment_record_methods() { let parsed = TargetCliArgs::try_parse_from([ "radroots",