commit d9e323d3adf8c7436baf831b83e4e2121de41b9e
parent fb4b11e7f00721b20abdce710c217e259317b59f
Author: triesap <tyson@radroots.org>
Date: Sun, 17 May 2026 01:19:25 +0000
validation: add receipt inspection commands
Diffstat:
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",