cli

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

commit 7c63a52138f25f5f9120a2112d15d67fbbc2c9f2
parent c1f7e5ec3df7480039c4846b5833e58d982e7d6c
Author: triesap <tyson@radroots.org>
Date:   Thu, 16 Jul 2026 13:52:39 +0000

cli: cut over trade commands to release product SDK

Replace the retired trade request/accept/decline/cancel command tree with release-product proposal, revision, candidate, cancellation, query, evidence, and private-artifact commands.

Route trade execution through SDK command/query/private-artifact services, generate proposal drafts from basket quotes, and remove old order runtime surfaces.

Refresh generated runtime contract bindings, signer readiness, source guards, and parser tests for the canonical trade operation catalog.

Diffstat:
MCargo.lock | 3+++
Msrc/cli/global.rs | 81-------------------------------------------------------------------------------
Msrc/cli/input.rs | 122++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Msrc/cli/mod.rs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/cli/trade.rs | 199++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/generated/runtime_contract_registry.rs | 202+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Msrc/generated/runtime_contract_target.rs | 14++++++++++----
Msrc/main.rs | 26++++++++++++++++++++++----
Msrc/ops/exec/basket.rs | 81+++++++++++++++++++++++++++----------------------------------------------------
Msrc/ops/exec/core.rs | 4++--
Msrc/ops/exec/trade.rs | 643+++++++++++++++++++++++++++++++------------------------------------------------
Msrc/out/envelope.rs | 6+++---
Msrc/out/terminal/actions.rs | 9++-------
Msrc/out/terminal/errors.rs | 2+-
Msrc/runtime/account.rs | 2+-
Msrc/runtime/farm.rs | 5+----
Msrc/runtime/mod.rs | 2+-
Dsrc/runtime/order.rs | 4847-------------------------------------------------------------------------------
Dsrc/runtime/order/sdk_status.rs | 327-------------------------------------------------------------------------------
Msrc/runtime/sdk.rs | 265++++++++++++-------------------------------------------------------------------
Msrc/runtime/signer.rs | 83+++++++++++++++++++++++++++++++++++--------------------------------------------
Msrc/runtime/sync.rs | 1+
Asrc/runtime/trade.rs | 1028+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/validation_receipt.rs | 1564++-----------------------------------------------------------------------------
Msrc/view/runtime.rs | 843-------------------------------------------------------------------------------
25 files changed, 1947 insertions(+), 8476 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3683,11 +3683,13 @@ dependencies = [ name = "radroots_event_store" version = "0.1.0-alpha.2" dependencies = [ + "hex", "radroots_event", "radroots_nostr", "radroots_transport", "serde", "serde_json", + "sha2", "sqlx", "thiserror 1.0.69", ] @@ -3924,6 +3926,7 @@ dependencies = [ name = "radroots_sdk" version = "0.1.0" dependencies = [ + "base64 0.22.1", "futures", "hex", "nostr", diff --git a/src/cli/global.rs b/src/cli/global.rs @@ -213,87 +213,6 @@ pub struct ListingMutationArgs { pub offline: bool, } -#[derive(Debug, Clone, Default)] -pub struct OrderDraftCreateArgs { - pub listing: Option<String>, - pub listing_addr: Option<String>, - pub bin_id: Option<String>, - pub bin_count: Option<u32>, - pub adjustments: Vec<OrderDraftAdjustmentArgs>, -} - -#[derive(Debug, Clone, Default)] -pub struct OrderDraftAdjustmentArgs { - pub id: String, - pub effect: String, - pub amount: String, - pub currency: String, - pub reason: String, -} - -#[derive(Debug, Clone)] -pub struct TradeRequestArgs { - pub key: String, - pub idempotency_key: Option<String>, - pub confirm_public_note: bool, -} - -#[derive(Debug, Clone)] -pub struct TradeAppRecordExportArgs { - pub record_id: String, - pub output: Option<PathBuf>, -} - -#[derive(Debug, Clone)] -pub struct TradeRebindArgs { - pub key: String, - pub selector: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TradeDecisionArg { - Accept, - Decline, -} - -impl TradeDecisionArg { - pub fn as_str(self) -> &'static str { - match self { - Self::Accept => "accepted", - Self::Decline => "declined", - } - } - - pub fn command(self) -> &'static str { - match self { - Self::Accept => "accept", - Self::Decline => "decline", - } - } -} - -#[derive(Debug, Clone)] -pub struct TradeDecisionArgs { - pub key: String, - pub decision: TradeDecisionArg, - pub reason: Option<String>, - pub idempotency_key: Option<String>, - pub confirm_public_note: bool, -} - -#[derive(Debug, Clone)] -pub struct TradeCancelArgs { - pub key: String, - pub reason: String, - pub idempotency_key: Option<String>, - pub confirm_public_note: bool, -} - -#[derive(Debug, Clone)] -pub struct TradeStatusArgs { - pub key: String, -} - #[derive(Debug, Clone)] pub struct RecordLookupArgs { pub key: String, diff --git a/src/cli/input.rs b/src/cli/input.rs @@ -1,3 +1,5 @@ +use std::path::Path; + use crate::cli::global::{RuntimeInvocationArgs, RuntimeOutputFormatArg}; use crate::cli::{TargetCliArgs, TargetCommand, TargetOutputFormat}; use crate::ops::OperationData; @@ -41,7 +43,9 @@ pub fn operation_id_from_target(args: &TargetCliArgs) -> &'static str { pub fn target_operation_input(command: &TargetCommand) -> OperationData { use crate::cli::{ AccountCommand, BasketCommand, BasketItemCommand, FarmCommand, ListingCommand, - MarketCommand, StoreCommand, TradeCommand, TransportCapabilityCommand, TransportCommand, + MarketCommand, StoreCommand, TradeCancellationCommand, TradeCandidateCommand, TradeCommand, + TradeEvidenceCommand, TradeOperationCommand, TradePrivateArtifactCommand, + TradeProposalCommand, TradeRevisionCommand, TransportCapabilityCommand, TransportCommand, TransportConfigCommand, TransportDeliveryCommand, ValidationCommand, ValidationReceiptCommand, }; @@ -155,23 +159,90 @@ pub fn target_operation_input(command: &TargetCommand) -> OperationData { BasketCommand::List => {} }, TargetCommand::Trade(args) => match &args.command { - TradeCommand::Request(args) => { - insert_string(&mut input, "trade_id", &args.trade_id); - insert_bool(&mut input, "confirm_public_note", args.confirm_public_note); - } + TradeCommand::Proposal(args) => match &args.command { + TradeProposalCommand::Submit(args) => { + insert_path_required(&mut input, "file", &args.file) + } + }, + TradeCommand::Revision(args) => match &args.command { + TradeRevisionCommand::Propose(args) => { + insert_path_required(&mut input, "file", &args.file) + } + }, + TradeCommand::Candidate(args) => match &args.command { + TradeCandidateCommand::Decide(args) => { + insert_path_required(&mut input, "file", &args.file); + insert_bool( + &mut input, + "acknowledge_private_terms", + args.acknowledge_private_terms, + ); + } + }, + TradeCommand::Cancellation(args) => match &args.command { + TradeCancellationCommand::Submit(args) => { + insert_path_required(&mut input, "file", &args.file) + } + }, + TradeCommand::Operation(args) => match &args.command { + TradeOperationCommand::Resume(args) => { + insert_path_required(&mut input, "file", &args.file); + if let Some(operation_kind) = args.operation_kind { + input.insert( + "operation_kind".to_owned(), + Value::String(operation_kind.as_sdk_operation_kind().to_owned()), + ); + } + insert_bool( + &mut input, + "acknowledge_private_terms", + args.acknowledge_private_terms, + ); + } + }, TradeCommand::Get(args) => insert_string(&mut input, "trade_id", &args.trade_id), - TradeCommand::Accept(args) => insert_string(&mut input, "trade_id", &args.trade_id), - TradeCommand::Decline(args) => { - insert_string(&mut input, "trade_id", &args.trade_id); - insert_string(&mut input, "reason", &args.reason); - insert_bool(&mut input, "confirm_public_note", args.confirm_public_note); - } - TradeCommand::Cancel(args) => { - insert_string(&mut input, "trade_id", &args.trade_id); - insert_string(&mut input, "reason", &args.reason); - insert_bool(&mut input, "confirm_public_note", args.confirm_public_note); + TradeCommand::List(args) => { + insert_u32(&mut input, "limit", args.limit); + insert_string(&mut input, "cursor", &args.cursor); } - TradeCommand::List => {} + TradeCommand::Evidence(args) => match &args.command { + TradeEvidenceCommand::Refresh(args) => { + insert_string(&mut input, "trade_id", &args.trade_id) + } + TradeEvidenceCommand::Inspect(args) => { + insert_string(&mut input, "trade_id", &args.trade_id); + insert_u32(&mut input, "limit", args.limit); + insert_string(&mut input, "cursor", &args.cursor); + } + }, + TradeCommand::PrivateArtifact(args) => match &args.command { + TradePrivateArtifactCommand::Seal(args) => { + insert_string(&mut input, "trade_id", &args.trade_id); + insert_string(&mut input, "artifact_id", &args.artifact_id); + insert_string(&mut input, "schema_id", &args.schema_id); + insert_path(&mut input, "input", &args.input); + input.insert( + "kind".to_owned(), + Value::String(args.kind.as_str().to_owned()), + ); + insert_string(&mut input, "candidate_id", &args.candidate_id); + insert_string(&mut input, "retention_class", &args.retention_class); + insert_i64(&mut input, "expires_at_ms", args.expires_at_ms); + } + TradePrivateArtifactCommand::Open(args) => { + input.insert( + "artifact_id".to_owned(), + Value::String(args.artifact_id.clone()), + ); + insert_path(&mut input, "output", &args.output); + } + TradePrivateArtifactCommand::Delete(args) => { + input.insert( + "artifact_id".to_owned(), + Value::String(args.artifact_id.clone()), + ); + } + }, }, TargetCommand::Validation(args) => match &args.command { ValidationCommand::Status => {} @@ -254,3 +325,22 @@ fn insert_path(input: &mut OperationData, key: &str, value: &Option<std::path::P ); } } + +fn insert_path_required(input: &mut OperationData, key: &str, value: &Path) { + input.insert( + key.to_owned(), + Value::String(value.to_string_lossy().into_owned()), + ); +} + +fn insert_u32(input: &mut OperationData, key: &str, value: Option<u32>) { + if let Some(value) = value { + input.insert(key.to_owned(), Value::Number(value.into())); + } +} + +fn insert_i64(input: &mut OperationData, key: &str, value: Option<i64>) { + if let Some(value) = value { + input.insert(key.to_owned(), Value::Number(value.into())); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs @@ -189,12 +189,32 @@ impl TargetCommand { BasketCommand::Quote(_) => "basket.quote", }, Self::Trade(args) => match &args.command { - TradeCommand::Request(_) => "trade.request", + TradeCommand::Proposal(args) => match &args.command { + TradeProposalCommand::Submit(_) => "trade.proposal.submit", + }, + TradeCommand::Revision(args) => match &args.command { + TradeRevisionCommand::Propose(_) => "trade.revision.propose", + }, + TradeCommand::Candidate(args) => match &args.command { + TradeCandidateCommand::Decide(_) => "trade.candidate.decide", + }, + TradeCommand::Cancellation(args) => match &args.command { + TradeCancellationCommand::Submit(_) => "trade.cancellation.submit", + }, + TradeCommand::Operation(args) => match &args.command { + TradeOperationCommand::Resume(_) => "trade.operation.resume", + }, TradeCommand::Get(_) => "trade.get", - TradeCommand::List => "trade.list", - TradeCommand::Accept(_) => "trade.accept", - TradeCommand::Decline(_) => "trade.decline", - TradeCommand::Cancel(_) => "trade.cancel", + TradeCommand::List(_) => "trade.list", + TradeCommand::Evidence(args) => match &args.command { + TradeEvidenceCommand::Refresh(_) => "trade.evidence.refresh", + TradeEvidenceCommand::Inspect(_) => "trade.evidence.inspect", + }, + TradeCommand::PrivateArtifact(args) => match &args.command { + TradePrivateArtifactCommand::Seal(_) => "trade.private_artifact.seal", + TradePrivateArtifactCommand::Open(_) => "trade.private_artifact.open", + TradePrivateArtifactCommand::Delete(_) => "trade.private_artifact.delete", + }, }, Self::Validation(args) => match &args.command { ValidationCommand::Status => "validation.status", @@ -327,6 +347,30 @@ mod tests { ], vec![ "radroots".to_owned(), + "trade".to_owned(), + "request".to_owned(), + "abc".to_owned(), + ], + vec![ + "radroots".to_owned(), + "trade".to_owned(), + "accept".to_owned(), + "abc".to_owned(), + ], + vec![ + "radroots".to_owned(), + "trade".to_owned(), + "decline".to_owned(), + "abc".to_owned(), + ], + vec![ + "radroots".to_owned(), + "trade".to_owned(), + "cancel".to_owned(), + "abc".to_owned(), + ], + vec![ + "radroots".to_owned(), "listing".to_owned(), "archive".to_owned(), ], @@ -368,11 +412,11 @@ mod tests { let trade = TargetCliArgs::try_parse_from([ "radroots", "trade", - "request", - "trade_test", - "--confirm-public-note", + "proposal", + "submit", + "proposal.json", ]) - .expect("trade request parses"); - assert_eq!(trade.command.operation_id(), "trade.request"); + .expect("trade proposal submit parses"); + assert_eq!(trade.command.operation_id(), "trade.proposal.submit"); } } diff --git a/src/cli/trade.rs b/src/cli/trade.rs @@ -1,4 +1,6 @@ -use clap::{Args, Subcommand}; +use std::path::PathBuf; + +use clap::{Args, Subcommand, ValueEnum}; #[derive(Debug, Clone, Args)] pub struct TradeArgs { @@ -8,19 +10,116 @@ pub struct TradeArgs { #[derive(Debug, Clone, Subcommand)] pub enum TradeCommand { - Request(TradeRequestArgs), + Proposal(TradeProposalArgs), + Revision(TradeRevisionArgs), + Candidate(TradeCandidateArgs), + Cancellation(TradeCancellationArgs), + Operation(TradeOperationArgs), Get(TradeKeyArgs), - List, - Accept(TradeKeyArgs), - Decline(TradeDeclineArgs), - Cancel(TradeCancelArgs), + List(TradeListArgs), + Evidence(TradeEvidenceArgs), + PrivateArtifact(TradePrivateArtifactArgs), } #[derive(Debug, Clone, Args)] -pub struct TradeRequestArgs { - pub trade_id: Option<String>, - #[arg(long)] - pub confirm_public_note: bool, +pub struct TradeProposalArgs { + #[command(subcommand)] + pub command: TradeProposalCommand, +} + +#[derive(Debug, Clone, Args)] +pub struct TradeRevisionArgs { + #[command(subcommand)] + pub command: TradeRevisionCommand, +} + +#[derive(Debug, Clone, Args)] +pub struct TradeCandidateArgs { + #[command(subcommand)] + pub command: TradeCandidateCommand, +} + +#[derive(Debug, Clone, Args)] +pub struct TradeCancellationArgs { + #[command(subcommand)] + pub command: TradeCancellationCommand, +} + +#[derive(Debug, Clone, Args)] +pub struct TradeOperationArgs { + #[command(subcommand)] + pub command: TradeOperationCommand, +} + +#[derive(Debug, Clone, Args)] +pub struct TradeEvidenceArgs { + #[command(subcommand)] + pub command: TradeEvidenceCommand, +} + +#[derive(Debug, Clone, Args)] +pub struct TradePrivateArtifactArgs { + #[command(subcommand)] + pub command: TradePrivateArtifactCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum TradeProposalCommand { + Submit(TradeEnvelopeFileArgs), +} + +#[derive(Debug, Clone, Subcommand)] +pub enum TradeRevisionCommand { + Propose(TradeEnvelopeFileArgs), +} + +#[derive(Debug, Clone, Subcommand)] +pub enum TradeCandidateCommand { + Decide(TradeDecisionEnvelopeArgs), +} + +#[derive(Debug, Clone, Subcommand)] +pub enum TradeCancellationCommand { + Submit(TradeEnvelopeFileArgs), +} + +#[derive(Debug, Clone, Subcommand)] +pub enum TradeOperationCommand { + Resume(TradeResumeArgs), +} + +#[derive(Debug, Clone, Subcommand)] +pub enum TradeEvidenceCommand { + Refresh(TradeKeyArgs), + Inspect(TradeEvidenceInspectArgs), +} + +#[derive(Debug, Clone, Subcommand)] +pub enum TradePrivateArtifactCommand { + Seal(TradePrivateArtifactSealArgs), + Open(TradePrivateArtifactOpenArgs), + Delete(TradePrivateArtifactDeleteArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct TradeEnvelopeFileArgs { + pub file: PathBuf, +} + +#[derive(Debug, Clone, Args)] +pub struct TradeDecisionEnvelopeArgs { + pub file: PathBuf, + #[arg(long = "acknowledge-private-terms")] + pub acknowledge_private_terms: bool, +} + +#[derive(Debug, Clone, Args)] +pub struct TradeResumeArgs { + pub file: PathBuf, + #[arg(long = "operation-kind", value_enum)] + pub operation_kind: Option<TradeResumeOperationKindArg>, + #[arg(long = "acknowledge-private-terms")] + pub acknowledge_private_terms: bool, } #[derive(Debug, Clone, Args)] @@ -29,19 +128,87 @@ pub struct TradeKeyArgs { } #[derive(Debug, Clone, Args)] -pub struct TradeDeclineArgs { +pub struct TradeListArgs { + #[arg(long)] + pub limit: Option<u32>, + #[arg(long)] + pub cursor: Option<String>, +} + +#[derive(Debug, Clone, Args)] +pub struct TradeEvidenceInspectArgs { pub trade_id: Option<String>, #[arg(long)] - pub reason: Option<String>, + pub limit: Option<u32>, #[arg(long)] - pub confirm_public_note: bool, + pub cursor: Option<String>, } #[derive(Debug, Clone, Args)] -pub struct TradeCancelArgs { +pub struct TradePrivateArtifactSealArgs { pub trade_id: Option<String>, + #[arg(long = "artifact-id")] + pub artifact_id: Option<String>, + #[arg(long = "schema-id")] + pub schema_id: Option<String>, #[arg(long)] - pub reason: Option<String>, + pub input: Option<PathBuf>, + #[arg(long, value_enum, default_value_t = TradePrivateArtifactKindArg::BindingTerms)] + pub kind: TradePrivateArtifactKindArg, + #[arg(long = "candidate-id")] + pub candidate_id: Option<String>, + #[arg(long = "retention-class")] + pub retention_class: Option<String>, + #[arg(long = "expires-at-ms")] + pub expires_at_ms: Option<i64>, +} + +#[derive(Debug, Clone, Args)] +pub struct TradePrivateArtifactOpenArgs { + pub artifact_id: String, #[arg(long)] - pub confirm_public_note: bool, + pub output: Option<PathBuf>, +} + +#[derive(Debug, Clone, Args)] +pub struct TradePrivateArtifactDeleteArgs { + pub artifact_id: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum TradeResumeOperationKindArg { + SubmitProposal, + ProposeRevision, + DecideCandidate, + Cancel, +} + +impl TradeResumeOperationKindArg { + pub fn as_sdk_operation_kind(self) -> &'static str { + match self { + Self::SubmitProposal => "trade.submit_proposal.v1", + Self::ProposeRevision => "trade.propose_revision.v1", + Self::DecideCandidate => "trade.decide_candidate.v1", + Self::Cancel => "trade.cancel.v1", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum TradePrivateArtifactKindArg { + BindingTerms, + Message, + ContactBundle, + DeliveryInstruction, +} + +impl TradePrivateArtifactKindArg { + pub fn as_str(self) -> &'static str { + match self { + Self::BindingTerms => "binding_terms", + Self::Message => "message", + Self::ContactBundle => "contact_bundle", + Self::DeliveryInstruction => "delivery_instruction", + } + } } diff --git a/src/generated/runtime_contract_registry.rs b/src/generated/runtime_contract_registry.rs @@ -497,23 +497,83 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ supports_ndjson: false, }, OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::TradeRequest, + runtime_operation_id: RuntimeOperationIdV1::TradeProposalSubmit, descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[33], - operation_id: "trade.request", - cli_path: "radroots trade request", + operation_id: "trade.proposal.submit", + cli_path: "radroots trade proposal submit <file>", namespace: "trade", - mcp_tool: "trade_request", - rust_request: "TradeRequestRequest", - rust_result: "TradeRequestResult", - json_kind: "trade.request", - description: "Request a trade from a quoted basket.", + mcp_tool: "trade_proposal_submit", + rust_request: "TradeProposalSubmitRequest", + rust_result: "TradeProposalSubmitResult", + json_kind: "trade.proposal.submit", + description: "Submit a trade proposal from a complete candidate draft.", role: OperationRole::Buyer, supports_json: true, supports_ndjson: false, }, OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::TradeGet, + runtime_operation_id: RuntimeOperationIdV1::TradeRevisionPropose, descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[34], + operation_id: "trade.revision.propose", + cli_path: "radroots trade revision propose <file>", + namespace: "trade", + mcp_tool: "trade_revision_propose", + rust_request: "TradeRevisionProposeRequest", + rust_result: "TradeRevisionProposeResult", + json_kind: "trade.revision.propose", + description: "Propose a complete replacement candidate for a trade.", + role: OperationRole::Any, + supports_json: true, + supports_ndjson: false, + }, + OperationSpec { + runtime_operation_id: RuntimeOperationIdV1::TradeCandidateDecide, + descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[35], + operation_id: "trade.candidate.decide", + cli_path: "radroots trade candidate decide <file>", + namespace: "trade", + mcp_tool: "trade_candidate_decide", + rust_request: "TradeCandidateDecideRequest", + rust_result: "TradeCandidateDecideResult", + json_kind: "trade.candidate.decide", + description: "Decide a candidate proposal.", + role: OperationRole::Any, + supports_json: true, + supports_ndjson: false, + }, + OperationSpec { + runtime_operation_id: RuntimeOperationIdV1::TradeCancellationSubmit, + descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[36], + operation_id: "trade.cancellation.submit", + cli_path: "radroots trade cancellation submit <file>", + namespace: "trade", + mcp_tool: "trade_cancellation_submit", + rust_request: "TradeCancellationSubmitRequest", + rust_result: "TradeCancellationSubmitResult", + json_kind: "trade.cancellation.submit", + description: "Submit a trade cancellation mutation.", + role: OperationRole::Any, + supports_json: true, + supports_ndjson: false, + }, + OperationSpec { + runtime_operation_id: RuntimeOperationIdV1::TradeOperationResume, + descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[37], + operation_id: "trade.operation.resume", + cli_path: "radroots trade operation resume <file>", + namespace: "trade", + mcp_tool: "trade_operation_resume", + rust_request: "TradeOperationResumeRequest", + rust_result: "TradeOperationResumeResult", + json_kind: "trade.operation.resume", + description: "Resume a prepared trade operation envelope.", + role: OperationRole::Any, + supports_json: true, + supports_ndjson: false, + }, + OperationSpec { + runtime_operation_id: RuntimeOperationIdV1::TradeGet, + descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[38], operation_id: "trade.get", cli_path: "radroots trade get <key>", namespace: "trade", @@ -521,14 +581,14 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ rust_request: "TradeGetRequest", rust_result: "TradeGetResult", json_kind: "trade.get", - description: "Get trade details.", + description: "Get SDK trade projection details.", role: OperationRole::Any, supports_json: true, supports_ndjson: false, }, OperationSpec { runtime_operation_id: RuntimeOperationIdV1::TradeList, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[35], + descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[39], operation_id: "trade.list", cli_path: "radroots trade list", namespace: "trade", @@ -536,59 +596,89 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ rust_request: "TradeListRequest", rust_result: "TradeListResult", json_kind: "trade.list", - description: "List trades.", + description: "List SDK trade projections.", role: OperationRole::Any, supports_json: true, supports_ndjson: true, }, OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::TradeAccept, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[36], - operation_id: "trade.accept", - cli_path: "radroots trade accept <key>", + runtime_operation_id: RuntimeOperationIdV1::TradeEvidenceRefresh, + descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[40], + operation_id: "trade.evidence.refresh", + cli_path: "radroots trade evidence refresh <trade-id>", namespace: "trade", - mcp_tool: "trade_accept", - rust_request: "TradeAcceptRequest", - rust_result: "TradeAcceptResult", - json_kind: "trade.accept", - description: "Accept a trade request.", - role: OperationRole::Seller, + mcp_tool: "trade_evidence_refresh", + rust_request: "TradeEvidenceRefreshRequest", + rust_result: "TradeEvidenceRefreshResult", + json_kind: "trade.evidence.refresh", + description: "Refresh SDK trade evidence projections.", + role: OperationRole::Any, supports_json: true, supports_ndjson: false, }, OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::TradeDecline, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[37], - operation_id: "trade.decline", - cli_path: "radroots trade decline <key>", + runtime_operation_id: RuntimeOperationIdV1::TradeEvidenceInspect, + descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[41], + operation_id: "trade.evidence.inspect", + cli_path: "radroots trade evidence inspect <trade-id>", namespace: "trade", - mcp_tool: "trade_decline", - rust_request: "TradeDeclineRequest", - rust_result: "TradeDeclineResult", - json_kind: "trade.decline", - description: "Decline a trade request.", - role: OperationRole::Seller, + mcp_tool: "trade_evidence_inspect", + rust_request: "TradeEvidenceInspectRequest", + rust_result: "TradeEvidenceInspectResult", + json_kind: "trade.evidence.inspect", + description: "Inspect protected trade evidence metadata.", + role: OperationRole::Any, + supports_json: true, + supports_ndjson: true, + }, + OperationSpec { + runtime_operation_id: RuntimeOperationIdV1::TradePrivateArtifactSeal, + descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[42], + operation_id: "trade.private_artifact.seal", + cli_path: "radroots trade private-artifact seal <trade-id>", + namespace: "trade", + mcp_tool: "trade_private_artifact_seal", + rust_request: "TradePrivateArtifactSealRequest", + rust_result: "TradePrivateArtifactSealResult", + json_kind: "trade.private_artifact.seal", + description: "Seal a protected private trade artifact.", + role: OperationRole::Any, supports_json: true, supports_ndjson: false, }, OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::TradeCancel, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[38], - operation_id: "trade.cancel", - cli_path: "radroots trade cancel <key>", + runtime_operation_id: RuntimeOperationIdV1::TradePrivateArtifactOpen, + descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[43], + operation_id: "trade.private_artifact.open", + cli_path: "radroots trade private-artifact open <artifact-id>", namespace: "trade", - mcp_tool: "trade_cancel", - rust_request: "TradeCancelRequest", - rust_result: "TradeCancelResult", - json_kind: "trade.cancel", - description: "Cancel a trade request.", - role: OperationRole::Buyer, + mcp_tool: "trade_private_artifact_open", + rust_request: "TradePrivateArtifactOpenRequest", + rust_result: "TradePrivateArtifactOpenResult", + json_kind: "trade.private_artifact.open", + description: "Open a protected private trade artifact.", + role: OperationRole::Any, + supports_json: true, + supports_ndjson: false, + }, + OperationSpec { + runtime_operation_id: RuntimeOperationIdV1::TradePrivateArtifactDelete, + descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[44], + operation_id: "trade.private_artifact.delete", + cli_path: "radroots trade private-artifact delete <artifact-id>", + namespace: "trade", + mcp_tool: "trade_private_artifact_delete", + rust_request: "TradePrivateArtifactDeleteRequest", + rust_result: "TradePrivateArtifactDeleteResult", + json_kind: "trade.private_artifact.delete", + description: "Delete a protected private trade artifact.", + role: OperationRole::Any, supports_json: true, supports_ndjson: false, }, OperationSpec { runtime_operation_id: RuntimeOperationIdV1::ValidationStatus, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[39], + descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[45], operation_id: "validation.status", cli_path: "radroots validation status", namespace: "validation", @@ -603,7 +693,7 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ }, OperationSpec { runtime_operation_id: RuntimeOperationIdV1::ValidationReceiptGet, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[40], + descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[46], operation_id: "validation.receipt.get", cli_path: "radroots validation receipt get <receipt-event-id>", namespace: "validation", @@ -618,7 +708,7 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ }, OperationSpec { runtime_operation_id: RuntimeOperationIdV1::ValidationReceiptVerify, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[41], + descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[47], operation_id: "validation.receipt.verify", cli_path: "radroots validation receipt verify <receipt-event-id>", namespace: "validation", @@ -633,7 +723,7 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ }, OperationSpec { runtime_operation_id: RuntimeOperationIdV1::SyncStatus, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[42], + descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[48], operation_id: "sync.status", cli_path: "radroots sync status", namespace: "sync", @@ -648,7 +738,7 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ }, OperationSpec { runtime_operation_id: RuntimeOperationIdV1::SyncPull, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[43], + descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[49], operation_id: "sync.pull", cli_path: "radroots sync pull", namespace: "sync", @@ -663,7 +753,7 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ }, OperationSpec { runtime_operation_id: RuntimeOperationIdV1::SyncPush, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[44], + descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[50], operation_id: "sync.push", cli_path: "radroots sync push", namespace: "sync", @@ -678,7 +768,7 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ }, OperationSpec { runtime_operation_id: RuntimeOperationIdV1::HealthInspect, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[45], + descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[51], operation_id: "health.inspect", cli_path: "radroots health inspect", namespace: "health", @@ -693,7 +783,7 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ }, OperationSpec { runtime_operation_id: RuntimeOperationIdV1::TransportCapabilityList, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[46], + descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[52], operation_id: "transport.capability.list", cli_path: "radroots transport capability list", namespace: "transport", @@ -708,7 +798,7 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ }, OperationSpec { runtime_operation_id: RuntimeOperationIdV1::TransportConfigInspect, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[47], + descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[53], operation_id: "transport.config.inspect", cli_path: "radroots transport config inspect", namespace: "transport", @@ -723,7 +813,7 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ }, OperationSpec { runtime_operation_id: RuntimeOperationIdV1::TransportConfigUpdate, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[48], + descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[54], operation_id: "transport.config.update", cli_path: "radroots transport config update --kind local-only", namespace: "transport", @@ -738,7 +828,7 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ }, OperationSpec { runtime_operation_id: RuntimeOperationIdV1::TransportStatusInspect, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[49], + descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[55], operation_id: "transport.status.inspect", cli_path: "radroots transport status inspect", namespace: "transport", @@ -753,7 +843,7 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ }, OperationSpec { runtime_operation_id: RuntimeOperationIdV1::TransportDeliveryInspect, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[50], + descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[56], operation_id: "transport.delivery.inspect", cli_path: "radroots transport delivery inspect", namespace: "transport", @@ -768,7 +858,7 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ }, OperationSpec { runtime_operation_id: RuntimeOperationIdV1::TransportDeliveryRetry, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[51], + descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[57], operation_id: "transport.delivery.retry", cli_path: "radroots transport delivery retry", namespace: "transport", @@ -783,7 +873,7 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ }, OperationSpec { runtime_operation_id: RuntimeOperationIdV1::DiagnosticsInspect, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[52], + descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[58], operation_id: "diagnostics.inspect", cli_path: "radroots diagnostics inspect", namespace: "diagnostics", diff --git a/src/generated/runtime_contract_target.rs b/src/generated/runtime_contract_target.rs @@ -34,12 +34,18 @@ target_operation_contracts! { BasketItemUpdate => (BasketItemUpdateRequest, BasketItemUpdateResult, "basket.item.update"), BasketItemRemove => (BasketItemRemoveRequest, BasketItemRemoveResult, "basket.item.remove"), BasketQuote => (BasketQuoteRequest, BasketQuoteResult, "basket.quote"), - TradeRequest => (TradeRequestRequest, TradeRequestResult, "trade.request"), + TradeProposalSubmit => (TradeProposalSubmitRequest, TradeProposalSubmitResult, "trade.proposal.submit"), + TradeRevisionPropose => (TradeRevisionProposeRequest, TradeRevisionProposeResult, "trade.revision.propose"), + TradeCandidateDecide => (TradeCandidateDecideRequest, TradeCandidateDecideResult, "trade.candidate.decide"), + TradeCancellationSubmit => (TradeCancellationSubmitRequest, TradeCancellationSubmitResult, "trade.cancellation.submit"), + TradeOperationResume => (TradeOperationResumeRequest, TradeOperationResumeResult, "trade.operation.resume"), TradeGet => (TradeGetRequest, TradeGetResult, "trade.get"), TradeList => (TradeListRequest, TradeListResult, "trade.list"), - TradeAccept => (TradeAcceptRequest, TradeAcceptResult, "trade.accept"), - TradeDecline => (TradeDeclineRequest, TradeDeclineResult, "trade.decline"), - TradeCancel => (TradeCancelRequest, TradeCancelResult, "trade.cancel"), + TradeEvidenceRefresh => (TradeEvidenceRefreshRequest, TradeEvidenceRefreshResult, "trade.evidence.refresh"), + TradeEvidenceInspect => (TradeEvidenceInspectRequest, TradeEvidenceInspectResult, "trade.evidence.inspect"), + TradePrivateArtifactSeal => (TradePrivateArtifactSealRequest, TradePrivateArtifactSealResult, "trade.private_artifact.seal"), + TradePrivateArtifactOpen => (TradePrivateArtifactOpenRequest, TradePrivateArtifactOpenResult, "trade.private_artifact.open"), + TradePrivateArtifactDelete => (TradePrivateArtifactDeleteRequest, TradePrivateArtifactDeleteResult, "trade.private_artifact.delete"), ValidationStatus => (ValidationStatusRequest, ValidationStatusResult, "validation.status"), ValidationReceiptGet => (ValidationReceiptGetRequest, ValidationReceiptGetResult, "validation.receipt.get"), ValidationReceiptVerify => (ValidationReceiptVerifyRequest, ValidationReceiptVerifyResult, "validation.receipt.verify"), diff --git a/src/main.rs b/src/main.rs @@ -224,7 +224,19 @@ fn execute_request( TargetOperationRequest::BasketQuote(request) => { execute_with(BasketOperationService::new(config), request) } - TargetOperationRequest::TradeRequest(request) => { + TargetOperationRequest::TradeProposalSubmit(request) => { + execute_with(TradeOperationService::new(config), request) + } + TargetOperationRequest::TradeRevisionPropose(request) => { + execute_with(TradeOperationService::new(config), request) + } + TargetOperationRequest::TradeCandidateDecide(request) => { + execute_with(TradeOperationService::new(config), request) + } + TargetOperationRequest::TradeCancellationSubmit(request) => { + execute_with(TradeOperationService::new(config), request) + } + TargetOperationRequest::TradeOperationResume(request) => { execute_with(TradeOperationService::new(config), request) } TargetOperationRequest::TradeGet(request) => { @@ -233,13 +245,19 @@ fn execute_request( TargetOperationRequest::TradeList(request) => { execute_with(TradeOperationService::new(config), request) } - TargetOperationRequest::TradeAccept(request) => { + TargetOperationRequest::TradeEvidenceRefresh(request) => { + execute_with(TradeOperationService::new(config), request) + } + TargetOperationRequest::TradeEvidenceInspect(request) => { + execute_with(TradeOperationService::new(config), request) + } + TargetOperationRequest::TradePrivateArtifactSeal(request) => { execute_with(TradeOperationService::new(config), request) } - TargetOperationRequest::TradeDecline(request) => { + TargetOperationRequest::TradePrivateArtifactOpen(request) => { execute_with(TradeOperationService::new(config), request) } - TargetOperationRequest::TradeCancel(request) => { + TargetOperationRequest::TradePrivateArtifactDelete(request) => { execute_with(TradeOperationService::new(config), request) } TargetOperationRequest::ValidationStatus(request) => { diff --git a/src/ops/exec/basket.rs b/src/ops/exec/basket.rs @@ -3,14 +3,12 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; -use radroots_event::order::RadrootsOrderEconomics; use radroots_replica_schema::trade_product::{ITradeProductFieldsFilter, ITradeProductFindMany}; use radroots_replica_store::{ReplicaSql, trade_product}; use radroots_sql_core::SqlxSqliteExecutor; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use crate::cli::global::{OrderDraftAdjustmentArgs, OrderDraftCreateArgs}; use crate::ops::{ BasketCreateRequest, BasketCreateResult, BasketGetRequest, BasketGetResult, BasketItemAddRequest, BasketItemAddResult, BasketItemRemoveRequest, BasketItemRemoveResult, @@ -20,7 +18,7 @@ use crate::ops::{ OperationService, }; use crate::runtime::config::RuntimeConfig; -use crate::view::runtime::OrderNewView; +use crate::runtime::trade::{TradeCandidateDraftCreateArgs, TradeCandidateDraftView}; const BASKET_KIND: &str = "basket_v1"; const BASKET_SOURCE: &str = "local baskets - local first"; @@ -81,7 +79,7 @@ struct BasketQuote { trade_id: String, trade_file: String, #[serde(default, skip_serializing_if = "Option::is_none")] - economics: Option<RadrootsOrderEconomics>, + economics: Option<Value>, ready_for_submit: bool, created_at_unix: u64, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -382,14 +380,13 @@ impl OperationService<BasketQuoteRequest> for BasketOperationService<'_> { .expect("validated basket has one item") .clone(); if request.context.dry_run { - let order = crate::runtime::order::scaffold_preflight( + let trade = crate::runtime::trade::scaffold_proposal_draft_preflight( self.config, - &OrderDraftCreateArgs { + &TradeCandidateDraftCreateArgs { listing: item.listing.clone(), listing_addr: item.listing_addr.clone(), - bin_id: Some(item.bin_id.clone()), - bin_count: Some(item.quantity), - adjustments: order_adjustments_from_basket(&loaded.document), + bin_id: item.bin_id.clone(), + quantity: item.quantity, }, ) .map_err(|error| { @@ -401,38 +398,31 @@ impl OperationService<BasketQuoteRequest> for BasketOperationService<'_> { "basket_id": basket_id, "file": loaded.file.display().to_string(), "item": item, - "trade": order, - "actions": ["radroots basket quote create"], + "trade": trade, + "actions": [format!("radroots basket quote {basket_id}")], })); } - let order = crate::runtime::order::scaffold( + let trade = crate::runtime::trade::scaffold_proposal_draft( self.config, - &OrderDraftCreateArgs { + &TradeCandidateDraftCreateArgs { listing: item.listing.clone(), listing_addr: item.listing_addr.clone(), - bin_id: Some(item.bin_id.clone()), - bin_count: Some(item.quantity), - adjustments: order_adjustments_from_basket(&loaded.document), + bin_id: item.bin_id.clone(), + quantity: item.quantity, }, ) .map_err(|error| OperationAdapterError::runtime_failure(request.operation_id(), error))?; - let quote_economics = order.economics.clone(); + let quote_economics = serde_json::to_value(&trade.economics).ok(); let quote = BasketQuote { - quote_id: quote_economics - .as_ref() - .map(|economics| economics.quote_id.to_string()) - .unwrap_or_else(|| format!("quote_{}", loaded.document.basket.basket_id)), - quote_version: quote_economics - .as_ref() - .map(|economics| economics.quote_version) - .unwrap_or(1), - trade_id: order.order_id.clone(), - trade_file: order.file.clone(), + quote_id: format!("quote_{}", trade.trade_id), + quote_version: 1, + trade_id: trade.trade_id.clone(), + trade_file: trade.file.clone(), economics: quote_economics, - ready_for_submit: order.ready_for_submit, + ready_for_submit: trade.ready_for_submit, created_at_unix: now_unix(), - issues: quote_issues_from_order(&order), + issues: quote_issues_from_trade(&trade), }; loaded.document.quote = Some(quote.clone()); touch_basket(&mut loaded.document); @@ -444,8 +434,8 @@ impl OperationService<BasketQuoteRequest> for BasketOperationService<'_> { "basket_id": loaded.document.basket.basket_id, "file": loaded.file.display().to_string(), "quote": quote, - "trade": order, - "actions": quote_actions(&order), + "trade": trade, + "actions": quote_actions(&trade), })) } } @@ -886,18 +876,18 @@ fn basket_actions(document: &BasketDocument, issues: &[BasketIssue]) -> Vec<Stri } } -fn quote_actions(order: &OrderNewView) -> Vec<String> { - if order.ready_for_submit { - vec![format!("radroots trade request {}", order.order_id)] +fn quote_actions(trade: &TradeCandidateDraftView) -> Vec<String> { + if trade.ready_for_submit { + vec![format!("radroots trade proposal submit {}", trade.file)] } else { - let mut actions = vec![format!("radroots trade get {}", order.order_id)]; - actions.extend(order.actions.iter().cloned()); + let mut actions = vec![format!("radroots trade get {}", trade.trade_id)]; + actions.extend(trade.actions.iter().cloned()); actions } } -fn quote_issues_from_order(order: &OrderNewView) -> Vec<BasketIssue> { - order +fn quote_issues_from_trade(trade: &TradeCandidateDraftView) -> Vec<BasketIssue> { + trade .issues .iter() .map(|issue| BasketIssue { @@ -908,21 +898,6 @@ fn quote_issues_from_order(order: &OrderNewView) -> Vec<BasketIssue> { .collect() } -fn order_adjustments_from_basket(document: &BasketDocument) -> Vec<OrderDraftAdjustmentArgs> { - document - .basket - .adjustments - .iter() - .map(|adjustment| OrderDraftAdjustmentArgs { - id: adjustment.id.clone(), - effect: adjustment.effect.clone(), - amount: adjustment.amount.clone(), - currency: adjustment.currency.clone(), - reason: adjustment.reason.clone(), - }) - .collect() -} - fn load_required_basket( config: &RuntimeConfig, lookup: &str, diff --git a/src/ops/exec/core.rs b/src/ops/exec/core.rs @@ -614,7 +614,7 @@ fn health_actions( } if let Some(resolved) = account.resolved_account.as_ref() { if !resolved.write_capable { - push_unique(&mut actions, "radroots account attach-secret"); + push_unique(&mut actions, "radroots account create"); } } else { push_unique(&mut actions, "radroots account create"); @@ -700,7 +700,7 @@ fn publish_recovery_actions( push_unique(&mut actions, "radroots signer status"); } else if let Some(resolved) = account.resolved_account.as_ref() { if !resolved.write_capable { - push_unique(&mut actions, "radroots account attach-secret"); + push_unique(&mut actions, "radroots account create"); } } else { push_unique(&mut actions, "radroots account create"); diff --git a/src/ops/exec/trade.rs b/src/ops/exec/trade.rs @@ -1,20 +1,25 @@ +use std::path::PathBuf; + use serde::Serialize; use serde_json::{Value, json}; -use crate::cli::global::{ - RecordLookupArgs, TradeCancelArgs, TradeDecisionArg, TradeDecisionArgs, TradeRequestArgs, -}; use crate::ops::{ OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, - OperationResult, OperationResultData, OperationService, TradeAcceptRequest, TradeAcceptResult, - TradeCancelRequest, TradeCancelResult, TradeDeclineRequest, TradeDeclineResult, - TradeGetRequest, TradeGetResult, TradeListRequest, TradeListResult, TradeRequestRequest, - TradeRequestResult, + OperationResult, OperationResultData, OperationService, TradeCancellationSubmitRequest, + TradeCancellationSubmitResult, TradeCandidateDecideRequest, TradeCandidateDecideResult, + TradeEvidenceInspectRequest, TradeEvidenceInspectResult, TradeEvidenceRefreshRequest, + TradeEvidenceRefreshResult, TradeGetRequest, TradeGetResult, TradeListRequest, TradeListResult, + TradeOperationResumeRequest, TradeOperationResumeResult, TradePrivateArtifactDeleteRequest, + TradePrivateArtifactDeleteResult, TradePrivateArtifactOpenRequest, + TradePrivateArtifactOpenResult, TradePrivateArtifactSealRequest, + TradePrivateArtifactSealResult, TradeProposalSubmitRequest, TradeProposalSubmitResult, + TradeRevisionProposeRequest, TradeRevisionProposeResult, }; -use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; -use crate::view::runtime::{ - CommandDisposition, OrderCancellationView, OrderDecisionView, OrderSubmitView, +use crate::runtime::trade::{ + TradeEnvelopeFileRuntimeArgs, TradeEvidenceInspectRuntimeArgs, TradeIdRuntimeArgs, + TradePageRuntimeArgs, TradePrivateArtifactDeleteRuntimeArgs, + TradePrivateArtifactOpenRuntimeArgs, TradePrivateArtifactSealRuntimeArgs, }; pub struct TradeOperationService<'a> { @@ -27,31 +32,73 @@ impl<'a> TradeOperationService<'a> { } } -impl OperationService<TradeRequestRequest> for TradeOperationService<'_> { - type Result = TradeRequestResult; +impl OperationService<TradeProposalSubmitRequest> for TradeOperationService<'_> { + type Result = TradeProposalSubmitResult; fn execute( &self, - request: OperationRequest<TradeRequestRequest>, + request: OperationRequest<TradeProposalSubmitRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let key = required_trade_key(&request)?; - let args = TradeRequestArgs { - key, - idempotency_key: request - .context - .idempotency_key - .clone() - .or_else(|| string_input(&request, "idempotency_key")), - confirm_public_note: bool_input(&request, "confirm_public_note").unwrap_or(false), - }; - let mut config = self.config.clone(); - if request.context.dry_run { - config.output.dry_run = true; - } - let view = crate::runtime::order::submit(&config, &args).map_err(|error| { - OperationAdapterError::sdk_adapter_failure(request.operation_id(), error) - })?; - submit_result::<TradeRequestResult>(request.operation_id(), &view) + let args = envelope_args(&request)?; + let receipt = crate::runtime::trade::submit_proposal(self.config, &args) + .map_err(|error| sdk_failure(request.operation_id(), error))?; + serialized_target_result(&receipt) + } +} + +impl OperationService<TradeRevisionProposeRequest> for TradeOperationService<'_> { + type Result = TradeRevisionProposeResult; + + fn execute( + &self, + request: OperationRequest<TradeRevisionProposeRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let args = envelope_args(&request)?; + let receipt = crate::runtime::trade::propose_revision(self.config, &args) + .map_err(|error| sdk_failure(request.operation_id(), error))?; + serialized_target_result(&receipt) + } +} + +impl OperationService<TradeCandidateDecideRequest> for TradeOperationService<'_> { + type Result = TradeCandidateDecideResult; + + fn execute( + &self, + request: OperationRequest<TradeCandidateDecideRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let args = envelope_args(&request)?; + let receipt = crate::runtime::trade::decide_candidate(self.config, &args) + .map_err(|error| sdk_failure(request.operation_id(), error))?; + serialized_target_result(&receipt) + } +} + +impl OperationService<TradeCancellationSubmitRequest> for TradeOperationService<'_> { + type Result = TradeCancellationSubmitResult; + + fn execute( + &self, + request: OperationRequest<TradeCancellationSubmitRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let args = envelope_args(&request)?; + let receipt = crate::runtime::trade::cancel_trade(self.config, &args) + .map_err(|error| sdk_failure(request.operation_id(), error))?; + serialized_target_result(&receipt) + } +} + +impl OperationService<TradeOperationResumeRequest> for TradeOperationService<'_> { + type Result = TradeOperationResumeResult; + + fn execute( + &self, + request: OperationRequest<TradeOperationResumeRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let args = envelope_args(&request)?; + let receipt = crate::runtime::trade::resume_operation(self.config, &args) + .map_err(|error| sdk_failure(request.operation_id(), error))?; + serialized_target_result(&receipt) } } @@ -62,11 +109,12 @@ impl OperationService<TradeGetRequest> for TradeOperationService<'_> { &self, request: OperationRequest<TradeGetRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = RecordLookupArgs { - key: required_trade_key(&request)?, + let args = TradeIdRuntimeArgs { + trade_id: required_string(&request, "trade_id")?, }; - let view = map_runtime(crate::runtime::order::get(self.config, &args))?; - serialized_target_result::<TradeGetResult, _>(&view) + let view = crate::runtime::trade::get_trade(self.config, &args) + .map_err(|error| sdk_failure(request.operation_id(), error))?; + serialized_target_result(&view) } } @@ -75,302 +123,134 @@ impl OperationService<TradeListRequest> for TradeOperationService<'_> { fn execute( &self, - _request: OperationRequest<TradeListRequest>, + request: OperationRequest<TradeListRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let view = map_runtime(crate::runtime::order::list(self.config))?; - serialized_target_result::<TradeListResult, _>(&view) + let args = TradePageRuntimeArgs { + limit: u32_input(&request, "limit")?, + cursor: string_input(&request, "cursor"), + }; + let page = crate::runtime::trade::list_trades(self.config, &args) + .map_err(|error| sdk_failure(request.operation_id(), error))?; + serialized_target_result(&page) } } -impl OperationService<TradeAcceptRequest> for TradeOperationService<'_> { - type Result = TradeAcceptResult; +impl OperationService<TradeEvidenceRefreshRequest> for TradeOperationService<'_> { + type Result = TradeEvidenceRefreshResult; fn execute( &self, - request: OperationRequest<TradeAcceptRequest>, + request: OperationRequest<TradeEvidenceRefreshRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = TradeDecisionArgs { - key: required_trade_key(&request)?, - decision: TradeDecisionArg::Accept, - reason: None, - idempotency_key: request - .context - .idempotency_key - .clone() - .or_else(|| string_input(&request, "idempotency_key")), - confirm_public_note: false, + let args = TradeIdRuntimeArgs { + trade_id: required_string(&request, "trade_id")?, }; - let mut config = self.config.clone(); - if request.context.dry_run { - config.output.dry_run = true; - } - let view = crate::runtime::order::decide(&config, &args).map_err(|error| { - OperationAdapterError::sdk_adapter_failure(request.operation_id(), error) - })?; - decision_result::<TradeAcceptResult>(request.operation_id(), &view) + let receipt = crate::runtime::trade::refresh_evidence(self.config, &args) + .map_err(|error| sdk_failure(request.operation_id(), error))?; + serialized_target_result(&receipt) } } -impl OperationService<TradeDeclineRequest> for TradeOperationService<'_> { - type Result = TradeDeclineResult; +impl OperationService<TradeEvidenceInspectRequest> for TradeOperationService<'_> { + type Result = TradeEvidenceInspectResult; fn execute( &self, - request: OperationRequest<TradeDeclineRequest>, + request: OperationRequest<TradeEvidenceInspectRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let reason = string_input(&request, "reason") - .map(|reason| reason.trim().to_owned()) - .filter(|reason| !reason.is_empty()) - .ok_or_else(|| { - invalid_input( - request.operation_id(), - "missing required `reason` input".to_owned(), - ) - })?; - - let args = TradeDecisionArgs { - key: required_trade_key(&request)?, - decision: TradeDecisionArg::Decline, - reason: Some(reason), - idempotency_key: request - .context - .idempotency_key - .clone() - .or_else(|| string_input(&request, "idempotency_key")), - confirm_public_note: bool_input(&request, "confirm_public_note").unwrap_or(false), + let args = TradeEvidenceInspectRuntimeArgs { + trade_id: required_string(&request, "trade_id")?, + limit: u32_input(&request, "limit")?, + cursor: string_input(&request, "cursor"), }; - let mut config = self.config.clone(); - if request.context.dry_run { - config.output.dry_run = true; - } - let view = crate::runtime::order::decide(&config, &args).map_err(|error| { - OperationAdapterError::sdk_adapter_failure(request.operation_id(), error) - })?; - decision_result::<TradeDeclineResult>(request.operation_id(), &view) + let page = crate::runtime::trade::inspect_evidence(self.config, &args) + .map_err(|error| sdk_failure(request.operation_id(), error))?; + serialized_target_result(&page) } } -impl OperationService<TradeCancelRequest> for TradeOperationService<'_> { - type Result = TradeCancelResult; +impl OperationService<TradePrivateArtifactSealRequest> for TradeOperationService<'_> { + type Result = TradePrivateArtifactSealResult; fn execute( &self, - request: OperationRequest<TradeCancelRequest>, + request: OperationRequest<TradePrivateArtifactSealRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let reason = string_input(&request, "reason") - .map(|reason| reason.trim().to_owned()) - .filter(|reason| !reason.is_empty()) - .ok_or_else(|| { - invalid_input( - request.operation_id(), - "missing required `reason` input".to_owned(), - ) - })?; - - let args = TradeCancelArgs { - key: required_trade_key(&request)?, - reason, - idempotency_key: request - .context - .idempotency_key - .clone() - .or_else(|| string_input(&request, "idempotency_key")), - confirm_public_note: bool_input(&request, "confirm_public_note").unwrap_or(false), + let args = TradePrivateArtifactSealRuntimeArgs { + trade_id: required_string(&request, "trade_id")?, + artifact_id: required_string(&request, "artifact_id")?, + schema_id: required_string(&request, "schema_id")?, + input: required_path(&request, "input")?, + kind: string_input(&request, "kind").unwrap_or_else(|| "binding_terms".to_owned()), + candidate_id: string_input(&request, "candidate_id"), + retention_class: string_input(&request, "retention_class"), + expires_at_ms: i64_input(&request, "expires_at_ms")?, }; - let mut config = self.config.clone(); - if request.context.dry_run { - config.output.dry_run = true; - } - let view = crate::runtime::order::cancel(&config, &args).map_err(|error| { - OperationAdapterError::sdk_adapter_failure(request.operation_id(), error) - })?; - cancellation_result::<TradeCancelResult>(request.operation_id(), &view) + let receipt = crate::runtime::trade::seal_private_artifact(self.config, &args) + .map_err(|error| sdk_failure(request.operation_id(), error))?; + serialized_target_result(&receipt) } } -fn decision_result<R>( - operation_id: &str, - view: &OrderDecisionView, -) -> Result<OperationResult<R>, OperationAdapterError> -where - R: OperationResultData, -{ - match view.disposition() { - CommandDisposition::Success => serialized_target_result::<R, _>(view), - CommandDisposition::ValidationFailed => { - let message = view.reason.clone().unwrap_or_else(|| { - format!( - "order decision failed validation with state `{}`", - view.state - ) - }); - Err(OperationAdapterError::validation_failed_with_detail( - operation_id, - message, - order_decision_error_detail(view), - )) - } - disposition => { - let message = view - .reason - .clone() - .unwrap_or_else(|| format!("order decision finished with state `{}`", view.state)); - if disposition == CommandDisposition::ExternalUnavailable { - let detail = order_decision_error_detail(view); - if !view.failed_transport_targets.is_empty() - && view.attempted_transport_endpoints.is_empty() - { - Err(OperationAdapterError::network_unavailable_with_detail( - operation_id, - message, - detail, - )) - } else { - Err(OperationAdapterError::operation_unavailable_with_detail( - operation_id, - message, - detail, - )) - } - } else if disposition == CommandDisposition::Unconfigured { - Err(OperationAdapterError::operation_unavailable_with_detail( - operation_id, - message, - order_decision_error_detail(view), - )) - } else if disposition == CommandDisposition::NotFound { - Err(OperationAdapterError::not_found_with_detail( - operation_id, - message, - order_decision_error_detail(view), - )) - } else { - Err(OperationAdapterError::from_command_disposition( - operation_id, - disposition, - message, - )) - } +impl OperationService<TradePrivateArtifactOpenRequest> for TradeOperationService<'_> { + type Result = TradePrivateArtifactOpenResult; + + fn execute( + &self, + request: OperationRequest<TradePrivateArtifactOpenRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let args = TradePrivateArtifactOpenRuntimeArgs { + artifact_id: required_string(&request, "artifact_id")?, + output: required_path(&request, "output")?, + }; + let view = crate::runtime::trade::open_private_artifact(self.config, &args) + .map_err(|error| sdk_failure(request.operation_id(), error))?; + if view.state == "missing" { + return Err(OperationAdapterError::not_found_with_detail( + request.operation_id(), + format!("private artifact `{}` was not found", view.artifact_id), + json!({ + "artifact_id": view.artifact_id, + "output": view.output, + }), + )); } + serialized_target_result(&view) } } -fn order_decision_error_detail(view: &OrderDecisionView) -> Value { - json!({ - "state": &view.state, - "trade_id": &view.order_id, - "locator": &view.locator, - "listing_addr": &view.listing_addr, - "listing_event_id": &view.listing_event_id, - "request_event_id": &view.request_event_id, - "root_event_id": &view.root_event_id, - "prev_event_id": &view.prev_event_id, - "event_id": &view.event_id, - "event_kind": view.event_kind, - "inventory": &view.inventory, - "buyer_pubkey": &view.buyer_pubkey, - "seller_pubkey": &view.seller_pubkey, - "decision": &view.decision, - "dry_run": view.dry_run, - "target_transport_endpoints": &view.target_transport_endpoints, - "attempted_transport_endpoints": &view.attempted_transport_endpoints, - "accepted_transport_endpoints": &view.accepted_transport_endpoints, - "failed_transport_targets": &view.failed_transport_targets, - "fetched_count": view.fetched_count, - "decoded_count": view.decoded_count, - "skipped_count": view.skipped_count, - "idempotency_key": &view.idempotency_key, - "signer_mode": &view.signer_mode, - "issues": &view.issues, - "actions": &view.actions, - }) -} +impl OperationService<TradePrivateArtifactDeleteRequest> for TradeOperationService<'_> { + type Result = TradePrivateArtifactDeleteResult; -fn cancellation_result<R>( - operation_id: &str, - view: &OrderCancellationView, -) -> Result<OperationResult<R>, OperationAdapterError> -where - R: OperationResultData, -{ - match view.disposition() { - CommandDisposition::Success => serialized_target_result::<R, _>(view), - CommandDisposition::ValidationFailed => { - let message = view.reason.clone().unwrap_or_else(|| { - format!("order cancel failed validation with state `{}`", view.state) - }); - Err(OperationAdapterError::validation_failed_with_detail( - operation_id, - message, - order_cancellation_error_detail(view), - )) - } - disposition => { - let message = view - .reason - .clone() - .unwrap_or_else(|| format!("order cancel finished with state `{}`", view.state)); - if disposition == CommandDisposition::ExternalUnavailable { - let detail = order_cancellation_error_detail(view); - if !view.failed_transport_targets.is_empty() - && view.attempted_transport_endpoints.is_empty() - { - Err(OperationAdapterError::network_unavailable_with_detail( - operation_id, - message, - detail, - )) - } else { - Err(OperationAdapterError::operation_unavailable_with_detail( - operation_id, - message, - detail, - )) - } - } else if disposition == CommandDisposition::Unconfigured { - Err(OperationAdapterError::operation_unavailable_with_detail( - operation_id, - message, - order_cancellation_error_detail(view), - )) - } else { - Err(OperationAdapterError::from_command_disposition( - operation_id, - disposition, - message, - )) - } - } + fn execute( + &self, + request: OperationRequest<TradePrivateArtifactDeleteRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let args = TradePrivateArtifactDeleteRuntimeArgs { + artifact_id: required_string(&request, "artifact_id")?, + }; + let receipt = crate::runtime::trade::delete_private_artifact(self.config, &args) + .map_err(|error| sdk_failure(request.operation_id(), error))?; + serialized_target_result(&receipt) } } -fn order_cancellation_error_detail(view: &OrderCancellationView) -> Value { - json!({ - "state": &view.state, - "trade_id": &view.order_id, - "locator": &view.locator, - "listing_addr": &view.listing_addr, - "request_event_id": &view.request_event_id, - "decision_event_id": &view.decision_event_id, - "root_event_id": &view.root_event_id, - "prev_event_id": &view.prev_event_id, - "event_id": &view.event_id, - "event_kind": view.event_kind, - "buyer_pubkey": &view.buyer_pubkey, - "seller_pubkey": &view.seller_pubkey, - "cancellation_reason": &view.cancellation_reason, - "dry_run": view.dry_run, - "target_transport_endpoints": &view.target_transport_endpoints, - "attempted_transport_endpoints": &view.attempted_transport_endpoints, - "accepted_transport_endpoints": &view.accepted_transport_endpoints, - "failed_transport_targets": &view.failed_transport_targets, - "fetched_count": view.fetched_count, - "decoded_count": view.decoded_count, - "skipped_count": view.skipped_count, - "idempotency_key": &view.idempotency_key, - "signer_mode": &view.signer_mode, - "issues": &view.issues, - "actions": &view.actions, +fn envelope_args<P>( + request: &OperationRequest<P>, +) -> Result<TradeEnvelopeFileRuntimeArgs, OperationAdapterError> +where + P: OperationRequestPayload + OperationRequestData, +{ + Ok(TradeEnvelopeFileRuntimeArgs { + file: required_path(request, "file")?, + idempotency_key: request + .context + .idempotency_key + .clone() + .or_else(|| string_input(request, "idempotency_key")), + acknowledge_private_terms: bool_input(request, "acknowledge_private_terms") + .unwrap_or(false), + operation_kind: string_input(request, "operation_kind"), }) } @@ -382,105 +262,17 @@ where OperationResult::new(R::from_serializable(value)?) } -fn submit_result<R>( - operation_id: &str, - view: &OrderSubmitView, -) -> Result<OperationResult<R>, OperationAdapterError> -where - R: OperationResultData, -{ - match view.disposition() { - CommandDisposition::Success => serialized_target_result::<R, _>(view), - disposition => { - let message = view - .reason - .clone() - .unwrap_or_else(|| format!("order submit finished with state `{}`", view.state)); - let detail = order_submit_error_detail(view); - match disposition { - CommandDisposition::NotFound => Err(OperationAdapterError::not_found_with_detail( - operation_id, - message, - detail, - )), - CommandDisposition::ValidationFailed => { - Err(OperationAdapterError::validation_failed_with_detail( - operation_id, - message, - detail, - )) - } - CommandDisposition::Unconfigured => { - Err(OperationAdapterError::operation_unavailable_with_detail( - operation_id, - message, - detail, - )) - } - CommandDisposition::ExternalUnavailable => { - if !view.failed_transport_targets.is_empty() - && view.attempted_transport_endpoints.is_empty() - { - Err(OperationAdapterError::network_unavailable_with_detail( - operation_id, - message, - detail, - )) - } else { - Err(OperationAdapterError::operation_unavailable_with_detail( - operation_id, - message, - detail, - )) - } - } - _ => Err(OperationAdapterError::from_command_disposition( - operation_id, - disposition, - message, - )), - } - } - } -} - -fn order_submit_error_detail(view: &OrderSubmitView) -> Value { - json!({ - "state": &view.state, - "source": &view.source, - "trade_id": &view.order_id, - "locator": &view.locator, - "file": &view.file, - "listing_lookup": &view.listing_lookup, - "listing_addr": &view.listing_addr, - "listing_event_id": &view.listing_event_id, - "listing_relays": &view.listing_relays, - "buyer_account_id": &view.buyer_account_id, - "buyer_pubkey": &view.buyer_pubkey, - "seller_pubkey": &view.seller_pubkey, - "event_id": &view.event_id, - "event_kind": view.event_kind, - "dry_run": view.dry_run, - "deduplicated": view.deduplicated, - "target_transport_endpoints": &view.target_transport_endpoints, - "attempted_transport_endpoints": &view.attempted_transport_endpoints, - "accepted_transport_endpoints": &view.accepted_transport_endpoints, - "failed_transport_targets": &view.failed_transport_targets, - "idempotency_key": &view.idempotency_key, - "signer_mode": &view.signer_mode, - "issues": &view.issues, - "actions": &view.actions, - }) -} - -fn required_trade_key<P>(request: &OperationRequest<P>) -> Result<String, OperationAdapterError> +fn required_string<P>( + request: &OperationRequest<P>, + key: &str, +) -> Result<String, OperationAdapterError> where P: OperationRequestPayload + OperationRequestData, { - string_input(request, "trade_id").ok_or_else(|| { + string_input(request, key).ok_or_else(|| { invalid_input( request.operation_id(), - "missing required `trade_id` input".to_owned(), + format!("missing required `{key}` input"), ) }) } @@ -494,6 +286,8 @@ where .input() .get(key) .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) .map(str::to_owned) } @@ -504,8 +298,75 @@ where request.payload.input().get(key).and_then(Value::as_bool) } -fn map_runtime<T>(result: Result<T, RuntimeError>) -> Result<T, OperationAdapterError> { - result.map_err(|error| OperationAdapterError::Runtime(error.to_string())) +fn u32_input<P>( + request: &OperationRequest<P>, + key: &str, +) -> Result<Option<u32>, OperationAdapterError> +where + P: OperationRequestPayload + OperationRequestData, +{ + request + .payload + .input() + .get(key) + .map(|value| { + value + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| { + invalid_input( + request.operation_id(), + format!("`{key}` must be an unsigned 32-bit integer"), + ) + }) + }) + .transpose() +} + +fn i64_input<P>( + request: &OperationRequest<P>, + key: &str, +) -> Result<Option<i64>, OperationAdapterError> +where + P: OperationRequestPayload + OperationRequestData, +{ + request + .payload + .input() + .get(key) + .map(|value| { + value.as_i64().ok_or_else(|| { + invalid_input( + request.operation_id(), + format!("`{key}` must be a signed 64-bit integer"), + ) + }) + }) + .transpose() +} + +fn required_path<P>( + request: &OperationRequest<P>, + key: &str, +) -> Result<PathBuf, OperationAdapterError> +where + P: OperationRequestPayload + OperationRequestData, +{ + string_input(request, key) + .map(PathBuf::from) + .ok_or_else(|| { + invalid_input( + request.operation_id(), + format!("missing required `{key}` input"), + ) + }) +} + +fn sdk_failure( + operation_id: &str, + error: crate::runtime::sdk::CliSdkAdapterError, +) -> OperationAdapterError { + OperationAdapterError::sdk_adapter_failure(operation_id, error) } fn invalid_input(operation_id: &str, message: String) -> OperationAdapterError { diff --git a/src/out/envelope.rs b/src/out/envelope.rs @@ -547,14 +547,14 @@ mod tests { CliExitCode::ApprovalRequiredOrDenied, ); let envelope = OutputEnvelope::failure( - "trade.request", + "trade.proposal.submit", error, EnvelopeContext::new("req_order", false), ); let value = serde_json::to_value(envelope).expect("serialize envelope"); assert_eq!(value["schema_version"], OUTPUT_SCHEMA_VERSION); - assert_eq!(value["operation_id"], "trade.request"); + assert_eq!(value["operation_id"], "trade.proposal.submit"); assert_eq!(value["status"], "error"); assert_eq!(value["reason_code"], "approval_required"); assert_eq!(value["result"], Value::Null); @@ -577,7 +577,7 @@ mod tests { ] })); let envelope = OutputEnvelope::failure( - "trade.request", + "trade.proposal.submit", error, EnvelopeContext::new("req_order", true), ); diff --git a/src/out/terminal/actions.rs b/src/out/terminal/actions.rs @@ -88,10 +88,8 @@ pub fn terminal_action_from_command(command: &str) -> TerminalAction { } fn command_requires_placeholder(command: &str) -> bool { - matches!( - command, - "radroots account attach-secret" | "radroots listing publish" - ) || basket_item_add_requires_placeholder(command) + matches!(command, "radroots listing publish") + || basket_item_add_requires_placeholder(command) || basket_item_update_requires_placeholder(command) || basket_adjustment_add_requires_placeholder(command) || basket_adjustment_remove_requires_placeholder(command) @@ -115,9 +113,6 @@ fn command_with_placeholder(command: &str) -> String { return basket_quote_create_placeholder(command); } match command { - "radroots account attach-secret" => { - "radroots account attach-secret <account> <identity.json>" - } "radroots listing publish" => "radroots listing publish <file>", other => other, } diff --git a/src/out/terminal/errors.rs b/src/out/terminal/errors.rs @@ -42,7 +42,7 @@ mod tests { fn builds_terminal_error_document() { let error = OutputError::new("invalid_input", "missing input", CliExitCode::InvalidInput); let envelope = OutputEnvelope::failure( - "trade.request", + "trade.proposal.submit", error, EnvelopeContext::new("req_test", false), ); diff --git a/src/runtime/account.rs b/src/runtime/account.rs @@ -315,7 +315,7 @@ pub fn attach_identity_secret( snapshot_account( &snapshot, &attached.account_id, - "attached account missing after account attach-secret", + "attached account missing after account secret attachment", ) } diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs @@ -670,10 +670,7 @@ fn transport_farm_publish_readiness( account::AccountRuntimeFailure::watch_only(&account.record.account_id).to_string(), ), missing: vec!["Write-capable farm-bound seller account".to_owned()], - actions: vec![format!( - "radroots account attach-secret {} <path>", - account.record.account_id - )], + actions: vec!["radroots account create".to_owned()], }; } diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs @@ -7,7 +7,6 @@ pub mod hyf; pub mod listing; pub mod logging; pub mod mesh; -pub mod order; pub mod paths; pub mod provider; pub mod runtime_store; @@ -15,6 +14,7 @@ pub mod sdk; pub mod signer; pub mod store; pub mod sync; +pub mod trade; pub mod transport; pub mod validation_receipt; diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -1,4847 +0,0 @@ -mod sdk_status; - -use std::collections::{HashMap, HashSet}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; - -use radroots_authority::RadrootsActorContext; -use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountScope, - RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreMoney, RadrootsCoreUnit, - convert_unit_decimal, -}; -use radroots_event::RadrootsEventPtr; -use radroots_event::contract::RadrootsActorRole; -use radroots_event::ids::{ - RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId, - RadrootsOrderQuoteId, RadrootsPublicKey, -}; -use radroots_event::kinds::{ - KIND_LISTING, KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST, -}; -use radroots_event::order::{ - RadrootsOrderEconomicActor, RadrootsOrderEconomicEffect, RadrootsOrderEconomicItem, - RadrootsOrderEconomicLine, RadrootsOrderEconomicLineKind, RadrootsOrderEconomics, - RadrootsOrderEventType, RadrootsOrderInventoryCommitment, RadrootsOrderItem, - RadrootsOrderPricingBasis, RadrootsOrderRequest, -}; -use radroots_event_codec::d_tag::is_d_tag_base64url; -use radroots_event_codec::order::{order_event_context_from_tags, order_request_from_event}; -use radroots_nostr::prelude::{ - RadrootsNostrEvent, RadrootsNostrFilter, radroots_event_from_nostr, radroots_nostr_filter_tag, - radroots_nostr_kind, -}; -use radroots_replica_schema::nostr_event_head::{ - INostrEventHeadFindOne, INostrEventHeadFindOneArgs, NostrEventHeadQueryBindValues, -}; -use radroots_replica_schema::trade_product::{ - ITradeProductFieldsFilter, ITradeProductFindMany, TradeProduct, -}; -use radroots_replica_store::{ - ReplicaSql, ReplicaTradeProductSummaryRow, nostr_event_head, trade_product, -}; -use radroots_runtime_store::{ - BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, PublishOutboxStatus, RuntimeStoreRecord, - RuntimeStoreRecordFamily, RuntimeStoreRecordStatus, SourceRuntime, - validate_supported_buyer_order_request_local_work_payload, -}; -use radroots_sdk::{ - PrivacyPreflightConfirmation, ProductSensitivityField, PublishMode, PushOutboxEventReceipt, - PushOutboxEventState, PushOutboxReceipt, PushOutboxTargetOutcomeKind, - RadrootsTradeValidationTrustPolicy, SatisfactionPolicy, SdkMutationState, SdkTradeStatusSource, - TargetPolicy, TradeAcceptRequest, TradeCancelRequest, TradeCancellationPlan, - TradeCancellationReceipt, TradeDecisionPlan, TradeDecisionReceipt, TradeDeclineRequest, - TradeEvidenceMode, TradeMutationOutcome, TradeProposeRequest, TradeStatusReceipt, - TradeStatusRequest, TradeSubmitPlan, TradeSubmitReceipt, TradeWorkflowEnqueueReceipt, -}; -use radroots_sql_core::SqlxSqliteExecutor; -use radroots_trade::identity::RadrootsTradeLocator; -use radroots_trade::order::canonicalize_order_request_for_signer; -use radroots_trade::validation_receipt::{RadrootsValidatorSetV1, validator_set_address_from_str}; -use radroots_transport_nostr::{ - RadrootsRelayFetchFailure, RadrootsRelayFetchedEventsReceipt, RadrootsRelayTransportError, - RadrootsRelayUrl, RadrootsRelayUrlPolicy, -}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; - -use crate::cli::global::{ - OrderDraftCreateArgs, RecordLookupArgs, TradeAppRecordExportArgs, TradeCancelArgs, - TradeDecisionArg, TradeDecisionArgs, TradeRebindArgs, TradeRequestArgs, TradeStatusArgs, -}; -use crate::runtime::RuntimeError; -use crate::runtime::account; -use crate::runtime::config::RuntimeConfig; -use crate::runtime::runtime_store::{ - get_shared_record, list_shared_records_before, list_shared_records_latest, - shared_runtime_store_db_path, -}; -use crate::runtime::sdk::{ - CliSdkAdapterError, CliSdkSession, fetch_relay_events_via_shared_transport, - sdk_target_outcome_kind_label, sdk_transport_outcome_kind_label, -}; -use crate::runtime::sync::{RelayIngestScope, relay_provenance_relays_for_scope}; -use crate::view::runtime::{ - OrderAppRecordExportView, OrderAppRecordListView, OrderAppRecordSummaryView, - OrderCancellationView, OrderDecisionView, OrderDraftItemView, OrderEventListEntryView, - OrderEventListView, OrderGetView, OrderIssueView, OrderListView, OrderNewView, OrderRebindView, - OrderStatusView, OrderSubmitView, OrderSummaryView, OrderTradeLocatorView, - TransportTargetFailureView, -}; - -use self::sdk_status::sdk_order_status_view; - -const ORDER_DRAFT_KIND: &str = "order_draft_v1"; -const ORDER_SOURCE: &str = "local trade drafts · local first"; -const ORDER_APP_RECORD_SOURCE: &str = "app-authored shared local trade records"; -const ORDER_SUBMIT_SOURCE: &str = "SDK trade request · local key"; -const ORDER_DECISION_SOURCE: &str = "SDK trade decision · local key"; -const ORDER_CANCELLATION_SOURCE: &str = "SDK trade cancellation · local key"; -const ORDER_EVENT_LIST_SOURCE: &str = "shared Nostr transport fetch · selected seller identity"; -const ORDER_STATUS_SDK_SOURCE: &str = "SDK local trade projection"; -const ORDER_EVENT_LIST_RELAY_ACTION: &str = - "radroots transport config update --kind nostr --nostr-relay wss://relay.example.com"; -const ORDER_BUYER_ACTOR_SOURCE_RESOLVED_ACCOUNT: &str = "resolved_account"; -const ORDER_BUYER_ACTOR_SOURCE_REBIND: &str = "order_rebind"; -const ORDER_APP_RECORD_LIST_LIMIT: u32 = 500; -const ORDER_ACTOR_CONTEXT_ORDER_DRAFT: &str = "order_draft"; -const ORDER_ACTOR_CONTEXT_RESOLVED_ACCOUNT: &str = "resolved_account"; -const ORDER_ACTOR_CONTEXT_NETWORK_ONLY: &str = "network_only"; -const ORDER_ACTOR_CONTEXT_SDK_LOCAL: &str = "sdk_local_projection"; -const ORDERS_DIR: &str = "orders/drafts"; -const APP_ORDER_ALREADY_SUBMITTED_ISSUE: &str = "app_order_already_submitted"; -const APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE: &str = "app_order_signed_evidence_conflict"; - -static ORDER_COUNTER: AtomicU64 = AtomicU64::new(0); - -fn order_locator_view_from_key(key: &str) -> OrderTradeLocatorView { - OrderTradeLocatorView { - trade_id: key.to_owned(), - root_event_id: None, - listing_addr: None, - buyer_pubkey: None, - seller_pubkey: None, - } -} - -fn order_locator_view_from_locator(locator: &RadrootsTradeLocator) -> OrderTradeLocatorView { - OrderTradeLocatorView { - trade_id: locator.trade_id.as_str().to_owned(), - root_event_id: locator.root_event_id.as_ref().map(ToString::to_string), - listing_addr: locator.listing_addr.as_ref().map(ToString::to_string), - buyer_pubkey: locator.buyer_pubkey.as_ref().map(ToString::to_string), - seller_pubkey: locator.seller_pubkey.as_ref().map(ToString::to_string), - } -} - -fn order_locator_view_from_status(status: &OrderStatusView) -> OrderTradeLocatorView { - OrderTradeLocatorView { - trade_id: status.order_id.clone(), - root_event_id: status.request_event_id.clone(), - listing_addr: status.listing_addr.clone(), - buyer_pubkey: status.buyer_pubkey.clone(), - seller_pubkey: status.seller_pubkey.clone(), - } -} - -fn trade_locator_from_key(key: &str) -> Result<RadrootsTradeLocator, CliSdkAdapterError> { - Ok(TradeStatusRequest::parse(key)?.locator) -} - -fn trade_publish_mode(config: &RuntimeConfig) -> PublishMode { - if config.output.dry_run { - PublishMode::DryRun - } else { - PublishMode::EnqueueAndPublish - } -} - -fn trade_satisfaction_policy(mode: PublishMode) -> Result<SatisfactionPolicy, RuntimeError> { - Ok(match mode { - PublishMode::DryRun | PublishMode::EnqueueOnly => SatisfactionPolicy::NoWait, - PublishMode::EnqueueAndPublish => SatisfactionPolicy::AnyAccepted, - _ => { - return Err(RuntimeError::Config( - "unsupported SDK publish mode for CLI trade workflow".to_owned(), - )); - } - }) -} - -fn trade_target_policy() -> TargetPolicy { - TargetPolicy::default_profile() -} - -fn trade_privacy_confirmation(confirm_public_note: bool) -> PrivacyPreflightConfirmation { - if confirm_public_note { - PrivacyPreflightConfirmation::new() - .confirm(ProductSensitivityField::PublicButSensitiveNotes) - } else { - PrivacyPreflightConfirmation::new() - } -} - -fn sdk_trade_actor( - account: &account::AccountRecordView, - role: RadrootsActorRole, - operation: &str, -) -> Result<RadrootsActorContext, RuntimeError> { - RadrootsActorContext::local_account( - account.record.public_identity.public_key_hex.as_str(), - account.record.account_id.to_string(), - [role], - ) - .map_err(|error| RuntimeError::Config(format!("invalid trade {operation} SDK actor: {error}"))) -} - -fn connect_sdk_for_trade_actor( - config: &RuntimeConfig, - account: &account::AccountRecordView, - actor_label: &str, -) -> Result<CliSdkSession, CliSdkAdapterError> { - CliSdkSession::connect_for_actor( - config, - Some(account.record.account_id.as_str()), - account.record.public_identity.public_key_hex.as_str(), - actor_label, - ) -} - -fn protocol_order_id(value: &str, field: &str) -> Result<RadrootsOrderId, RuntimeError> { - value - .parse() - .map_err(|error| RuntimeError::Config(format!("{field} is not a valid order id: {error}"))) -} - -fn protocol_listing_addr(value: &str, field: &str) -> Result<RadrootsListingAddress, RuntimeError> { - value.parse().map_err(|error| { - RuntimeError::Config(format!("{field} is not a valid listing address: {error}")) - }) -} - -fn protocol_quote_id(value: &str, field: &str) -> Result<RadrootsOrderQuoteId, RuntimeError> { - value.parse().map_err(|error| { - RuntimeError::Config(format!("{field} is not a valid order quote id: {error}")) - }) -} - -fn protocol_inventory_bin_id( - value: &str, - field: &str, -) -> Result<RadrootsInventoryBinId, RuntimeError> { - value.parse().map_err(|error| { - RuntimeError::Config(format!("{field} is not a valid inventory bin id: {error}")) - }) -} - -fn protocol_pubkey(value: &str, field: &str) -> Result<RadrootsPublicKey, RuntimeError> { - value - .parse() - .map_err(|error| RuntimeError::Config(format!("{field} is not a valid pubkey: {error}"))) -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct OrderDraftDocument { - version: u32, - kind: String, - order: OrderDraft, - buyer_actor: OrderDraftBuyerActor, - #[serde(default, skip_serializing_if = "Option::is_none")] - listing_lookup: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct OrderDraft { - order_id: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - listing_addr: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - listing_event_id: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - listing_relays: Vec<String>, - #[serde(default, skip_serializing_if = "String::is_empty")] - buyer_pubkey: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - seller_pubkey: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - items: Vec<OrderDraftItem>, - #[serde(default, skip_serializing_if = "Option::is_none")] - economics: Option<RadrootsOrderEconomics>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct OrderDraftItem { - bin_id: String, - bin_count: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct OrderDraftBuyerActor { - account_id: String, - pubkey: String, - source: String, -} - -#[derive(Debug, Clone)] -struct LoadedOrderDraft { - file: PathBuf, - updated_at_unix: u64, - document: OrderDraftDocument, -} - -#[derive(Debug, Clone)] -struct LoadedAppOrderRecord { - loaded: LoadedOrderDraft, - source_issues: Vec<OrderIssueView>, -} - -#[derive(Debug, Clone)] -struct AppOrderRecordListEntry { - record: RuntimeStoreRecord, - superseded_count: usize, -} - -#[derive(Debug, Clone)] -struct ResolvedOrderListing { - listing_addr: String, - listing_event_id: String, - listing_relays: Vec<String>, - seller_pubkey: String, - economics_product: Option<ResolvedOrderEconomicsProduct>, -} - -#[derive(Debug, Clone)] -struct ResolvedOrderEconomicsProduct { - qty_amt_exact: Option<String>, - qty_unit: String, - price_amt_exact: Option<String>, - price_currency: String, - price_qty_amt_exact: Option<String>, - price_qty_unit: String, - primary_bin_id: Option<String>, - verified_primary_bin_id: Option<String>, - notes: Option<String>, -} - -impl ResolvedOrderEconomicsProduct { - fn from_summary(row: &ReplicaTradeProductSummaryRow) -> Self { - Self { - qty_amt_exact: row.qty_amt_exact.clone(), - qty_unit: row.qty_unit.clone(), - price_amt_exact: row.price_amt_exact.clone(), - price_currency: row.price_currency.clone(), - price_qty_amt_exact: row.price_qty_amt_exact.clone(), - price_qty_unit: row.price_qty_unit.clone(), - primary_bin_id: row.primary_bin_id.clone(), - verified_primary_bin_id: row.verified_primary_bin_id.clone(), - notes: row.notes.clone(), - } - } - - fn from_product(row: TradeProduct) -> Self { - Self { - qty_amt_exact: row.qty_amt_exact, - qty_unit: row.qty_unit, - price_amt_exact: row.price_amt_exact, - price_currency: row.price_currency, - price_qty_amt_exact: row.price_qty_amt_exact, - price_qty_unit: row.price_qty_unit, - primary_bin_id: row.primary_bin_id, - verified_primary_bin_id: row.verified_primary_bin_id, - notes: row.notes, - } - } -} - -#[derive(Debug, Clone, Deserialize)] -struct ResolvedTradeProductNotes { - #[serde(default)] - listing_discounts: Vec<RadrootsCoreDiscount>, -} - -#[derive(Debug, Clone)] -struct ResolvedOrderSubmitRequest { - request_event_id: String, - listing_event_id: Option<String>, - payload: RadrootsOrderRequest, -} - -#[derive(Debug, Clone)] -struct OrderRebindExistingRequestCheck { - state: String, - event_ids: Vec<String>, -} - -#[derive(Debug, Clone)] -struct OrderEventListActorContext { - source: &'static str, - seller_pubkey: String, -} - -#[derive(Debug, Clone)] -struct OrderBoundBuyerWriteContext { - account: account::AccountRecordView, -} - -#[derive(Debug, Clone)] -struct OrderBuyerWriteActorContext { - bound: Option<OrderBoundBuyerWriteContext>, -} - -pub fn scaffold( - config: &RuntimeConfig, - args: &OrderDraftCreateArgs, -) -> Result<OrderNewView, RuntimeError> { - validate_scaffold_args(args)?; - - let listing_lookup = normalize_optional(args.listing.as_deref()); - let explicit_listing_addr = normalize_optional(args.listing_addr.as_deref()); - let resolved_listing = resolve_order_listing( - config, - listing_lookup.as_deref(), - explicit_listing_addr.as_deref(), - )?; - - let buyer_actor = resolve_initial_buyer_actor(config)?; - let buyer_pubkey = buyer_actor.pubkey.clone(); - - let listing_addr = resolved_listing - .as_ref() - .map(|listing| listing.listing_addr.clone()) - .unwrap_or_default(); - let seller_pubkey = resolved_listing - .as_ref() - .map(|listing| listing.seller_pubkey.clone()) - .unwrap_or_default(); - let listing_event_id = resolved_listing - .as_ref() - .map(|listing| listing.listing_event_id.clone()) - .unwrap_or_default(); - let listing_relays = resolved_listing - .as_ref() - .map(|listing| listing.listing_relays.clone()) - .unwrap_or_default(); - - let items = match normalize_optional(args.bin_id.as_deref()) { - Some(bin_id) => vec![OrderDraftItem { - bin_id, - bin_count: args.bin_count.unwrap_or(1), - }], - None => Vec::new(), - }; - - let order_id = next_order_id(); - let economics = order_economics_from_resolved_listing( - order_id.as_str(), - resolved_listing.as_ref(), - items.as_slice(), - args.adjustments.as_slice(), - )?; - let drafts_dir = drafts_dir(config); - fs::create_dir_all(&drafts_dir)?; - let file = drafts_dir.join(format!("{order_id}.toml")); - - let document = OrderDraftDocument { - version: 1, - kind: ORDER_DRAFT_KIND.to_owned(), - order: OrderDraft { - order_id: order_id.clone(), - listing_addr, - listing_event_id, - listing_relays, - buyer_pubkey, - seller_pubkey, - items, - economics, - }, - buyer_actor, - listing_lookup, - }; - save_draft(file.as_path(), &document)?; - - let mut view: OrderNewView = view_from_loaded( - config, - LoadedOrderDraft { - file, - updated_at_unix: now_unix(), - document, - }, - )? - .into(); - view.actions - .insert(0, format!("radroots trade get {}", view.order_id)); - - Ok(view) -} - -pub fn scaffold_preflight( - config: &RuntimeConfig, - args: &OrderDraftCreateArgs, -) -> Result<OrderNewView, RuntimeError> { - validate_scaffold_args(args)?; - - let listing_lookup = normalize_optional(args.listing.as_deref()); - let explicit_listing_addr = normalize_optional(args.listing_addr.as_deref()); - let resolved_listing = resolve_order_listing( - config, - listing_lookup.as_deref(), - explicit_listing_addr.as_deref(), - )?; - - let buyer_actor = resolve_initial_buyer_actor(config)?; - let buyer_pubkey = buyer_actor.pubkey.clone(); - - let listing_addr = resolved_listing - .as_ref() - .map(|listing| listing.listing_addr.clone()) - .unwrap_or_default(); - let seller_pubkey = resolved_listing - .as_ref() - .map(|listing| listing.seller_pubkey.clone()) - .unwrap_or_default(); - let listing_event_id = resolved_listing - .as_ref() - .map(|listing| listing.listing_event_id.clone()) - .unwrap_or_default(); - let listing_relays = resolved_listing - .as_ref() - .map(|listing| listing.listing_relays.clone()) - .unwrap_or_default(); - - let items = match normalize_optional(args.bin_id.as_deref()) { - Some(bin_id) => vec![OrderDraftItem { - bin_id, - bin_count: args.bin_count.unwrap_or(1), - }], - None => Vec::new(), - }; - - let order_id = next_order_id(); - let economics = order_economics_from_resolved_listing( - order_id.as_str(), - resolved_listing.as_ref(), - items.as_slice(), - args.adjustments.as_slice(), - )?; - let file = drafts_dir(config).join(format!("{order_id}.toml")); - let document = OrderDraftDocument { - version: 1, - kind: ORDER_DRAFT_KIND.to_owned(), - order: OrderDraft { - order_id: order_id.clone(), - listing_addr, - listing_event_id, - listing_relays, - buyer_pubkey, - seller_pubkey, - items, - economics, - }, - buyer_actor, - listing_lookup, - }; - - let mut view: OrderNewView = view_from_loaded( - config, - LoadedOrderDraft { - file, - updated_at_unix: now_unix(), - document, - }, - )? - .into(); - view.state = "dry_run".to_owned(); - view.actions - .insert(0, format!("radroots trade get {}", view.order_id)); - - Ok(view) -} - -pub fn get(config: &RuntimeConfig, args: &RecordLookupArgs) -> Result<OrderGetView, RuntimeError> { - let lookup = args.key.clone(); - let file = draft_lookup_path(config, lookup.as_str()); - if !file.exists() { - if let Some(app_order) = load_app_order_record_for_lookup(config, lookup.as_str())? { - return view_from_loaded_with_source_issues( - config, - app_order.loaded, - app_order.source_issues.as_slice(), - ); - } - return Ok(OrderGetView { - state: "missing".to_owned(), - source: ORDER_SOURCE.to_owned(), - lookup: lookup.clone(), - order_id: None, - file: Some(file.display().to_string()), - listing_lookup: None, - listing_addr: None, - listing_event_id: None, - listing_relays: Vec::new(), - buyer_account_id: None, - buyer_pubkey: None, - buyer_actor_source: None, - buyer_custody: None, - buyer_write_capable: None, - seller_pubkey: None, - ready_for_submit: false, - items: Vec::new(), - economics: None, - updated_at_unix: None, - job: None, - workflow: None, - reason: Some(format!("trade draft `{lookup}` was not found")), - issues: Vec::new(), - actions: vec![ - "radroots trade list".to_owned(), - "radroots basket create".to_owned(), - ], - }); - } - - match load_draft(file.as_path()) { - Ok(loaded) => view_from_loaded(config, loaded), - Err(reason) => Ok(OrderGetView { - state: "error".to_owned(), - source: ORDER_SOURCE.to_owned(), - lookup, - order_id: None, - file: Some(file.display().to_string()), - listing_lookup: None, - listing_addr: None, - listing_event_id: None, - listing_relays: Vec::new(), - buyer_account_id: None, - buyer_pubkey: None, - buyer_actor_source: None, - buyer_custody: None, - buyer_write_capable: None, - seller_pubkey: None, - ready_for_submit: false, - items: Vec::new(), - economics: None, - updated_at_unix: None, - job: None, - workflow: None, - reason: Some(reason), - issues: Vec::new(), - actions: Vec::new(), - }), - } -} - -pub fn list(config: &RuntimeConfig) -> Result<OrderListView, RuntimeError> { - let dir = drafts_dir(config); - let mut orders = Vec::new(); - let mut local_order_ids = HashSet::new(); - if dir.exists() { - for entry in fs::read_dir(&dir)? { - let entry = entry?; - let path = entry.path(); - if path.extension().and_then(|value| value.to_str()) != Some("toml") { - continue; - } - match load_draft(path.as_path()) { - Ok(loaded) => { - local_order_ids.insert(loaded.document.order.order_id.clone()); - orders.push(summary_from_loaded(config, &loaded)?); - } - Err(reason) => orders.push(summary_for_invalid_file(path.as_path(), reason)), - } - } - } - for entry in current_app_order_record_entries(app_order_local_records(config)?) { - let app_order = load_app_order_record_from_record(config, entry.record.clone())?; - if local_order_ids.contains(&app_order.loaded.document.order.order_id) { - continue; - } - orders.push(summary_from_loaded_with_source_issues( - config, - &app_order.loaded, - app_order.source_issues.as_slice(), - )?); - } - - orders.sort_by(|left, right| { - right - .updated_at_unix - .cmp(&left.updated_at_unix) - .then_with(|| left.id.cmp(&right.id)) - }); - - let state = if orders.is_empty() { - "empty" - } else if orders.iter().any(|order| { - order.state == "error" || (!order.ready_for_submit && order.state != "submitted") - }) { - "degraded" - } else { - "ready" - }; - let actions = if orders.is_empty() { - vec!["radroots basket create".to_owned()] - } else { - Vec::new() - }; - - Ok(OrderListView { - state: state.to_owned(), - source: ORDER_SOURCE.to_owned(), - count: orders.len(), - orders, - actions, - }) -} - -pub fn app_record_list(config: &RuntimeConfig) -> Result<OrderAppRecordListView, RuntimeError> { - let database_path = shared_runtime_store_db_path(config)?; - let mut entries = current_app_order_record_entries(app_order_local_records(config)?); - let has_more = entries.len() > ORDER_APP_RECORD_LIST_LIMIT as usize; - if has_more { - entries.truncate(ORDER_APP_RECORD_LIST_LIMIT as usize); - } - let next_cursor = if has_more { - entries - .last() - .map(|entry| (entry.record.change_seq, entry.record.seq)) - } else { - None - }; - let records = entries - .iter() - .map(|entry| app_order_record_summary(config, &entry.record, entry.superseded_count)) - .collect::<Result<Vec<_>, _>>()?; - let state = if records.is_empty() { "empty" } else { "ready" }; - let actions = if records.is_empty() { - vec!["place a buyer order in radroots_studio_app".to_owned()] - } else { - Vec::new() - }; - - Ok(OrderAppRecordListView { - state: state.to_owned(), - source: ORDER_APP_RECORD_SOURCE.to_owned(), - count: records.len(), - limit: ORDER_APP_RECORD_LIST_LIMIT, - has_more, - next_before_change_seq: next_cursor.map(|(change_seq, _)| change_seq), - next_before_seq: next_cursor.map(|(_, seq)| seq), - runtime_store_db: database_path.display().to_string(), - records, - actions, - }) -} - -pub fn app_record_export( - config: &RuntimeConfig, - args: &TradeAppRecordExportArgs, -) -> Result<OrderAppRecordExportView, RuntimeError> { - let Some(record) = get_shared_record(config, args.record_id.as_str())? else { - return Ok(OrderAppRecordExportView { - state: "missing".to_owned(), - source: ORDER_APP_RECORD_SOURCE.to_owned(), - record_id: args.record_id.clone(), - dry_run: config.output.dry_run, - file: args - .output - .as_ref() - .map(|path| path.display().to_string()) - .unwrap_or_default(), - valid: false, - order_id: None, - listing_addr: None, - listing_event_id: None, - listing_relays: Vec::new(), - buyer_account_id: None, - buyer_pubkey: None, - buyer_actor_source: None, - seller_pubkey: None, - issues: Vec::new(), - reason: Some(format!( - "app-authored local trade record `{}` was not found", - args.record_id - )), - actions: vec!["radroots trade app list".to_owned()], - }); - }; - - let app_order = load_app_order_record_from_record(config, record)?; - let mut issues = source_and_document_issues(config, &app_order)?; - if !issues.is_empty() { - let state = app_order_export_failure_state(issues.as_slice()); - let actions = app_order_export_failure_actions(&app_order.loaded.document, &issues); - return Ok(OrderAppRecordExportView { - state: state.to_owned(), - source: ORDER_APP_RECORD_SOURCE.to_owned(), - record_id: args.record_id.clone(), - dry_run: config.output.dry_run, - file: args - .output - .as_ref() - .map(|path| path.display().to_string()) - .unwrap_or_default(), - valid: false, - order_id: Some(app_order.loaded.document.order.order_id.clone()), - listing_addr: non_empty_string(app_order.loaded.document.order.listing_addr.clone()), - listing_event_id: non_empty_string( - app_order.loaded.document.order.listing_event_id.clone(), - ), - listing_relays: order_listing_relays(&app_order.loaded.document), - buyer_account_id: buyer_account_id(&app_order.loaded.document), - buyer_pubkey: non_empty_string(app_order.loaded.document.order.buyer_pubkey.clone()), - buyer_actor_source: buyer_actor_source(&app_order.loaded.document), - seller_pubkey: non_empty_string(app_order.loaded.document.order.seller_pubkey.clone()), - issues, - reason: Some(format!( - "app-authored local trade record `{}` is not ready as a CLI trade draft", - args.record_id - )), - actions, - }); - } - - let output_path = order_export_output_path( - config, - args.output.as_ref(), - app_order.loaded.document.order.order_id.as_str(), - ); - validate_order_export_output_target(output_path.as_path())?; - if !config.output.dry_run { - save_draft(output_path.as_path(), &app_order.loaded.document)?; - } - issues.clear(); - - Ok(OrderAppRecordExportView { - state: if config.output.dry_run { - "dry_run" - } else { - "exported" - } - .to_owned(), - source: ORDER_APP_RECORD_SOURCE.to_owned(), - record_id: args.record_id.clone(), - dry_run: config.output.dry_run, - file: output_path.display().to_string(), - valid: true, - order_id: Some(app_order.loaded.document.order.order_id.clone()), - listing_addr: non_empty_string(app_order.loaded.document.order.listing_addr.clone()), - listing_event_id: non_empty_string( - app_order.loaded.document.order.listing_event_id.clone(), - ), - listing_relays: order_listing_relays(&app_order.loaded.document), - buyer_account_id: buyer_account_id(&app_order.loaded.document), - buyer_pubkey: non_empty_string(app_order.loaded.document.order.buyer_pubkey.clone()), - buyer_actor_source: buyer_actor_source(&app_order.loaded.document), - seller_pubkey: non_empty_string(app_order.loaded.document.order.seller_pubkey.clone()), - issues, - reason: Some(if config.output.dry_run { - "dry run requested; trade draft was not written".to_owned() - } else { - "app-authored local trade record exported as a CLI trade draft".to_owned() - }), - actions: vec![ - format!( - "radroots trade get {}", - app_order.loaded.document.order.order_id - ), - format!( - "radroots trade request {}", - app_order.loaded.document.order.order_id - ), - ], - }) -} - -pub fn submit( - config: &RuntimeConfig, - args: &TradeRequestArgs, -) -> Result<OrderSubmitView, CliSdkAdapterError> { - let file = draft_lookup_path(config, args.key.as_str()); - let (loaded, source_issues) = if file.exists() { - match load_draft(file.as_path()) { - Ok(loaded) => (loaded, Vec::new()), - Err(reason) => { - return Ok(OrderSubmitView { - state: "error".to_owned(), - source: ORDER_SOURCE.to_owned(), - order_id: args.key.clone(), - locator: order_locator_view_from_key(args.key.as_str()), - file: file.display().to_string(), - listing_lookup: None, - listing_addr: None, - listing_event_id: None, - listing_relays: Vec::new(), - buyer_account_id: None, - buyer_pubkey: None, - buyer_actor_source: None, - buyer_custody: None, - buyer_write_capable: None, - seller_pubkey: None, - event_id: None, - event_kind: None, - dry_run: config.output.dry_run, - deduplicated: false, - target_transport_endpoints: Vec::new(), - attempted_transport_endpoints: Vec::new(), - accepted_transport_endpoints: Vec::new(), - failed_transport_targets: Vec::new(), - idempotency_key: args.idempotency_key.clone(), - signer_mode: None, - reason: Some(reason), - job: None, - issues: Vec::new(), - actions: Vec::new(), - }); - } - } - } else if let Some(app_order) = load_app_order_record_for_lookup(config, args.key.as_str())? { - (app_order.loaded, app_order.source_issues) - } else { - return Ok(OrderSubmitView { - state: "missing".to_owned(), - source: ORDER_SOURCE.to_owned(), - order_id: args.key.clone(), - locator: order_locator_view_from_key(args.key.as_str()), - file: file.display().to_string(), - listing_lookup: None, - listing_addr: None, - listing_event_id: None, - listing_relays: Vec::new(), - buyer_account_id: None, - buyer_pubkey: None, - buyer_actor_source: None, - buyer_custody: None, - buyer_write_capable: None, - seller_pubkey: None, - event_id: None, - event_kind: None, - dry_run: config.output.dry_run, - deduplicated: false, - target_transport_endpoints: Vec::new(), - attempted_transport_endpoints: Vec::new(), - accepted_transport_endpoints: Vec::new(), - failed_transport_targets: Vec::new(), - idempotency_key: args.idempotency_key.clone(), - signer_mode: None, - reason: Some(format!("trade draft `{}` was not found", args.key)), - job: None, - issues: Vec::new(), - actions: vec![ - "radroots trade list".to_owned(), - "radroots basket create".to_owned(), - ], - }); - }; - - let mut issues = collect_issues(&loaded.document); - issues.extend(source_issues.clone()); - if let Some(view) = order_submit_app_signed_evidence_view(config, &loaded, args, &issues) { - return Ok(view); - } - if !issues.is_empty() { - let mut actions = actions_for_document(&loaded.document, loaded.file.as_path(), &issues); - actions.push(format!( - "radroots trade get {}", - loaded.document.order.order_id - )); - return Ok(OrderSubmitView { - state: "unconfigured".to_owned(), - source: ORDER_SOURCE.to_owned(), - order_id: loaded.document.order.order_id.clone(), - locator: order_locator_view_from_key(loaded.document.order.order_id.as_str()), - file: loaded.file.display().to_string(), - listing_lookup: loaded.document.listing_lookup.clone(), - listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), - listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()), - listing_relays: order_listing_relays(&loaded.document), - buyer_account_id: buyer_account_id(&loaded.document), - buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), - buyer_actor_source: buyer_actor_source(&loaded.document), - buyer_custody: None, - buyer_write_capable: None, - seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()), - event_id: None, - event_kind: None, - dry_run: config.output.dry_run, - deduplicated: false, - target_transport_endpoints: Vec::new(), - attempted_transport_endpoints: Vec::new(), - accepted_transport_endpoints: Vec::new(), - failed_transport_targets: Vec::new(), - idempotency_key: args.idempotency_key.clone(), - signer_mode: None, - reason: Some("trade draft is not ready for submit".to_owned()), - job: None, - issues, - actions, - }); - } - - let account = validate_bound_order_buyer_account(config, &loaded)?; - propose_trade_via_sdk(config, &loaded, args, &account) -} - -pub fn rebind( - config: &RuntimeConfig, - args: &TradeRebindArgs, -) -> Result<OrderRebindView, RuntimeError> { - rebind_inner(config, args, false) -} - -pub fn rebind_preflight( - config: &RuntimeConfig, - args: &TradeRebindArgs, -) -> Result<OrderRebindView, RuntimeError> { - rebind_inner(config, args, true) -} - -fn rebind_inner( - config: &RuntimeConfig, - args: &TradeRebindArgs, - dry_run: bool, -) -> Result<OrderRebindView, RuntimeError> { - let file = draft_lookup_path(config, args.key.as_str()); - if !file.exists() { - return Ok(OrderRebindView { - state: "missing".to_owned(), - source: ORDER_SOURCE.to_owned(), - lookup: args.key.clone(), - file: file.display().to_string(), - dry_run, - from_order_id: args.key.clone(), - to_order_id: args.key.clone(), - order_id_changed: false, - from_buyer_account_id: None, - from_buyer_pubkey: None, - from_buyer_actor_source: None, - to_buyer_account_id: args.selector.clone(), - to_buyer_pubkey: String::new(), - to_buyer_actor_source: ORDER_BUYER_ACTOR_SOURCE_REBIND.to_owned(), - buyer_pubkey_changed: false, - existing_request_check: "not_checked".to_owned(), - existing_request_event_ids: Vec::new(), - reason: Some(format!("trade draft `{}` was not found", args.key)), - actions: vec![ - "radroots trade list".to_owned(), - "radroots basket create".to_owned(), - ], - }); - } - - let loaded = load_draft(file.as_path()).map_err(RuntimeError::Config)?; - let target_account = account::resolve_account_selector(config, args.selector.as_str()) - .map_err(|error| order_rebind_selector_error(args.selector.as_str(), error))?; - let existing_request = order_rebind_existing_request_check(config, &loaded)?; - let from_order_id = loaded.document.order.order_id.clone(); - let from_buyer_account_id = buyer_account_id(&loaded.document); - let from_buyer_pubkey = non_empty_string(loaded.document.buyer_actor.pubkey.clone()); - let from_buyer_actor_source = buyer_actor_source(&loaded.document); - let target_account_id = target_account.record.account_id.to_string(); - let target_pubkey = target_account.record.public_identity.public_key_hex.clone(); - let current_buyer_pubkey = from_buyer_pubkey - .clone() - .or_else(|| non_empty_string(loaded.document.order.buyer_pubkey.clone())); - let buyer_pubkey_changed = current_buyer_pubkey - .as_deref() - .is_none_or(|pubkey| !pubkey.eq_ignore_ascii_case(target_pubkey.as_str())); - - if !existing_request.event_ids.is_empty() { - return Ok(OrderRebindView { - state: "invalid".to_owned(), - source: ORDER_SOURCE.to_owned(), - lookup: args.key.clone(), - file: loaded.file.display().to_string(), - dry_run, - from_order_id: from_order_id.clone(), - to_order_id: from_order_id.clone(), - order_id_changed: false, - from_buyer_account_id, - from_buyer_pubkey, - from_buyer_actor_source, - to_buyer_account_id: target_account_id, - to_buyer_pubkey: target_pubkey, - to_buyer_actor_source: ORDER_BUYER_ACTOR_SOURCE_REBIND.to_owned(), - buyer_pubkey_changed, - existing_request_check: existing_request.state, - existing_request_event_ids: existing_request.event_ids, - reason: Some( - "order rebind refused because a valid order request is already visible for this order id" - .to_owned(), - ), - actions: vec![ - format!("radroots trade get {from_order_id}"), - "radroots basket quote create <basket-id>".to_owned(), - ], - }); - } - - let mut document = loaded.document.clone(); - let to_order_id = if buyer_pubkey_changed { - next_order_id() - } else { - from_order_id.clone() - }; - let order_id_changed = to_order_id != from_order_id; - document.order.order_id = to_order_id.clone(); - document.order.buyer_pubkey = target_pubkey.clone(); - document.buyer_actor.account_id = target_account_id.clone(); - document.buyer_actor.pubkey = target_pubkey.clone(); - document.buyer_actor.source = ORDER_BUYER_ACTOR_SOURCE_REBIND.to_owned(); - if order_id_changed && let Some(economics) = document.order.economics.as_mut() { - economics.quote_id = - protocol_quote_id(format!("quote_{to_order_id}").as_str(), "quote_id")?; - } - - let output_file = if order_id_changed { - drafts_dir(config).join(format!("{to_order_id}.toml")) - } else { - loaded.file.clone() - }; - if !dry_run { - if order_id_changed && output_file.exists() { - return Err(RuntimeError::Config(format!( - "order rebind target file {} already exists", - output_file.display() - ))); - } - save_draft(output_file.as_path(), &document)?; - if order_id_changed && output_file != loaded.file { - fs::remove_file(loaded.file.as_path())?; - } - } - - Ok(OrderRebindView { - state: if dry_run { "dry_run" } else { "rebound" }.to_owned(), - source: ORDER_SOURCE.to_owned(), - lookup: args.key.clone(), - file: output_file.display().to_string(), - dry_run, - from_order_id: from_order_id.clone(), - to_order_id: to_order_id.clone(), - order_id_changed, - from_buyer_account_id, - from_buyer_pubkey, - from_buyer_actor_source, - to_buyer_account_id: target_account_id, - to_buyer_pubkey: target_pubkey, - to_buyer_actor_source: ORDER_BUYER_ACTOR_SOURCE_REBIND.to_owned(), - buyer_pubkey_changed, - existing_request_check: existing_request.state, - existing_request_event_ids: Vec::new(), - reason: Some(if dry_run { - "dry run requested; order buyer actor binding was not written".to_owned() - } else { - "order buyer actor binding updated".to_owned() - }), - actions: if dry_run { - vec![format!("radroots account select {}", args.selector)] - } else { - vec![format!("radroots trade get {to_order_id}")] - }, - }) -} - -pub fn event_list( - config: &RuntimeConfig, - order_id: Option<&str>, -) -> Result<OrderEventListView, RuntimeError> { - if config.transport.nostr_relay_urls.is_empty() { - return Ok(order_event_list_unconfigured( - None, - ORDER_ACTOR_CONTEXT_NETWORK_ONLY, - "trade event list requires a configured Nostr transport profile".to_owned(), - Vec::new(), - vec![ORDER_EVENT_LIST_RELAY_ACTION.to_owned()], - )); - } - - let actor_context = match order_event_list_actor_context(config, order_id)? { - Some(context) => context, - None => { - return Ok(order_event_list_unconfigured( - None, - ORDER_ACTOR_CONTEXT_NETWORK_ONLY, - "trade event list requires a selected seller account".to_owned(), - config.transport.nostr_relay_urls.clone(), - vec!["radroots account create".to_owned()], - )); - } - }; - let seller_pubkey = actor_context.seller_pubkey; - let filter = order_request_filter(seller_pubkey.as_str(), order_id)?; - let receipt = fetch_relay_events_via_shared_transport( - &config.transport.nostr_relay_urls, - now_unix_ms(), - 1_000, - filter, - ) - .map_err(order_relay_fetch_error)?; - if receipt.connected_relays.is_empty() && !receipt.failed_relays.is_empty() { - return Ok(order_event_list_unavailable( - seller_pubkey, - actor_context.source, - relay_fetch_failure_reason(&receipt.failed_relays), - receipt.target_relays, - receipt.failed_relays, - )); - } - - Ok(order_event_list_from_receipt( - seller_pubkey, - order_id, - actor_context.source, - receipt, - )) -} - -pub fn decide( - config: &RuntimeConfig, - args: &TradeDecisionArgs, -) -> Result<OrderDecisionView, CliSdkAdapterError> { - let seller = match account::resolve_account(config)? { - Some(account) => account, - None => { - let mut view = - order_decision_base_view(config, args, "unconfigured", config.output.dry_run); - view.reason = Some(format!( - "order {} requires a selected seller account", - args.decision.command() - )); - view.actions = vec!["radroots account create".to_owned()]; - return Ok(view); - } - }; - decide_trade_via_sdk(config, args, &seller) -} - -pub fn cancel( - config: &RuntimeConfig, - args: &TradeCancelArgs, -) -> Result<OrderCancellationView, CliSdkAdapterError> { - let actor_context = match order_buyer_write_actor_context(config, args.key.as_str())? { - Some(context) => context, - None => { - let mut view = - order_cancellation_base_view(config, args, "unconfigured", config.output.dry_run); - view.reason = Some("trade cancel requires a selected buyer account".to_owned()); - view.actions = vec!["radroots account create".to_owned()]; - return Ok(view); - } - }; - cancel_trade_via_sdk(config, args, actor_context) -} - -pub fn status( - config: &RuntimeConfig, - args: &TradeStatusArgs, -) -> Result<OrderStatusView, CliSdkAdapterError> { - let request = TradeStatusRequest::parse(args.key.as_str())? - .with_validation_trust_policy(trade_validation_trust_policy(config)?); - let session = CliSdkSession::connect(config)?; - let receipt = session.block_on(session.sdk().trades().status(request))?; - Ok(sdk_order_status_view(receipt)) -} - -fn decide_trade_via_sdk( - config: &RuntimeConfig, - args: &TradeDecisionArgs, - seller: &account::AccountRecordView, -) -> Result<OrderDecisionView, CliSdkAdapterError> { - let actor = sdk_trade_actor(seller, RadrootsActorRole::Seller, args.decision.command())?; - let session = connect_sdk_for_trade_actor(config, seller, "trade decision")?; - let locator = trade_locator_from_key(args.key.as_str())?; - let status = trade_status_for_locator(config, &session, locator.clone())?; - let status_view = sdk_order_status_view(status.clone()); - let publish_mode = trade_publish_mode(config); - let ack_policy = trade_satisfaction_policy(publish_mode)?; - let outcome = match args.decision { - TradeDecisionArg::Accept => { - let commitments = inventory_commitments_from_status(&status)?; - let mut request = TradeAcceptRequest::new( - actor, - locator, - commitments, - trade_target_policy(), - publish_mode, - ack_policy, - TradeEvidenceMode::ResyncBeforeMutation, - ) - .with_privacy_confirmation(trade_privacy_confirmation(false)); - if let Some(idempotency_key) = args.idempotency_key.as_deref() { - request = request.try_with_idempotency_key(idempotency_key)?; - } - session.block_on(session.sdk().trades().seller().accept_trade(request))? - } - TradeDecisionArg::Decline => { - let reason = args - .reason - .as_deref() - .map(str::trim) - .filter(|reason| !reason.is_empty()) - .ok_or_else(|| { - RuntimeError::Config("trade decline requires a non-empty reason".to_owned()) - })?; - let mut request = TradeDeclineRequest::new( - actor, - locator, - reason, - trade_target_policy(), - publish_mode, - ack_policy, - TradeEvidenceMode::ResyncBeforeMutation, - ) - .with_privacy_confirmation(trade_privacy_confirmation(args.confirm_public_note)); - if let Some(idempotency_key) = args.idempotency_key.as_deref() { - request = request.try_with_idempotency_key(idempotency_key)?; - } - session.block_on(session.sdk().trades().seller().decline_trade(request))? - } - }; - Ok(sdk_trade_decision_outcome_view( - config, - args, - &status_view, - outcome, - )) -} - -fn cancel_trade_via_sdk( - config: &RuntimeConfig, - args: &TradeCancelArgs, - actor_context: OrderBuyerWriteActorContext, -) -> Result<OrderCancellationView, CliSdkAdapterError> { - let account = match actor_context.bound { - Some(bound) => bound.account, - None => account::resolve_account(config)?.ok_or_else(|| { - RuntimeError::Config("trade cancel requires a selected buyer account".to_owned()) - })?, - }; - let actor = sdk_trade_actor(&account, RadrootsActorRole::Buyer, "cancel")?; - let session = connect_sdk_for_trade_actor(config, &account, "trade cancel")?; - let locator = trade_locator_from_key(args.key.as_str())?; - let status = trade_status_for_locator(config, &session, locator.clone())?; - let status_view = sdk_order_status_view(status); - let publish_mode = trade_publish_mode(config); - let ack_policy = trade_satisfaction_policy(publish_mode)?; - let mut request = TradeCancelRequest::new( - actor, - locator, - args.reason.trim(), - trade_target_policy(), - publish_mode, - ack_policy, - TradeEvidenceMode::ResyncBeforeMutation, - ) - .with_privacy_confirmation(trade_privacy_confirmation(args.confirm_public_note)); - if let Some(idempotency_key) = args.idempotency_key.as_deref() { - request = request.try_with_idempotency_key(idempotency_key)?; - } - let outcome = session.block_on(session.sdk().trades().buyer().cancel_trade(request))?; - Ok(sdk_trade_cancellation_outcome_view( - config, - args, - &status_view, - outcome, - )) -} - -fn trade_status_for_locator( - config: &RuntimeConfig, - session: &CliSdkSession, - locator: RadrootsTradeLocator, -) -> Result<TradeStatusReceipt, CliSdkAdapterError> { - let request = TradeStatusRequest::new(locator) - .with_source(SdkTradeStatusSource::ResyncThenLocal) - .with_validation_trust_policy(trade_validation_trust_policy(config)?); - Ok(session.block_on(session.sdk().trades().status(request))?) -} - -fn trade_validation_trust_policy( - config: &RuntimeConfig, -) -> Result<RadrootsTradeValidationTrustPolicy, CliSdkAdapterError> { - let policy = RadrootsTradeValidationTrustPolicy::production() - .with_require_cryptographic_proof(config.rhi.require_cryptographic_proof); - let Some(validator_set_config) = config.rhi.validator_set.as_ref() else { - return Ok(policy); - }; - let validator_set = RadrootsValidatorSetV1 { - set_id: validator_set_config.set_id.clone(), - validator_pubkey: RadrootsPublicKey::parse(validator_set_config.validator_pubkey.as_str()) - .map_err(|error| { - RuntimeError::Config(format!("invalid RHI validator pubkey: {error}")) - })?, - threshold: 1, - valid_from: validator_set_config.valid_from, - valid_until: validator_set_config.valid_until, - protocol_contract_hash: validator_set_config.protocol_contract_hash.clone(), - operator_name: validator_set_config.operator_name.clone(), - operator_contact: validator_set_config.operator_contact.clone(), - }; - let validator_set_addr = - validator_set_address_from_str(validator_set_config.validator_set_addr.as_str()) - .map_err(|error| RuntimeError::Config(error.to_string()))?; - Ok(policy.with_validator_set( - validator_set, - validator_set_addr, - validator_set_config.validator_set_event_id.clone(), - )) -} - -fn inventory_commitments_from_status( - status: &TradeStatusReceipt, -) -> Result<Vec<RadrootsOrderInventoryCommitment>, RuntimeError> { - let economics = status.economics.as_ref().ok_or_else(|| { - RuntimeError::Config("trade accept requires SDK status economics evidence".to_owned()) - })?; - Ok(economics - .items - .iter() - .map(|item| RadrootsOrderInventoryCommitment { - bin_id: item.bin_id.clone(), - bin_count: item.bin_count, - }) - .collect()) -} - -fn sdk_trade_decision_outcome_view( - config: &RuntimeConfig, - args: &TradeDecisionArgs, - status: &OrderStatusView, - outcome: TradeMutationOutcome<TradeDecisionPlan, TradeDecisionReceipt>, -) -> OrderDecisionView { - match outcome { - TradeMutationOutcome::DryRun { plan } => { - let mut view = order_decision_base_view(config, args, "dry_run", true); - apply_order_decision_status(&mut view, status); - view.request_event_id = Some(plan.request_event_id.to_string()); - view.root_event_id = Some(plan.request_event_id.to_string()); - view.prev_event_id = Some(plan.request_event_id.to_string()); - view.event_id = Some(plan.expected_event_id.to_string()); - view.event_kind = Some(KIND_ORDER_DECISION); - view.target_transport_endpoints = config.transport.nostr_relay_urls.clone(); - view.reason = Some(format!( - "dry run requested; seller trade {} publication skipped", - args.decision.command() - )); - view.actions = vec![format!("radroots trade get {}", status.order_id)]; - view - } - TradeMutationOutcome::Enqueued { receipt } => { - sdk_enqueued_order_decision_view(config, args, status, receipt, None) - } - TradeMutationOutcome::Published { receipt, publish } => { - sdk_enqueued_order_decision_view(config, args, status, receipt, Some(&publish)) - } - } -} - -fn sdk_trade_cancellation_outcome_view( - config: &RuntimeConfig, - args: &TradeCancelArgs, - status: &OrderStatusView, - outcome: TradeMutationOutcome<TradeCancellationPlan, TradeCancellationReceipt>, -) -> OrderCancellationView { - match outcome { - TradeMutationOutcome::DryRun { plan } => { - let mut view = order_cancellation_base_view(config, args, "dry_run", true); - apply_order_cancellation_status(&mut view, status); - view.root_event_id = Some(plan.root_event_id.to_string()); - view.prev_event_id = Some(plan.previous_event_id.to_string()); - view.event_id = Some(plan.expected_event_id.to_string()); - view.event_kind = Some(KIND_ORDER_CANCELLATION); - view.target_transport_endpoints = config.transport.nostr_relay_urls.clone(); - view.reason = - Some("dry run requested; buyer trade cancellation publication skipped".to_owned()); - view.actions = vec![format!("radroots trade get {}", status.order_id)]; - view - } - TradeMutationOutcome::Enqueued { receipt } => { - sdk_enqueued_order_cancellation_view(config, args, status, receipt, None) - } - TradeMutationOutcome::Published { receipt, publish } => { - sdk_enqueued_order_cancellation_view(config, args, status, receipt, Some(&publish)) - } - } -} - -fn event_kind_u32(event: &RadrootsNostrEvent) -> u32 { - u32::from(event.kind.as_u16()) -} - -fn order_event_list_entry_from_event( - event: &RadrootsNostrEvent, - seller_pubkey: &str, -) -> Result<OrderEventListEntryView, RuntimeError> { - let event_kind = event_kind_u32(event); - if event_kind != KIND_ORDER_REQUEST { - return Err(RuntimeError::Config(format!( - "trade event list received unexpected kind `{event_kind}`" - ))); - } - - let event = radroots_event_from_nostr(event); - let envelope = order_request_from_event(&event) - .map_err(|error| RuntimeError::Config(format!("decode order request event: {error}")))?; - let event_tags = event.tags_as_vec(); - let context = - order_event_context_from_tags(RadrootsOrderEventType::OrderRequested, &event_tags) - .map_err(|error| RuntimeError::Config(format!("decode order request tags: {error}")))?; - - if context.counterparty_pubkey != seller_pubkey - || envelope.payload.seller_pubkey != seller_pubkey - { - return Err(RuntimeError::Config( - "order request is not targeted at the selected seller".to_owned(), - )); - } - - let listing_event_id = context.listing_event.as_ref().map(|event| event.id.clone()); - let created_at_unix = event.created_at_u64(); - - Ok(OrderEventListEntryView { - id: envelope.order_id.clone(), - state: "requested".to_owned(), - event_id: Some(event.id_str().to_owned()), - event_kind: Some(event.kind_u32()), - listing_lookup: None, - listing_addr: Some(envelope.listing_addr), - listing_event_id, - buyer_account_id: None, - buyer_pubkey: Some(envelope.payload.buyer_pubkey.to_string()), - seller_pubkey: Some(envelope.payload.seller_pubkey.to_string()), - item_count: Some(envelope.payload.items.len()), - created_at_unix: Some(created_at_unix), - submitted_at_unix: Some(created_at_unix), - updated_at_unix: created_at_unix, - job: None, - workflow: None, - issues: Vec::new(), - }) -} - -fn order_request_filter( - seller_pubkey: &str, - order_id: Option<&str>, -) -> Result<RadrootsNostrFilter, RuntimeError> { - let filter = RadrootsNostrFilter::new() - .kind(radroots_nostr_kind(KIND_ORDER_REQUEST as u16)) - .limit(1_000); - let filter = radroots_nostr_filter_tag(filter, "p", vec![seller_pubkey.to_owned()]) - .map_err(|error| RuntimeError::Config(format!("build order event filter: {error}")))?; - if let Some(order_id) = order_id { - return radroots_nostr_filter_tag(filter, "d", vec![order_id.to_owned()]) - .map_err(|error| RuntimeError::Config(format!("build order event filter: {error}"))); - } - Ok(filter) -} - -fn order_event_list_unconfigured( - seller_pubkey: Option<String>, - actor_context_source: &'static str, - reason: String, - target_transport_endpoints: Vec<String>, - actions: Vec<String>, -) -> OrderEventListView { - OrderEventListView { - state: "unconfigured".to_owned(), - source: ORDER_EVENT_LIST_SOURCE.to_owned(), - actor_context_source: actor_context_source.to_owned(), - seller_pubkey, - target_transport_endpoints, - attempted_transport_endpoints: Vec::new(), - failed_transport_targets: Vec::new(), - fetched_count: 0, - decoded_count: 0, - skipped_count: 0, - count: 0, - reason: Some(reason), - orders: Vec::new(), - actions, - } -} - -fn order_event_list_unavailable( - seller_pubkey: String, - actor_context_source: &'static str, - reason: String, - target_transport_endpoints: Vec<String>, - failed_transport_targets: Vec<RadrootsRelayFetchFailure>, -) -> OrderEventListView { - OrderEventListView { - state: "unavailable".to_owned(), - source: ORDER_EVENT_LIST_SOURCE.to_owned(), - actor_context_source: actor_context_source.to_owned(), - seller_pubkey: Some(seller_pubkey), - target_transport_endpoints, - attempted_transport_endpoints: Vec::new(), - failed_transport_targets: relay_failures(failed_transport_targets), - fetched_count: 0, - decoded_count: 0, - skipped_count: 0, - count: 0, - reason: Some(format!("Nostr transport fetch failed: {reason}")), - orders: Vec::new(), - actions: Vec::new(), - } -} - -fn order_event_list_from_receipt( - seller_pubkey: String, - order_id: Option<&str>, - actor_context_source: &'static str, - receipt: RadrootsRelayFetchedEventsReceipt, -) -> OrderEventListView { - let RadrootsRelayFetchedEventsReceipt { - target_relays, - connected_relays, - failed_relays, - events, - .. - } = receipt; - let fetched_count = events.len(); - let mut skipped_count = 0usize; - let mut decoded_count = 0usize; - let mut orders = Vec::new(); - - for event in events { - match order_event_list_entry_from_event(&event.event, seller_pubkey.as_str()) { - Ok(entry) => { - decoded_count += 1; - if order_id.is_none_or(|order_id| entry.id == order_id) { - orders.push(entry); - } - } - Err(_) => skipped_count += 1, - } - } - - orders.sort_by(|left, right| { - right - .updated_at_unix - .cmp(&left.updated_at_unix) - .then_with(|| left.id.cmp(&right.id)) - }); - - let reason = if orders.is_empty() { - Some(match order_id { - Some(order_id) => { - format!("no Nostr-backed order request events matched `{order_id}`") - } - None => "no Nostr-backed order request events matched the selected seller".to_owned(), - }) - } else { - None - }; - - OrderEventListView { - state: if orders.is_empty() { "empty" } else { "ready" }.to_owned(), - source: ORDER_EVENT_LIST_SOURCE.to_owned(), - actor_context_source: actor_context_source.to_owned(), - seller_pubkey: Some(seller_pubkey), - target_transport_endpoints: target_relays, - attempted_transport_endpoints: connected_relays, - failed_transport_targets: relay_failures(failed_relays), - fetched_count, - decoded_count, - skipped_count, - count: orders.len(), - reason, - orders, - actions: Vec::new(), - } -} - -fn order_decision_base_view( - config: &RuntimeConfig, - args: &TradeDecisionArgs, - state: &str, - dry_run: bool, -) -> OrderDecisionView { - OrderDecisionView { - state: state.to_owned(), - source: ORDER_DECISION_SOURCE.to_owned(), - order_id: args.key.clone(), - locator: order_locator_view_from_key(args.key.as_str()), - listing_addr: None, - buyer_pubkey: None, - seller_pubkey: None, - decision: args.decision.as_str().to_owned(), - request_event_id: None, - listing_event_id: None, - root_event_id: None, - prev_event_id: None, - event_id: None, - event_kind: None, - inventory: None, - dry_run, - target_transport_endpoints: config.transport.nostr_relay_urls.clone(), - attempted_transport_endpoints: Vec::new(), - accepted_transport_endpoints: Vec::new(), - failed_transport_targets: Vec::new(), - fetched_count: 0, - decoded_count: 0, - skipped_count: 0, - idempotency_key: args.idempotency_key.clone(), - signer_mode: Some(config.signer.backend.as_str().to_owned()), - reason: None, - issues: Vec::new(), - actions: Vec::new(), - } -} - -fn order_cancellation_base_view( - config: &RuntimeConfig, - args: &TradeCancelArgs, - state: &str, - dry_run: bool, -) -> OrderCancellationView { - OrderCancellationView { - state: state.to_owned(), - source: ORDER_CANCELLATION_SOURCE.to_owned(), - order_id: args.key.clone(), - locator: order_locator_view_from_key(args.key.as_str()), - listing_addr: None, - buyer_pubkey: None, - seller_pubkey: None, - request_event_id: None, - decision_event_id: None, - root_event_id: None, - prev_event_id: None, - event_id: None, - event_kind: None, - cancellation_reason: Some(args.reason.clone()), - dry_run, - target_transport_endpoints: config.transport.nostr_relay_urls.clone(), - attempted_transport_endpoints: Vec::new(), - accepted_transport_endpoints: Vec::new(), - failed_transport_targets: Vec::new(), - fetched_count: 0, - decoded_count: 0, - skipped_count: 0, - idempotency_key: args.idempotency_key.clone(), - signer_mode: Some(config.signer.backend.as_str().to_owned()), - reason: None, - issues: Vec::new(), - actions: Vec::new(), - } -} - -fn apply_order_cancellation_status(view: &mut OrderCancellationView, status: &OrderStatusView) { - view.order_id = status.order_id.clone(); - view.locator = order_locator_view_from_status(status); - view.listing_addr = status.listing_addr.clone(); - view.buyer_pubkey = status.buyer_pubkey.clone(); - view.seller_pubkey = status.seller_pubkey.clone(); - view.request_event_id = status.request_event_id.clone(); - view.decision_event_id = status.decision_event_id.clone(); - view.root_event_id = status.request_event_id.clone(); - view.prev_event_id = order_cancellation_prev_event_id(status); - view.target_transport_endpoints = status.target_transport_endpoints.clone(); - view.attempted_transport_endpoints = status.attempted_transport_endpoints.clone(); - view.failed_transport_targets = status.failed_transport_targets.clone(); - view.fetched_count = status.fetched_count; - view.decoded_count = status.decoded_count; - view.skipped_count = status.skipped_count; - view.issues = status.reducer_issues.clone(); -} - -fn order_cancellation_prev_event_id(status: &OrderStatusView) -> Option<String> { - match status.state.as_str() { - "requested" => status.request_event_id.clone(), - "pending_rhi" => status - .last_event_id - .clone() - .or(status.decision_event_id.clone()), - _ => status.last_event_id.clone(), - } -} - -fn apply_order_decision_status(view: &mut OrderDecisionView, status: &OrderStatusView) { - view.order_id = status.order_id.clone(); - view.locator = order_locator_view_from_status(status); - view.listing_addr = status.listing_addr.clone(); - view.buyer_pubkey = status.buyer_pubkey.clone(); - view.seller_pubkey = status.seller_pubkey.clone(); - view.request_event_id = status.request_event_id.clone(); - view.listing_event_id = status.listing_event_id.clone(); - view.root_event_id = status.request_event_id.clone(); - view.prev_event_id = status.last_event_id.clone(); - view.target_transport_endpoints = status.target_transport_endpoints.clone(); - view.attempted_transport_endpoints = status.attempted_transport_endpoints.clone(); - view.failed_transport_targets = status.failed_transport_targets.clone(); - view.fetched_count = status.fetched_count; - view.decoded_count = status.decoded_count; - view.skipped_count = status.skipped_count; - view.issues = status.reducer_issues.clone(); - view.inventory = status.inventory.clone(); -} - -fn sdk_enqueued_order_decision_view( - config: &RuntimeConfig, - args: &TradeDecisionArgs, - status: &OrderStatusView, - enqueue: TradeDecisionReceipt, - push: Option<&PushOutboxReceipt>, -) -> OrderDecisionView { - let push_event = push.and_then(|push| sdk_push_event_for_order_decision(&enqueue, push)); - let mut view = order_decision_base_view( - config, - args, - sdk_order_decision_state(args.decision, push_event).as_str(), - false, - ); - apply_order_decision_status(&mut view, status); - view.locator = order_locator_view_from_locator(&enqueue.locator); - view.request_event_id = Some(enqueue.request_event_id.to_string()); - view.root_event_id = Some(enqueue.request_event_id.to_string()); - view.prev_event_id = Some(enqueue.request_event_id.to_string()); - view.event_id = Some(enqueue.signed_event_id.as_str().to_owned()); - view.event_kind = Some(KIND_ORDER_DECISION); - view.target_transport_endpoints = push_event - .map(sdk_push_target_transport_endpoints) - .unwrap_or_else(|| config.transport.nostr_relay_urls.clone()); - view.attempted_transport_endpoints = push_event - .map(sdk_push_attempted_transport_endpoints) - .unwrap_or_default(); - view.accepted_transport_endpoints = push_event - .map(sdk_push_accepted_transport_endpoints) - .unwrap_or_default(); - view.failed_transport_targets = push_event - .map(sdk_push_failed_transport_targets) - .unwrap_or_default(); - view.reason = sdk_order_decision_reason(&enqueue.workflow, push_event); - view.actions = sdk_order_decision_actions(push_event); - view -} - -fn sdk_push_event_for_order_decision<'a>( - enqueue: &TradeDecisionReceipt, - push: &'a PushOutboxReceipt, -) -> Option<&'a PushOutboxEventReceipt> { - push.events - .iter() - .find(|event| event.event_id == enqueue.signed_event_id) -} - -fn sdk_order_decision_state( - decision: TradeDecisionArg, - push_event: Option<&PushOutboxEventReceipt>, -) -> String { - match push_event.map(|event| event.final_state) { - Some(PushOutboxEventState::Published) => decision.as_str(), - Some(PushOutboxEventState::PublishRetryable | PushOutboxEventState::FailedTerminal) => { - "unavailable" - } - Some(_) | None => "queued", - } - .to_owned() -} - -fn sdk_order_decision_reason( - enqueue: &TradeWorkflowEnqueueReceipt, - push_event: Option<&PushOutboxEventReceipt>, -) -> Option<String> { - match push_event.map(|event| event.final_state) { - Some(PushOutboxEventState::Published) => None, - Some(PushOutboxEventState::PublishRetryable) => Some(format!( - "{}; SDK transport publish did not reach accepted quorum; outbox event remains retryable; {}", - sdk_order_enqueue_summary(enqueue), - sdk_order_enqueue_retry_summary(enqueue) - )), - Some(PushOutboxEventState::FailedTerminal) => Some(format!( - "{}; SDK transport publish failed terminally; {}", - sdk_order_enqueue_summary(enqueue), - sdk_order_enqueue_retry_summary(enqueue) - )), - Some(state) => Some(format!( - "{}; SDK transport push left event in state `{state:?}`; {}", - sdk_order_enqueue_summary(enqueue), - sdk_order_enqueue_retry_summary(enqueue) - )), - None => Some(format!( - "{}; order decision queued in SDK outbox; no ready SDK outbox event was pushed; {}", - sdk_order_enqueue_summary(enqueue), - sdk_order_enqueue_retry_summary(enqueue) - )), - } -} - -fn sdk_order_decision_actions(push_event: Option<&PushOutboxEventReceipt>) -> Vec<String> { - if !matches!( - push_event.map(|event| event.final_state), - Some(PushOutboxEventState::Published) - ) { - return sdk_order_push_recovery_actions(); - } - Vec::new() -} - -fn sdk_enqueued_order_cancellation_view( - config: &RuntimeConfig, - args: &TradeCancelArgs, - status: &OrderStatusView, - enqueue: TradeCancellationReceipt, - push: Option<&PushOutboxReceipt>, -) -> OrderCancellationView { - let push_event = - push.and_then(|push| sdk_push_event_for_event_id(&enqueue.signed_event_id, push)); - let mut view = order_cancellation_base_view( - config, - args, - sdk_order_lifecycle_state("cancelled", push_event).as_str(), - false, - ); - apply_order_cancellation_status(&mut view, status); - view.locator = order_locator_view_from_locator(&enqueue.locator); - view.root_event_id = Some(enqueue.root_event_id.to_string()); - view.prev_event_id = Some(enqueue.previous_event_id.to_string()); - view.event_id = Some(enqueue.signed_event_id.as_str().to_owned()); - view.event_kind = Some(KIND_ORDER_CANCELLATION); - view.target_transport_endpoints = push_event - .map(sdk_push_target_transport_endpoints) - .unwrap_or_else(|| config.transport.nostr_relay_urls.clone()); - view.attempted_transport_endpoints = push_event - .map(sdk_push_attempted_transport_endpoints) - .unwrap_or_default(); - view.accepted_transport_endpoints = push_event - .map(sdk_push_accepted_transport_endpoints) - .unwrap_or_default(); - view.failed_transport_targets = push_event - .map(sdk_push_failed_transport_targets) - .unwrap_or_default(); - view.reason = sdk_order_lifecycle_reason("trade cancellation", &enqueue.workflow, push_event); - view.actions = sdk_order_lifecycle_actions(push_event); - view -} - -fn sdk_push_event_for_event_id<'a>( - event_id: &RadrootsEventId, - push: &'a PushOutboxReceipt, -) -> Option<&'a PushOutboxEventReceipt> { - push.events.iter().find(|event| event.event_id == *event_id) -} - -fn sdk_order_lifecycle_state( - published_state: &str, - push_event: Option<&PushOutboxEventReceipt>, -) -> String { - match push_event.map(|event| event.final_state) { - Some(PushOutboxEventState::Published) => published_state, - Some(PushOutboxEventState::PublishRetryable | PushOutboxEventState::FailedTerminal) => { - "unavailable" - } - Some(_) | None => "queued", - } - .to_owned() -} - -fn sdk_order_lifecycle_reason( - workflow: &str, - enqueue: &TradeWorkflowEnqueueReceipt, - push_event: Option<&PushOutboxEventReceipt>, -) -> Option<String> { - match push_event.map(|event| event.final_state) { - Some(PushOutboxEventState::Published) => None, - Some(PushOutboxEventState::PublishRetryable) => Some(format!( - "{}; SDK transport publish for {workflow} did not reach accepted quorum; outbox event remains retryable; {}", - sdk_order_enqueue_summary(enqueue), - sdk_order_enqueue_retry_summary(enqueue) - )), - Some(PushOutboxEventState::FailedTerminal) => Some(format!( - "{}; SDK transport publish for {workflow} failed terminally; {}", - sdk_order_enqueue_summary(enqueue), - sdk_order_enqueue_retry_summary(enqueue) - )), - Some(state) => Some(format!( - "{}; SDK transport push for {workflow} left event in state `{state:?}`; {}", - sdk_order_enqueue_summary(enqueue), - sdk_order_enqueue_retry_summary(enqueue) - )), - None => Some(format!( - "{}; {workflow} queued in SDK outbox; no ready SDK outbox event was pushed; {}", - sdk_order_enqueue_summary(enqueue), - sdk_order_enqueue_retry_summary(enqueue) - )), - } -} - -fn sdk_order_lifecycle_actions(push_event: Option<&PushOutboxEventReceipt>) -> Vec<String> { - if !matches!( - push_event.map(|event| event.final_state), - Some(PushOutboxEventState::Published) - ) { - return sdk_order_push_recovery_actions(); - } - Vec::new() -} - -fn sdk_order_enqueue_summary(enqueue: &TradeWorkflowEnqueueReceipt) -> String { - format!( - "local SDK enqueued `{}` as `{}` with outbox_event_id {}; {}", - enqueue.operation_kind, - sdk_mutation_state_label(&enqueue.state), - enqueue.outbox_event_id, - sdk_order_idempotency_summary(enqueue) - ) -} - -fn sdk_order_idempotency_summary(enqueue: &TradeWorkflowEnqueueReceipt) -> &'static str { - if enqueue.idempotency.replayed_existing_operation { - "idempotency replayed an existing queued operation" - } else if enqueue.idempotency.safe_to_retry_with_same_idempotency_key { - "same idempotency key remains retry-safe" - } else { - "same idempotency key retry safety is unavailable" - } -} - -fn sdk_order_enqueue_retry_summary(enqueue: &TradeWorkflowEnqueueReceipt) -> &'static str { - if enqueue - .retry - .safe_to_retry_enqueue_with_same_idempotency_key - { - "enqueue is safe to retry with the same idempotency key" - } else if enqueue.retry.retryable_after_error { - "inspect local SDK state before retrying enqueue" - } else { - "do not retry enqueue before inspecting local SDK state" - } -} - -fn sdk_mutation_state_label(state: &SdkMutationState) -> &'static str { - match state { - SdkMutationState::StoredAndQueued => "stored_and_queued", - SdkMutationState::AlreadyQueued => "already_queued", - _ => "unknown", - } -} - -fn sdk_order_push_recovery_actions() -> Vec<String> { - vec![ - "radroots sync push".to_owned(), - "radroots sync status".to_owned(), - ] -} - -fn validate_scaffold_args(args: &OrderDraftCreateArgs) -> Result<(), RuntimeError> { - match (normalize_optional(args.bin_id.as_deref()), args.bin_count) { - (None, Some(_)) => Err(RuntimeError::Config( - "`--qty` requires `--bin` when creating an trade draft".to_owned(), - )), - (Some(_), Some(0)) => Err(RuntimeError::Config( - "`--qty` must be greater than zero".to_owned(), - )), - (Some(_), None) | (Some(_), Some(_)) | (None, None) => Ok(()), - } -} - -fn resolve_order_listing( - config: &RuntimeConfig, - listing_lookup: Option<&str>, - explicit_listing_addr: Option<&str>, -) -> Result<Option<ResolvedOrderListing>, RuntimeError> { - if let Some(listing_addr) = explicit_listing_addr { - let parsed = parse_listing_addr(listing_addr).map_err(|error| { - RuntimeError::Config(format!("explicit listing_addr is invalid: {error}")) - })?; - if parsed.kind != KIND_LISTING { - return Err(RuntimeError::Config( - "explicit listing_addr must reference a public NIP-99 listing".to_owned(), - )); - } - let replica_listing_event_id = - resolve_active_listing_event_id(config, listing_addr, &parsed)?; - let shared_provenance = resolve_shared_signed_listing_provenance( - config, - listing_addr, - replica_listing_event_id.as_deref(), - )?; - let listing_event_id = replica_listing_event_id - .or_else(|| { - shared_provenance - .as_ref() - .map(|provenance| provenance.event_id.clone()) - }) - .unwrap_or_default(); - let listing_relays = listing_provenance_relays( - config, - listing_event_id.as_str(), - shared_provenance.as_ref(), - )?; - let economics_product = resolve_trade_product_by_listing_addr(config, listing_addr)?; - return Ok(Some(ResolvedOrderListing { - listing_addr: listing_addr.to_owned(), - listing_event_id, - listing_relays, - seller_pubkey: parsed.seller_pubkey, - economics_product, - })); - } - - let Some(listing_lookup) = listing_lookup else { - return Ok(None); - }; - - if !config.local.replica_store_path.exists() { - return Err(RuntimeError::Config(format!( - "trade listing lookup `{listing_lookup}` requires local market data; run `radroots store inspect` and `radroots market pull` before creating a trade from a listing" - ))); - } - - let db = ReplicaSql::new(SqlxSqliteExecutor::open(&config.local.replica_store_path)?); - let rows = db.trade_product_lookup(listing_lookup)?; - match rows.len() { - 0 => Err(RuntimeError::Config(format!( - "listing `{listing_lookup}` is not available in the local replica; run `radroots market pull` or pass `--listing-addr`" - ))), - 1 => { - let row = rows.into_iter().next().expect("one row"); - let economics_product = ResolvedOrderEconomicsProduct::from_summary(&row); - let listing_addr = normalize_optional(row.listing_addr.as_deref()).ok_or_else(|| { - RuntimeError::Config(format!( - "listing `{listing_lookup}` is missing a canonical listing address; run `radroots market pull` or pass `--listing-addr`" - )) - })?; - let parsed = parse_listing_addr(listing_addr.as_str()).map_err(|error| { - RuntimeError::Config(format!( - "listing `{listing_lookup}` has invalid listing_addr: {error}; run `radroots market pull` or pass `--listing-addr`" - )) - })?; - if parsed.kind != KIND_LISTING { - return Err(RuntimeError::Config(format!( - "listing `{listing_lookup}` listing_addr must reference a public NIP-99 listing; run `radroots market pull` or pass `--listing-addr`" - ))); - } - - let listing_event_id = resolve_active_listing_event_id( - config, - listing_addr.as_str(), - &parsed, - )? - .ok_or_else(|| { - RuntimeError::Config(format!( - "listing `{listing_lookup}` is missing the latest listing event pointer; run `radroots market pull` before creating a trade from this listing" - )) - })?; - let shared_provenance = resolve_shared_signed_listing_provenance( - config, - listing_addr.as_str(), - Some(listing_event_id.as_str()), - )?; - let listing_relays = listing_provenance_relays( - config, - listing_event_id.as_str(), - shared_provenance.as_ref(), - )?; - - Ok(Some(ResolvedOrderListing { - listing_addr, - listing_event_id, - listing_relays, - seller_pubkey: parsed.seller_pubkey, - economics_product: Some(economics_product), - })) - } - count => Err(RuntimeError::Config(format!( - "listing lookup `{listing_lookup}` matched {count} local listings; use a unique product key or pass `--listing-addr`" - ))), - } -} - -fn resolve_trade_product_by_listing_addr( - config: &RuntimeConfig, - listing_addr: &str, -) -> Result<Option<ResolvedOrderEconomicsProduct>, RuntimeError> { - if !config.local.replica_store_path.exists() { - return Ok(None); - } - - let executor = SqlxSqliteExecutor::open(&config.local.replica_store_path)?; - let product_rows = trade_product::find_many( - &executor, - &ITradeProductFindMany { - filter: Some(trade_product_listing_addr_filter(listing_addr)), - }, - ) - .map_err(|error| RuntimeError::Config(format!("resolve listing product state: {error:?}")))? - .results; - - match product_rows.len() { - 0 => Ok(None), - 1 => Ok(product_rows - .into_iter() - .next() - .map(ResolvedOrderEconomicsProduct::from_product)), - count => Err(RuntimeError::Config(format!( - "listing address `{listing_addr}` matched {count} active local listing rows" - ))), - } -} - -fn resolve_active_listing_event_id( - config: &RuntimeConfig, - listing_addr: &str, - parsed: &ParsedListingAddress, -) -> Result<Option<String>, RuntimeError> { - if !config.local.replica_store_path.exists() { - return Ok(None); - } - - let executor = SqlxSqliteExecutor::open(&config.local.replica_store_path)?; - let product_rows = trade_product::find_many( - &executor, - &ITradeProductFindMany { - filter: Some(trade_product_listing_addr_filter(listing_addr)), - }, - ) - .map_err(|error| RuntimeError::Config(format!("resolve listing product state: {error:?}")))? - .results; - - match product_rows.len() { - 0 => return Ok(None), - 1 => {} - count => { - return Err(RuntimeError::Config(format!( - "listing address `{listing_addr}` matched {count} active local listing rows" - ))); - } - } - - let key = format!( - "{}:{}:{}", - parsed.kind, parsed.seller_pubkey, parsed.listing_id - ); - let state = nostr_event_head::find_one( - &executor, - &INostrEventHeadFindOne::On(INostrEventHeadFindOneArgs { - on: NostrEventHeadQueryBindValues::Key { key }, - }), - ) - .map_err(|error| RuntimeError::Config(format!("resolve listing event state: {error:?}")))? - .result; - - let Some(state) = state else { - return Ok(None); - }; - if !is_valid_event_id(state.last_event_id.as_str()) { - return Err(RuntimeError::Config(format!( - "listing address `{listing_addr}` has invalid latest listing event id in local replica" - ))); - } - - Ok(Some(state.last_event_id)) -} - -#[derive(Debug, Clone)] -struct SharedListingProvenance { - event_id: String, - relays: Vec<String>, -} - -fn listing_provenance_relays( - config: &RuntimeConfig, - listing_event_id: &str, - shared_provenance: Option<&SharedListingProvenance>, -) -> Result<Vec<String>, RuntimeError> { - let mut relays = Vec::<String>::new(); - if let Some(provenance) = shared_provenance - && provenance.event_id == listing_event_id - { - relays.extend(provenance.relays.iter().cloned()); - } - relays.extend(relay_provenance_relays_for_scope( - config, - RelayIngestScope::MarketPull, - )?); - normalize_listing_relay_set(relays) - .map_err(|error| RuntimeError::Config(format!("listing provenance relays: {error}"))) -} - -fn resolve_shared_signed_listing_provenance( - config: &RuntimeConfig, - listing_addr: &str, - listing_event_id: Option<&str>, -) -> Result<Option<SharedListingProvenance>, RuntimeError> { - let _ = (config, listing_addr, listing_event_id); - Ok(None) -} - -fn trade_product_listing_addr_filter(listing_addr: &str) -> ITradeProductFieldsFilter { - ITradeProductFieldsFilter { - id: None, - created_at: None, - updated_at: None, - key: None, - category: None, - title: None, - summary: None, - process: None, - lot: None, - profile: None, - year: None, - qty_amt: None, - qty_amt_exact: None, - qty_unit: None, - qty_label: None, - qty_avail: None, - price_amt: None, - price_amt_exact: None, - price_currency: None, - price_qty_amt: None, - price_qty_amt_exact: None, - price_qty_unit: None, - listing_addr: Some(listing_addr.to_owned()), - primary_bin_id: None, - verified_primary_bin_id: None, - notes: None, - } -} - -fn order_economics_from_resolved_listing( - order_id: &str, - resolved_listing: Option<&ResolvedOrderListing>, - items: &[OrderDraftItem], - adjustments: &[crate::cli::global::OrderDraftAdjustmentArgs], -) -> Result<Option<RadrootsOrderEconomics>, RuntimeError> { - let Some(listing) = resolved_listing else { - return Ok(None); - }; - let Some(product) = listing.economics_product.as_ref() else { - return Ok(None); - }; - let Some(primary_bin_id) = product.primary_bin_id.as_deref().and_then(non_empty_ref) else { - return Ok(None); - }; - let Some(verified_primary_bin_id) = product - .verified_primary_bin_id - .as_deref() - .and_then(non_empty_ref) - else { - return Err(RuntimeError::Config(format!( - "listing_primary_bin_invalid: listing `{}` primary bin `{primary_bin_id}` is not verified in the current local replica", - listing.listing_addr - ))); - }; - if verified_primary_bin_id != primary_bin_id { - return Err(RuntimeError::Config(format!( - "listing_primary_bin_invalid: listing `{}` primary bin `{primary_bin_id}` does not match verified primary bin `{verified_primary_bin_id}` in the current local replica", - listing.listing_addr - ))); - } - if items.is_empty() - || items - .iter() - .any(|item| item.bin_id.as_str() != primary_bin_id) - { - return Ok(None); - } - - let currency = parse_economics_currency(product.price_currency.as_str(), "price_currency")?; - let quantity_amount = - exact_non_negative_decimal(product.qty_amt_exact.as_deref(), "qty_amt_exact")?; - let quantity_unit = parse_economics_unit(product.qty_unit.as_str(), "qty_unit")?; - let price_amount = - exact_non_negative_decimal(product.price_amt_exact.as_deref(), "price_amt_exact")?; - let price_quantity_amount = exact_positive_decimal( - product.price_qty_amt_exact.as_deref(), - "price_qty_amt_exact", - )?; - let price_unit = parse_economics_unit(product.price_qty_unit.as_str(), "price_qty_unit")?; - let quantity_unit_in_price_units = - convert_unit_decimal(RadrootsCoreDecimal::ONE, quantity_unit, price_unit).map_err( - |error| { - RuntimeError::Config(format!( - "listing quantity unit and price unit are incompatible: {error}" - )) - }, - )?; - let unit_price_amount = (price_amount / price_quantity_amount) * quantity_unit_in_price_units; - - let mut subtotal_amount = RadrootsCoreDecimal::ZERO; - let mut economic_items = Vec::with_capacity(items.len()); - for item in items { - let line_amount = - unit_price_amount * quantity_amount * RadrootsCoreDecimal::from(item.bin_count); - subtotal_amount = subtotal_amount + line_amount; - economic_items.push(RadrootsOrderEconomicItem { - bin_id: protocol_inventory_bin_id(item.bin_id.as_str(), "order item bin_id")?, - bin_count: item.bin_count, - quantity_amount, - quantity_unit, - unit_price_amount, - unit_price_currency: currency, - line_subtotal: RadrootsCoreMoney::new(line_amount, currency), - }); - } - - let subtotal = RadrootsCoreMoney::new(subtotal_amount, currency); - let discounts = listing_discount_lines_from_product( - product, - &subtotal, - items, - quantity_amount, - quantity_unit, - )?; - let adjustments = basket_adjustment_lines(adjustments)?; - let zero = RadrootsCoreMoney::zero(currency); - let mut economics = RadrootsOrderEconomics { - quote_id: protocol_quote_id(format!("quote_{order_id}").as_str(), "quote_id")?, - quote_version: 1, - pricing_basis: RadrootsOrderPricingBasis::ListingEvent, - currency, - items: economic_items, - discounts, - adjustments, - subtotal: subtotal.clone(), - discount_total: zero.clone(), - adjustment_total: zero, - total: subtotal, - }; - economics.canonicalize(); - economics - .validate() - .map_err(|error| RuntimeError::Config(format!("build order economics: {error}")))?; - Ok(Some(economics)) -} - -fn listing_discount_lines_from_product( - product: &ResolvedOrderEconomicsProduct, - subtotal: &RadrootsCoreMoney, - items: &[OrderDraftItem], - quantity_amount: RadrootsCoreDecimal, - quantity_unit: RadrootsCoreUnit, -) -> Result<Vec<RadrootsOrderEconomicLine>, RuntimeError> { - let Some(notes) = product.notes.as_deref().and_then(non_empty_ref) else { - return Ok(Vec::new()); - }; - let parsed = serde_json::from_str::<ResolvedTradeProductNotes>(notes).map_err(|error| { - RuntimeError::Config(format!("listing discount metadata is invalid: {error}")) - })?; - let mut lines = Vec::new(); - for (index, discount) in parsed.listing_discounts.iter().enumerate() { - if !discount_applies(discount, items, quantity_amount, quantity_unit)? { - continue; - } - let amount = listing_discount_amount(discount, subtotal, items)?; - if amount.is_zero() { - return Err(RuntimeError::Config( - "listing discount amount must be greater than zero".to_owned(), - )); - } - lines.push(RadrootsOrderEconomicLine { - id: format!("listing_discount_{}", index + 1), - kind: RadrootsOrderEconomicLineKind::ListingDiscount, - actor: RadrootsOrderEconomicActor::Seller, - effect: RadrootsOrderEconomicEffect::Decrease, - amount, - reason: format!("listing discount {}", index + 1), - }); - } - Ok(lines) -} - -fn discount_applies( - discount: &RadrootsCoreDiscount, - items: &[OrderDraftItem], - quantity_amount: RadrootsCoreDecimal, - quantity_unit: RadrootsCoreUnit, -) -> Result<bool, RuntimeError> { - match &discount.threshold { - RadrootsCoreDiscountThreshold::BinCount { bin_id, min } => Ok(items - .iter() - .any(|item| item.bin_id == *bin_id && item.bin_count >= *min)), - RadrootsCoreDiscountThreshold::OrderQuantity { min } => { - let requested = items.iter().fold(RadrootsCoreDecimal::ZERO, |total, item| { - total + quantity_amount * RadrootsCoreDecimal::from(item.bin_count) - }); - let converted = - convert_unit_decimal(requested, quantity_unit, min.unit).map_err(|error| { - RuntimeError::Config(format!( - "listing discount quantity threshold is incompatible: {error}" - )) - })?; - Ok(converted >= min.amount) - } - } -} - -fn listing_discount_amount( - discount: &RadrootsCoreDiscount, - subtotal: &RadrootsCoreMoney, - items: &[OrderDraftItem], -) -> Result<RadrootsCoreMoney, RuntimeError> { - match &discount.value { - RadrootsCoreDiscountValue::Percent(percent) => Ok(percent.of_money(subtotal)), - RadrootsCoreDiscountValue::MoneyPerBin(money) => { - if money.currency != subtotal.currency { - return Err(RuntimeError::Config( - "listing discount currency must match listing price currency".to_owned(), - )); - } - let multiplier = match &discount.scope { - RadrootsCoreDiscountScope::Bin => { - items.iter().map(|item| item.bin_count).sum::<u32>().max(1) - } - RadrootsCoreDiscountScope::OrderTotal => 1, - }; - Ok(money.mul_decimal(RadrootsCoreDecimal::from(multiplier))) - } - } -} - -fn basket_adjustment_lines( - adjustments: &[crate::cli::global::OrderDraftAdjustmentArgs], -) -> Result<Vec<RadrootsOrderEconomicLine>, RuntimeError> { - adjustments - .iter() - .map(|adjustment| { - let currency = - parse_economics_currency(adjustment.currency.as_str(), "adjustment_currency")?; - let amount = decimal_from_adjustment(adjustment.amount.as_str(), "adjustment_amount")?; - if amount.is_zero() { - return Err(RuntimeError::Config( - "basket adjustment amount must be greater than zero".to_owned(), - )); - } - let effect = match adjustment.effect.as_str() { - "increase" => RadrootsOrderEconomicEffect::Increase, - "decrease" => RadrootsOrderEconomicEffect::Decrease, - other => { - return Err(RuntimeError::Config(format!( - "basket adjustment effect `{other}` is invalid" - ))); - } - }; - if adjustment.id.trim().is_empty() { - return Err(RuntimeError::Config( - "basket adjustment id must not be empty".to_owned(), - )); - } - if adjustment.reason.trim().is_empty() { - return Err(RuntimeError::Config( - "basket adjustment reason must not be empty".to_owned(), - )); - } - Ok(RadrootsOrderEconomicLine { - id: adjustment.id.trim().to_owned(), - kind: RadrootsOrderEconomicLineKind::BasketAdjustment, - actor: RadrootsOrderEconomicActor::Buyer, - effect, - amount: RadrootsCoreMoney::new(amount, currency), - reason: adjustment.reason.trim().to_owned(), - }) - }) - .collect() -} - -fn parse_economics_currency( - value: &str, - field: &str, -) -> Result<RadrootsCoreCurrency, RuntimeError> { - value - .parse::<RadrootsCoreCurrency>() - .map_err(|error| RuntimeError::Config(format!("listing {field} is invalid: {error}"))) -} - -fn parse_economics_unit(value: &str, field: &str) -> Result<RadrootsCoreUnit, RuntimeError> { - value - .parse::<RadrootsCoreUnit>() - .map_err(|error| RuntimeError::Config(format!("listing {field} is invalid: {error}"))) -} - -fn exact_non_negative_decimal( - value: Option<&str>, - field: &str, -) -> Result<RadrootsCoreDecimal, RuntimeError> { - let parsed = exact_decimal(value, field)?; - if parsed.is_sign_negative() { - return Err(RuntimeError::Config(format!( - "listing {field} must be non-negative" - ))); - } - Ok(parsed) -} - -fn exact_positive_decimal( - value: Option<&str>, - field: &str, -) -> Result<RadrootsCoreDecimal, RuntimeError> { - let parsed = exact_non_negative_decimal(value, field)?; - if parsed.is_zero() { - return Err(RuntimeError::Config(format!( - "listing {field} must be greater than zero" - ))); - } - Ok(parsed) -} - -fn exact_decimal(value: Option<&str>, field: &str) -> Result<RadrootsCoreDecimal, RuntimeError> { - let Some(value) = value.and_then(non_empty_ref) else { - return Err(RuntimeError::Config(format!( - "listing {field} exact source is missing" - ))); - }; - value - .parse::<RadrootsCoreDecimal>() - .map_err(|error| RuntimeError::Config(format!("listing {field} is invalid: {error}"))) -} - -fn decimal_from_adjustment(value: &str, field: &str) -> Result<RadrootsCoreDecimal, RuntimeError> { - let parsed = value - .trim() - .parse::<RadrootsCoreDecimal>() - .map_err(|error| RuntimeError::Config(format!("basket {field} is invalid: {error}")))?; - if parsed.is_sign_negative() { - return Err(RuntimeError::Config(format!( - "basket {field} must be non-negative" - ))); - } - Ok(parsed) -} - -fn view_from_loaded( - config: &RuntimeConfig, - loaded: LoadedOrderDraft, -) -> Result<OrderGetView, RuntimeError> { - view_from_loaded_with_source_issues(config, loaded, &[]) -} - -fn view_from_loaded_with_source_issues( - config: &RuntimeConfig, - loaded: LoadedOrderDraft, - source_issues: &[OrderIssueView], -) -> Result<OrderGetView, RuntimeError> { - let OrderInspection { - state, - ready_for_submit, - listing_addr, - listing_event_id, - seller_pubkey, - buyer_custody, - buyer_write_capable, - issues, - } = inspect_document_with_source_issues(config, &loaded.document, source_issues)?; - - let actions = actions_for_document(&loaded.document, loaded.file.as_path(), issues.as_slice()); - - Ok(OrderGetView { - state, - source: ORDER_SOURCE.to_owned(), - lookup: loaded.document.order.order_id.clone(), - order_id: Some(loaded.document.order.order_id.clone()), - file: Some(loaded.file.display().to_string()), - listing_lookup: loaded.document.listing_lookup.clone(), - listing_addr, - listing_event_id, - listing_relays: order_listing_relays(&loaded.document), - buyer_account_id: buyer_account_id(&loaded.document), - buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), - buyer_actor_source: buyer_actor_source(&loaded.document), - buyer_custody, - buyer_write_capable, - seller_pubkey, - ready_for_submit, - items: loaded - .document - .order - .items - .iter() - .map(|item| OrderDraftItemView { - bin_id: item.bin_id.clone(), - bin_count: item.bin_count, - }) - .collect(), - economics: loaded.document.order.economics.clone(), - updated_at_unix: Some(loaded.updated_at_unix), - job: None, - workflow: None, - reason: None, - issues, - actions, - }) -} - -fn summary_from_loaded( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, -) -> Result<OrderSummaryView, RuntimeError> { - summary_from_loaded_with_source_issues(config, loaded, &[]) -} - -fn summary_from_loaded_with_source_issues( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, - source_issues: &[OrderIssueView], -) -> Result<OrderSummaryView, RuntimeError> { - let OrderInspection { - state, - ready_for_submit, - listing_addr, - listing_event_id, - seller_pubkey: _, - buyer_custody, - buyer_write_capable, - issues, - } = inspect_document_with_source_issues(config, &loaded.document, source_issues)?; - - Ok(OrderSummaryView { - id: loaded.document.order.order_id.clone(), - state, - ready_for_submit, - file: loaded.file.display().to_string(), - listing_lookup: loaded.document.listing_lookup.clone(), - listing_addr, - listing_event_id, - listing_relays: order_listing_relays(&loaded.document), - buyer_account_id: buyer_account_id(&loaded.document), - buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), - buyer_actor_source: buyer_actor_source(&loaded.document), - buyer_custody, - buyer_write_capable, - item_count: loaded.document.order.items.len(), - economics: loaded.document.order.economics.clone(), - updated_at_unix: loaded.updated_at_unix, - job: None, - issues, - }) -} - -fn summary_for_invalid_file(path: &Path, reason: String) -> OrderSummaryView { - let id = path - .file_stem() - .and_then(|value| value.to_str()) - .unwrap_or("unknown") - .to_owned(); - OrderSummaryView { - id, - state: "error".to_owned(), - ready_for_submit: false, - file: path.display().to_string(), - listing_lookup: None, - listing_addr: None, - listing_event_id: None, - listing_relays: Vec::new(), - buyer_account_id: None, - buyer_pubkey: None, - buyer_actor_source: None, - buyer_custody: None, - buyer_write_capable: None, - item_count: 0, - economics: None, - updated_at_unix: modified_unix(path).unwrap_or_default(), - job: None, - issues: vec![issue_with_code("invalid_order_draft", "draft", reason)], - } -} - -fn app_order_local_records( - config: &RuntimeConfig, -) -> Result<Vec<RuntimeStoreRecord>, RuntimeError> { - let mut app_records = Vec::new(); - let mut before_cursor = None::<(i64, i64)>; - loop { - let shared_records = if let Some((before_change_seq, before_seq)) = before_cursor { - list_shared_records_before( - config, - before_change_seq, - before_seq, - ORDER_APP_RECORD_LIST_LIMIT, - )? - } else { - list_shared_records_latest(config, ORDER_APP_RECORD_LIST_LIMIT)? - }; - let Some(next_cursor) = shared_records - .last() - .map(|record| (record.change_seq, record.seq)) - else { - break; - }; - let has_more = shared_records.len() == ORDER_APP_RECORD_LIST_LIMIT as usize; - app_records.extend(shared_records.into_iter().filter(is_app_order_local_record)); - if !has_more { - break; - } - before_cursor = Some(next_cursor); - } - Ok(app_records) -} - -fn is_app_order_local_record(record: &RuntimeStoreRecord) -> bool { - record.source_runtime == SourceRuntime::App - && record.family == RuntimeStoreRecordFamily::LocalWork - && record.status == RuntimeStoreRecordStatus::LocalSaved - && local_record_kind(record).as_deref() == Some(BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND) -} - -fn current_app_order_record_entries( - mut records: Vec<RuntimeStoreRecord>, -) -> Vec<AppOrderRecordListEntry> { - records.sort_by(|left, right| { - right - .change_seq - .cmp(&left.change_seq) - .then_with(|| right.seq.cmp(&left.seq)) - .then_with(|| left.record_id.cmp(&right.record_id)) - }); - - let mut entries = Vec::<AppOrderRecordListEntry>::new(); - let mut seen = HashMap::<String, usize>::new(); - for record in records { - let key = app_order_record_current_key(&record); - if let Some(index) = seen.get(&key).copied() { - entries[index].superseded_count += 1; - } else { - seen.insert(key, entries.len()); - entries.push(AppOrderRecordListEntry { - record, - superseded_count: 0, - }); - } - } - entries -} - -fn current_app_order_record_for( - config: &RuntimeConfig, - record: &RuntimeStoreRecord, -) -> Result<Option<RuntimeStoreRecord>, RuntimeError> { - let key = app_order_record_current_key(record); - Ok(app_order_local_records(config)? - .into_iter() - .filter(|candidate| app_order_record_current_key(candidate) == key) - .max_by(|left, right| { - left.change_seq - .cmp(&right.change_seq) - .then_with(|| left.seq.cmp(&right.seq)) - })) -} - -fn app_order_conflicting_record_ids_for( - config: &RuntimeConfig, - record: &RuntimeStoreRecord, -) -> Result<Vec<String>, RuntimeError> { - if app_order_record_order_id(record).is_none() { - return Ok(Vec::new()); - } - let key = app_order_record_current_key(record); - let mut record_ids = app_order_local_records(config)? - .into_iter() - .filter(|candidate| candidate.record_id != record.record_id) - .filter(|candidate| app_order_record_current_key(candidate) == key) - .map(|candidate| candidate.record_id) - .collect::<Vec<_>>(); - record_ids.sort(); - record_ids.dedup(); - Ok(record_ids) -} - -fn load_app_order_record_for_lookup( - config: &RuntimeConfig, - lookup: &str, -) -> Result<Option<LoadedAppOrderRecord>, RuntimeError> { - if let Some(record) = get_shared_record(config, lookup)? - && is_app_order_local_record(&record) - { - return load_app_order_record_from_record(config, record).map(Some); - } - for entry in current_app_order_record_entries(app_order_local_records(config)?) { - if app_order_record_order_id(&entry.record).as_deref() == Some(lookup) { - return load_app_order_record_from_record(config, entry.record).map(Some); - } - } - Ok(None) -} - -fn load_app_order_record_from_record( - config: &RuntimeConfig, - record: RuntimeStoreRecord, -) -> Result<LoadedAppOrderRecord, RuntimeError> { - let mut source_issues = app_order_record_source_issues(config, &record)?; - let payload = record.local_work_json.clone().unwrap_or(Value::Null); - let document = match payload.get("document").cloned() { - Some(value) => match serde_json::from_value::<OrderDraftDocument>(value) { - Ok(document) => document, - Err(error) => { - source_issues.push(issue_with_code( - "invalid_app_order_record", - "document", - format!("app-authored order document cannot be decoded: {error}"), - )); - placeholder_app_order_document(&record) - } - }, - None => { - source_issues.push(issue_with_code( - "invalid_app_order_record", - "document", - "app-authored trade record is missing document", - )); - placeholder_app_order_document(&record) - } - }; - let loaded = LoadedOrderDraft { - file: PathBuf::from(format!("shared-runtime-store/{}", record.record_id)), - updated_at_unix: u64::try_from(record.updated_at_ms / 1000).unwrap_or_default(), - document, - }; - source_issues.extend(app_order_signed_evidence_issues(config, &loaded)?); - - Ok(LoadedAppOrderRecord { - loaded, - source_issues, - }) -} - -fn app_order_record_source_issues( - config: &RuntimeConfig, - record: &RuntimeStoreRecord, -) -> Result<Vec<OrderIssueView>, RuntimeError> { - let mut issues = Vec::new(); - if record.source_runtime != SourceRuntime::App { - issues.push(issue_with_code( - "app_order_unsupported", - "source_runtime", - "trade record must come from radroots_studio_app", - )); - } - if record.family != RuntimeStoreRecordFamily::LocalWork { - issues.push(issue_with_code( - "app_order_unsupported", - "family", - "trade record must be shared local work", - )); - } - if record.status != RuntimeStoreRecordStatus::LocalSaved { - issues.push(issue_with_code( - "app_order_unsupported", - "status", - format!( - "trade record status `{}` is not consumable as local saved work", - record.status.as_str() - ), - )); - } - let Some(payload) = record.local_work_json.as_ref() else { - issues.push(issue_with_code( - "invalid_app_order_record", - "local_work_json", - "app-authored trade record is missing local work payload", - )); - return Ok(issues); - }; - let current = payload["currentness"]["current"].as_bool() == Some(true); - if !current { - issues.push(issue_with_code( - "app_order_stale", - "currentness.current", - "app-authored trade record is not marked current", - )); - } - if payload["currentness"]["record_id"].as_str() != Some(record.record_id.as_str()) { - issues.push(issue_with_code( - "invalid_app_order_record", - "currentness.record_id", - "app-authored trade record currentness id does not match the shared record id", - )); - } - if current { - match validate_supported_buyer_order_request_local_work_payload(payload) { - Ok(_) => {} - Err(error) => { - let support_state = payload["support_status"]["state"].as_str(); - let support_issues = payload["support_status"]["issues"] - .as_array() - .cloned() - .unwrap_or_default(); - if support_state == Some("unsupported") { - issues.push(issue_with_code( - "app_order_unsupported", - "support_status.state", - "app-authored trade record is not marked supported", - )); - for support_issue in support_issues { - if let Some(support_issue) = support_issue.as_str() { - issues.push(issue_with_code( - "app_order_unsupported", - "support_status.issues", - format!("app order support issue: {support_issue}"), - )); - } - } - } else { - issues.push(issue_with_code( - "invalid_app_order_record", - "local_work_json", - error.to_string(), - )); - } - } - } - } - if let Some(current_record) = current_app_order_record_for(config, record)? - && current_record.record_id != record.record_id - { - issues.push(issue_with_code( - "app_order_stale", - "record_id", - format!( - "app-authored local trade record `{}` was superseded by `{}`", - record.record_id, current_record.record_id - ), - )); - } - let conflicting_record_ids = app_order_conflicting_record_ids_for(config, record)?; - if !conflicting_record_ids.is_empty() { - issues.push(issue_with_code( - "app_order_conflict", - "order_id", - format!( - "app-authored order id conflicts with other shared records: {}", - conflicting_record_ids.join(", ") - ), - )); - } - Ok(issues) -} - -fn app_order_signed_evidence_issues( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, -) -> Result<Vec<OrderIssueView>, RuntimeError> { - let order_id = loaded.document.order.order_id.as_str(); - let candidate_records = visible_signed_order_request_records(config, order_id)?; - if candidate_records.is_empty() { - return Ok(Vec::new()); - } - - let expected_payload = match canonical_order_request_payload_from_loaded( - loaded, - loaded.document.order.buyer_pubkey.as_str(), - ) { - Ok(payload) => payload, - Err(error) => { - let event_ids = candidate_records - .iter() - .map(signed_record_event_id) - .collect::<Vec<_>>(); - return Ok(vec![issue_with_events( - APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE, - "signed_event", - format!( - "signed order request evidence cannot be compared with local work: {error}" - ), - event_ids, - )]); - } - }; - - let mut submitted_event_ids = Vec::new(); - let mut conflict_issues = Vec::new(); - for record in candidate_records { - let event_id = signed_record_event_id(&record); - match signed_order_request_from_record(&record) - .and_then(|event| order_submit_request_from_event(&event, loaded)) - { - Ok(request) - if order_submit_request_matches_draft(&request, loaded, &expected_payload) => - { - submitted_event_ids.push(request.request_event_id); - } - Ok(request) => conflict_issues.push(issue_with_events( - APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE, - "signed_event", - format!( - "signed order request event `{}` conflicts with the app-authored local order", - request.request_event_id - ), - vec![request.request_event_id], - )), - Err(error) => conflict_issues.push(issue_with_events( - APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE, - "signed_event", - format!("signed order request event `{event_id}` cannot be validated: {error}"), - vec![event_id], - )), - } - } - - conflict_issues.sort_by(|left, right| { - left.event_ids - .cmp(&right.event_ids) - .then_with(|| left.message.cmp(&right.message)) - }); - if !conflict_issues.is_empty() { - return Ok(conflict_issues); - } - - submitted_event_ids.sort(); - submitted_event_ids.dedup(); - if submitted_event_ids.is_empty() { - Ok(Vec::new()) - } else { - Ok(vec![issue_with_events( - APP_ORDER_ALREADY_SUBMITTED_ISSUE, - "signed_event", - "app-authored local order already has matching signed order request evidence", - submitted_event_ids, - )]) - } -} - -fn visible_signed_order_request_records( - config: &RuntimeConfig, - order_id: &str, -) -> Result<Vec<RuntimeStoreRecord>, RuntimeError> { - let mut records = Vec::new(); - let mut before_cursor = None::<(i64, i64)>; - loop { - let shared_records = if let Some((before_change_seq, before_seq)) = before_cursor { - list_shared_records_before( - config, - before_change_seq, - before_seq, - ORDER_APP_RECORD_LIST_LIMIT, - )? - } else { - list_shared_records_latest(config, ORDER_APP_RECORD_LIST_LIMIT)? - }; - let Some(next_cursor) = shared_records - .last() - .map(|record| (record.change_seq, record.seq)) - else { - break; - }; - let has_more = shared_records.len() == ORDER_APP_RECORD_LIST_LIMIT as usize; - records.extend( - shared_records - .into_iter() - .filter(|record| is_visible_signed_order_request_record(record, order_id)), - ); - if !has_more { - break; - } - before_cursor = Some(next_cursor); - } - Ok(records) -} - -fn is_visible_signed_order_request_record(record: &RuntimeStoreRecord, order_id: &str) -> bool { - record.family == RuntimeStoreRecordFamily::SignedEvent - && record.status == RuntimeStoreRecordStatus::Published - && record.outbox_status == PublishOutboxStatus::Acknowledged - && record.event_kind == Some(i64::from(KIND_ORDER_REQUEST)) - && signed_record_tag_values(record, "d") - .iter() - .any(|value| value == order_id) -} - -fn signed_order_request_from_record( - record: &RuntimeStoreRecord, -) -> Result<RadrootsNostrEvent, RuntimeError> { - let raw_event_json = record.raw_event_json.as_ref().ok_or_else(|| { - RuntimeError::Config(format!( - "signed event record `{}` is missing raw_event_json", - record.record_id - )) - })?; - serde_json::from_value::<RadrootsNostrEvent>(raw_event_json.clone()).map_err(|error| { - RuntimeError::Config(format!( - "signed event record `{}` raw_event_json cannot be decoded: {error}", - record.record_id - )) - }) -} - -fn signed_record_tag_values(record: &RuntimeStoreRecord, key: &str) -> Vec<String> { - record - .event_tags_json - .as_ref() - .or(record - .raw_event_json - .as_ref() - .and_then(|event| event.get("tags"))) - .and_then(Value::as_array) - .map(|tags| { - tags.iter() - .filter_map(Value::as_array) - .filter_map(|tag| { - if tag.first().and_then(Value::as_str) == Some(key) { - tag.get(1).and_then(Value::as_str).map(str::to_owned) - } else { - None - } - }) - .collect::<Vec<_>>() - }) - .unwrap_or_default() -} - -fn signed_record_event_id(record: &RuntimeStoreRecord) -> String { - record - .event_id - .clone() - .unwrap_or_else(|| record.record_id.clone()) -} - -fn source_and_document_issues( - config: &RuntimeConfig, - app_order: &LoadedAppOrderRecord, -) -> Result<Vec<OrderIssueView>, RuntimeError> { - Ok(inspect_document_with_source_issues( - config, - &app_order.loaded.document, - app_order.source_issues.as_slice(), - )? - .issues) -} - -fn app_order_record_summary( - config: &RuntimeConfig, - record: &RuntimeStoreRecord, - superseded_count: usize, -) -> Result<OrderAppRecordSummaryView, RuntimeError> { - let record_kind = local_record_kind(record).unwrap_or_else(|| "unknown".to_owned()); - let app_order = load_app_order_record_from_record(config, record.clone())?; - let issues = source_and_document_issues(config, &app_order)?; - let exportable = issues.is_empty(); - let reason = issues.first().map(|issue| issue.message.clone()); - let document = &app_order.loaded.document; - let status = if app_order_issue_present(&issues, APP_ORDER_ALREADY_SUBMITTED_ISSUE) { - "submitted".to_owned() - } else if app_order_issue_present(&issues, APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE) { - "conflict".to_owned() - } else { - record.status.as_str().to_owned() - }; - let actions = if exportable { - vec![ - format!("radroots trade get {}", document.order.order_id), - format!("radroots trade app export {}", record.record_id), - format!("radroots trade request {}", document.order.order_id), - ] - } else if app_order_issue_present(&issues, APP_ORDER_ALREADY_SUBMITTED_ISSUE) { - vec![format!("radroots trade get {}", document.order.order_id)] - } else if app_order_issue_present(&issues, APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE) { - vec![ - format!("radroots trade get {}", document.order.order_id), - "radroots trade app list".to_owned(), - ] - } else { - Vec::new() - }; - Ok(OrderAppRecordSummaryView { - record_id: record.record_id.clone(), - seq: record.seq, - change_seq: record.change_seq, - superseded_count, - record_kind, - status, - source_runtime: record.source_runtime.as_str().to_owned(), - owner_account_id: record.owner_account_id.clone(), - owner_pubkey: record.owner_pubkey.clone(), - farm_id: record.farm_id.clone(), - listing_addr: record - .listing_addr - .clone() - .or_else(|| non_empty_string(app_order.loaded.document.order.listing_addr.clone())), - listing_relays: order_listing_relays(document), - order_id: non_empty_string(document.order.order_id.clone()), - buyer_account_id: buyer_account_id(document), - buyer_pubkey: non_empty_string(document.order.buyer_pubkey.clone()), - seller_pubkey: non_empty_string(document.order.seller_pubkey.clone()), - ready_for_submit: exportable, - exportable, - reason, - actions, - }) -} - -fn app_order_record_current_key(record: &RuntimeStoreRecord) -> String { - app_order_record_order_id(record) - .map(|order_id| format!("order:{order_id}")) - .unwrap_or_else(|| format!("record:{}", record.record_id)) -} - -fn app_order_record_order_id(record: &RuntimeStoreRecord) -> Option<String> { - record - .local_work_json - .as_ref() - .and_then(|payload| payload["document"]["order"]["order_id"].as_str()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned) -} - -fn placeholder_app_order_document(record: &RuntimeStoreRecord) -> OrderDraftDocument { - OrderDraftDocument { - version: 0, - kind: "invalid_app_order_record".to_owned(), - order: OrderDraft { - order_id: app_order_record_order_id(record).unwrap_or_else(|| record.record_id.clone()), - listing_addr: String::new(), - listing_event_id: String::new(), - listing_relays: Vec::new(), - buyer_pubkey: String::new(), - seller_pubkey: String::new(), - items: Vec::new(), - economics: None, - }, - buyer_actor: OrderDraftBuyerActor { - account_id: String::new(), - pubkey: String::new(), - source: String::new(), - }, - listing_lookup: None, - } -} - -fn app_order_export_failure_state(issues: &[OrderIssueView]) -> &'static str { - if issues - .iter() - .any(|issue| issue.code == APP_ORDER_ALREADY_SUBMITTED_ISSUE) - { - "already_submitted" - } else if issues.iter().any(|issue| { - issue.code == "app_order_conflict" || issue.code == APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE - }) { - "conflict" - } else if issues.iter().any(|issue| issue.code == "app_order_stale") { - "stale" - } else if issues - .iter() - .any(|issue| issue.code == "invalid_app_order_record") - { - "invalid" - } else if issues - .iter() - .any(|issue| issue.code == "app_order_unsupported") - { - "unsupported" - } else { - "invalid" - } -} - -fn app_order_export_failure_actions( - document: &OrderDraftDocument, - issues: &[OrderIssueView], -) -> Vec<String> { - if app_order_issue_present(issues, APP_ORDER_ALREADY_SUBMITTED_ISSUE) { - vec![format!("radroots trade get {}", document.order.order_id)] - } else if app_order_issue_present(issues, APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE) { - vec![ - format!("radroots trade get {}", document.order.order_id), - "radroots trade app list".to_owned(), - ] - } else { - vec!["radroots trade app list".to_owned()] - } -} - -fn order_export_output_path( - config: &RuntimeConfig, - output: Option<&PathBuf>, - order_id: &str, -) -> PathBuf { - output - .cloned() - .unwrap_or_else(|| drafts_dir(config).join(format!("{order_id}.toml"))) -} - -fn validate_order_export_output_target(output_path: &Path) -> Result<(), RuntimeError> { - if output_path.exists() { - return Err(RuntimeError::Config(format!( - "trade draft output {} must not already exist", - output_path.display() - ))); - } - if let Some(parent) = output_path.parent() - && parent.exists() - && !parent.is_dir() - { - return Err(RuntimeError::Config(format!( - "trade draft parent {} is not a directory", - parent.display() - ))); - } - Ok(()) -} - -fn local_record_kind(record: &RuntimeStoreRecord) -> Option<String> { - record - .local_work_json - .as_ref() - .and_then(|payload| payload.get("record_kind")) - .and_then(Value::as_str) - .map(str::to_owned) -} - -fn inspect_document_with_source_issues( - config: &RuntimeConfig, - document: &OrderDraftDocument, - source_issues: &[OrderIssueView], -) -> Result<OrderInspection, RuntimeError> { - let listing_addr = non_empty_string(document.order.listing_addr.clone()); - let listing_event_id = non_empty_string(document.order.listing_event_id.clone()); - let parsed_listing_addr = listing_addr - .as_deref() - .and_then(|value| parse_listing_addr(value).ok()); - let seller_pubkey = non_empty_string(document.order.seller_pubkey.clone()).or_else(|| { - parsed_listing_addr - .as_ref() - .map(|listing| listing.seller_pubkey.clone()) - }); - let mut issues = collect_issues(document); - let buyer_readiness = inspect_buyer_actor_readiness(config, document)?; - issues.extend(buyer_readiness.issues); - issues.extend(source_issues.iter().cloned()); - let ready_for_submit = issues.is_empty(); - let state = if app_order_issue_present(&issues, APP_ORDER_ALREADY_SUBMITTED_ISSUE) { - "submitted".to_owned() - } else if app_order_issue_present(&issues, APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE) { - "conflict".to_owned() - } else if ready_for_submit { - "ready".to_owned() - } else { - "draft".to_owned() - }; - - Ok(OrderInspection { - state, - ready_for_submit, - listing_addr, - listing_event_id, - seller_pubkey, - buyer_custody: buyer_readiness - .account - .as_ref() - .map(|account| account.custody.as_str().to_owned()), - buyer_write_capable: buyer_readiness - .account - .as_ref() - .map(|account| account.write_capable), - issues, - }) -} - -#[derive(Debug, Clone)] -struct OrderBuyerActorReadiness { - account: Option<account::AccountRecordView>, - issues: Vec<OrderIssueView>, -} - -fn inspect_buyer_actor_readiness( - config: &RuntimeConfig, - document: &OrderDraftDocument, -) -> Result<OrderBuyerActorReadiness, RuntimeError> { - let account_id = document.buyer_actor.account_id.trim(); - let buyer_pubkey = document.buyer_actor.pubkey.trim(); - if account_id.is_empty() || buyer_pubkey.is_empty() { - return Ok(OrderBuyerActorReadiness { - account: None, - issues: Vec::new(), - }); - } - - let snapshot = account::snapshot(config)?; - let Some(account) = snapshot - .accounts - .into_iter() - .find(|account| account.record.account_id.as_str() == account_id) - else { - return Ok(OrderBuyerActorReadiness { - account: None, - issues: vec![issue_with_code( - "account_unresolved", - "buyer_actor.account_id", - format!( - "order buyer_actor account_id `{account_id}` is not present in the local account store" - ), - )], - }); - }; - - let account_pubkey = account.record.public_identity.public_key_hex.as_str(); - let mut issues = Vec::new(); - if !account_pubkey.eq_ignore_ascii_case(buyer_pubkey) { - issues.push(issue_with_code( - "account_mismatch", - "buyer_actor.pubkey", - format!( - "order buyer_actor pubkey `{buyer_pubkey}` does not match local account `{account_id}` pubkey `{account_pubkey}`" - ), - )); - } - if !account.write_capable { - issues.push(issue_with_code( - "account_watch_only", - "buyer_actor.account_id", - format!( - "order buyer_actor account `{account_id}` is watch_only and cannot sign until a matching secret is attached" - ), - )); - } - - Ok(OrderBuyerActorReadiness { - account: Some(account), - issues, - }) -} - -fn collect_issues(document: &OrderDraftDocument) -> Vec<OrderIssueView> { - let mut issues = Vec::new(); - if document.version != 1 { - issues.push(issue("version", "version must be 1")); - } - if document.kind != ORDER_DRAFT_KIND { - issues.push(issue("kind", format!("kind must be `{ORDER_DRAFT_KIND}`"))); - } - if !is_valid_order_id(document.order.order_id.as_str()) { - issues.push(issue( - "order.order_id", - "order_id must look like `ord_<base64url>` or a canonical UUID", - )); - } - - match normalize_optional(Some(document.order.listing_addr.as_str())) { - Some(listing_addr) => match parse_listing_addr(listing_addr.as_str()) { - Ok(parsed) => { - if parsed.kind != KIND_LISTING { - issues.push(issue( - "trade.listing_addr", - "listing_addr must reference a public NIP-99 listing", - )); - } - if let Some(seller_pubkey) = non_empty_string(document.order.seller_pubkey.clone()) - && seller_pubkey != parsed.seller_pubkey - { - issues.push(issue( - "order.seller_pubkey", - "seller_pubkey must match listing_addr seller when both are set", - )); - } - } - Err(error) => issues.push(issue( - "trade.listing_addr", - format!("listing_addr is invalid: {error}"), - )), - }, - None => issues.push(issue( - "trade.listing_addr", - "listing_addr is required before trade request", - )), - } - - match normalize_optional(Some(document.order.listing_event_id.as_str())) { - Some(listing_event_id) => { - if !is_valid_event_id(listing_event_id.as_str()) { - issues.push(issue( - "trade.listing_event_id", - "listing_event_id must be a 64-character hex Nostr event id", - )); - } - } - None => issues.push(issue( - "trade.listing_event_id", - "latest active listing event id is required before trade request; run `radroots market pull` and create the trade from local market data", - )), - } - - match normalize_listing_relay_set(document.order.listing_relays.iter()) { - Ok(listing_relays) if listing_relays.is_empty() => issues.push(issue_with_code( - "listing_provenance_missing", - "trade.listing_relays", - "listing relay provenance is required before trade request; run `radroots market pull` and create the trade from current local market data", - )), - Ok(_) => {} - Err(error) => issues.push(issue_with_code( - "listing_provenance_invalid", - "trade.listing_relays", - format!("listing relay provenance is invalid: {error}"), - )), - } - - if document.order.items.is_empty() { - issues.push(issue( - "order.items", - "at least one order item is required before trade request", - )); - } - for (index, item) in document.order.items.iter().enumerate() { - if item.bin_id.trim().is_empty() { - issues.push(issue( - format!("order.items[{index}].bin_id"), - "bin_id must not be empty", - )); - } - if item.bin_count == 0 { - issues.push(issue( - format!("order.items[{index}].bin_count"), - "bin_count must be greater than zero", - )); - } - } - - match &document.order.economics { - Some(economics) => { - if let Err(error) = economics.validate() { - issues.push(issue( - "order.economics", - format!("order economics is invalid: {error}"), - )); - } - if !order_items_match_economics(document.order.items.as_slice(), economics) { - issues.push(issue( - "order.economics", - "order economics must match the order item bin ids and counts", - )); - } - } - None => issues.push(issue( - "order.economics", - "quote economics is required before trade request; run `radroots basket quote create` from current local market data", - )), - } - - if document.buyer_actor.account_id.trim().is_empty() { - issues.push(issue( - "buyer_actor.account_id", - "buyer_actor account_id is required before trade request", - )); - } - if document.buyer_actor.pubkey.trim().is_empty() { - issues.push(issue( - "buyer_actor.pubkey", - "buyer_actor pubkey is required before trade request", - )); - } - if document.buyer_actor.source.trim().is_empty() { - issues.push(issue( - "buyer_actor.source", - "buyer_actor source is required before trade request", - )); - } else if !matches!( - document.buyer_actor.source.as_str(), - ORDER_BUYER_ACTOR_SOURCE_RESOLVED_ACCOUNT | ORDER_BUYER_ACTOR_SOURCE_REBIND - ) { - issues.push(issue( - "buyer_actor.source", - format!( - "unsupported buyer_actor source `{}`", - document.buyer_actor.source - ), - )); - } - if document.order.buyer_pubkey.trim().is_empty() { - issues.push(issue( - "order.buyer_pubkey", - "order buyer_pubkey is required before trade request", - )); - } else if !document - .order - .buyer_pubkey - .eq_ignore_ascii_case(document.buyer_actor.pubkey.as_str()) - { - issues.push(issue( - "order.buyer_pubkey", - "order buyer_pubkey must match buyer_actor pubkey", - )); - } - - issues -} - -fn order_items_match_economics( - items: &[OrderDraftItem], - economics: &RadrootsOrderEconomics, -) -> bool { - let mut order_items = items - .iter() - .map(|item| (item.bin_id.as_str(), item.bin_count)) - .collect::<Vec<_>>(); - let mut economic_items = economics - .items - .iter() - .map(|item| (item.bin_id.as_str(), item.bin_count)) - .collect::<Vec<_>>(); - order_items.sort_unstable(); - economic_items.sort_unstable(); - order_items == economic_items -} - -fn actions_for_document( - document: &OrderDraftDocument, - file: &Path, - issues: &[OrderIssueView], -) -> Vec<String> { - if app_order_issue_present(issues, APP_ORDER_ALREADY_SUBMITTED_ISSUE) { - return vec![format!("radroots trade get {}", document.order.order_id)]; - } - if app_order_issue_present(issues, APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE) { - return vec![ - format!("radroots trade get {}", document.order.order_id), - "radroots trade app list".to_owned(), - ]; - } - - let mut actions = Vec::new(); - actions.push(format!( - "edit {} and fill the remaining draft fields", - file.display() - )); - if document.buyer_actor.account_id.trim().is_empty() - || document.buyer_actor.pubkey.trim().is_empty() - || document.order.buyer_pubkey.trim().is_empty() - || !document - .order - .buyer_pubkey - .eq_ignore_ascii_case(document.buyer_actor.pubkey.as_str()) - { - actions.push(format!( - "radroots trade rebind {} <selector>", - document.order.order_id - )); - } - if issues - .iter() - .any(|issue| issue.code == "account_unresolved") - { - actions.push("radroots account import <path>".to_owned()); - actions.push(format!( - "radroots trade rebind {} <selector>", - document.order.order_id - )); - } - if issues - .iter() - .any(|issue| issue.code == "account_watch_only") - { - actions.push(format!( - "radroots account attach-secret {} <path>", - document.buyer_actor.account_id - )); - actions.push(format!("radroots trade get {}", document.order.order_id)); - } - if issues.iter().any(|issue| issue.code == "account_mismatch") { - actions.push(format!( - "radroots trade rebind {} <selector>", - document.order.order_id - )); - } - if document.order.items.is_empty() - || issues - .iter() - .any(|issue| issue.field.starts_with("order.items[")) - { - actions.push(format!("radroots trade get {}", document.order.order_id)); - } - let mut deduped = Vec::new(); - for action in actions { - if !deduped.contains(&action) { - deduped.push(action); - } - } - deduped -} - -fn app_order_issue_present(issues: &[OrderIssueView], code: &str) -> bool { - issues.iter().any(|issue| issue.code == code) -} - -fn app_order_issue<'a>(issues: &'a [OrderIssueView], code: &str) -> Option<&'a OrderIssueView> { - issues.iter().find(|issue| issue.code == code) -} - -fn order_rebind_selector_error(selector: &str, error: RuntimeError) -> RuntimeError { - match error { - RuntimeError::Accounts(_) | RuntimeError::Account(_) => { - account::AccountRuntimeFailure::unresolved_with_detail( - format!("order rebind target selector `{selector}` did not resolve"), - json!({ - "selector": selector, - "actions": [ - "radroots account list", - "radroots account import <path>", - "radroots account create", - ], - }), - ) - .into() - } - other => other, - } -} - -fn order_rebind_existing_request_check( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, -) -> Result<OrderRebindExistingRequestCheck, RuntimeError> { - if config.transport.nostr_relay_urls.is_empty() { - return Ok(OrderRebindExistingRequestCheck { - state: "skipped_no_relays".to_owned(), - event_ids: Vec::new(), - }); - } - - let filter = order_request_filter( - loaded.document.order.seller_pubkey.as_str(), - Some(loaded.document.order.order_id.as_str()), - )?; - let receipt = fetch_relay_events_via_shared_transport( - &config.transport.nostr_relay_urls, - now_unix_ms(), - 1_000, - filter, - ) - .map_err(order_relay_fetch_error)?; - let mut event_ids = receipt - .events - .iter() - .filter_map(|fetched| { - order_submit_request_from_event(&fetched.event, loaded) - .ok() - .map(|request| request.request_event_id) - }) - .collect::<Vec<_>>(); - event_ids.sort(); - event_ids.dedup(); - - Ok(OrderRebindExistingRequestCheck { - state: if event_ids.is_empty() { - "clear".to_owned() - } else { - "blocked_existing_request".to_owned() - }, - event_ids, - }) -} - -fn resolve_initial_buyer_actor( - config: &RuntimeConfig, -) -> Result<OrderDraftBuyerActor, RuntimeError> { - let resolution = account::resolve_account_resolution(config)?; - let Some(account) = resolution.resolved_account else { - return Err(account::AccountRuntimeFailure::unresolved_with_detail( - account::unresolved_account_reason(config)?, - json!({ - "buyer_actor_source": ORDER_BUYER_ACTOR_SOURCE_RESOLVED_ACCOUNT, - "actions": [ - "radroots account create", - "radroots account import <path>", - ], - }), - ) - .into()); - }; - Ok(OrderDraftBuyerActor { - account_id: account.record.account_id.to_string(), - pubkey: account.record.public_identity.public_key_hex, - source: ORDER_BUYER_ACTOR_SOURCE_RESOLVED_ACCOUNT.to_owned(), - }) -} - -fn buyer_account_id(document: &OrderDraftDocument) -> Option<String> { - non_empty_string(document.buyer_actor.account_id.clone()) -} - -fn buyer_actor_source(document: &OrderDraftDocument) -> Option<String> { - non_empty_string(document.buyer_actor.source.clone()) -} - -fn load_local_order_draft_if_exists( - config: &RuntimeConfig, - lookup: &str, -) -> Result<Option<LoadedOrderDraft>, RuntimeError> { - let file = draft_lookup_path(config, lookup); - if !file.exists() { - return Ok(None); - } - load_draft(file.as_path()) - .map(Some) - .map_err(RuntimeError::Config) -} - -fn order_event_list_actor_context( - config: &RuntimeConfig, - order_id: Option<&str>, -) -> Result<Option<OrderEventListActorContext>, RuntimeError> { - if let Some(order_id) = order_id - && let Some(loaded) = load_local_order_draft_if_exists(config, order_id)? - { - let seller_pubkey = - non_empty_string(loaded.document.order.seller_pubkey).ok_or_else(|| { - RuntimeError::Config(format!( - "local trade draft `{order_id}` is missing seller_pubkey" - )) - })?; - return Ok(Some(OrderEventListActorContext { - source: ORDER_ACTOR_CONTEXT_ORDER_DRAFT, - seller_pubkey, - })); - } - - Ok( - account::resolve_account(config)?.map(|account| OrderEventListActorContext { - source: ORDER_ACTOR_CONTEXT_RESOLVED_ACCOUNT, - seller_pubkey: account.record.public_identity.public_key_hex, - }), - ) -} - -fn bound_buyer_write_context_if_exists( - config: &RuntimeConfig, - order_id: &str, -) -> Result<Option<OrderBoundBuyerWriteContext>, RuntimeError> { - let Some(loaded) = load_local_order_draft_if_exists(config, order_id)? else { - return Ok(None); - }; - let account = validate_bound_order_buyer_account(config, &loaded)?; - Ok(Some(OrderBoundBuyerWriteContext { account })) -} - -fn order_buyer_write_actor_context( - config: &RuntimeConfig, - order_id: &str, -) -> Result<Option<OrderBuyerWriteActorContext>, RuntimeError> { - if let Some(bound) = bound_buyer_write_context_if_exists(config, order_id)? { - return Ok(Some(OrderBuyerWriteActorContext { bound: Some(bound) })); - } - - Ok(account::resolve_account(config)?.map(|_| OrderBuyerWriteActorContext { bound: None })) -} - -fn order_submit_app_signed_evidence_view( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, - args: &TradeRequestArgs, - issues: &[OrderIssueView], -) -> Option<OrderSubmitView> { - if let Some(issue) = app_order_issue(issues, APP_ORDER_ALREADY_SUBMITTED_ISSUE) { - return Some(OrderSubmitView { - state: "submitted".to_owned(), - source: ORDER_SUBMIT_SOURCE.to_owned(), - order_id: loaded.document.order.order_id.clone(), - locator: order_locator_view_from_key(loaded.document.order.order_id.as_str()), - file: loaded.file.display().to_string(), - listing_lookup: loaded.document.listing_lookup.clone(), - listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), - listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()), - listing_relays: order_listing_relays(&loaded.document), - buyer_account_id: buyer_account_id(&loaded.document), - buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), - buyer_actor_source: buyer_actor_source(&loaded.document), - buyer_custody: None, - buyer_write_capable: None, - seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()), - event_id: issue.event_ids.first().cloned(), - event_kind: Some(KIND_ORDER_REQUEST), - dry_run: config.output.dry_run, - deduplicated: true, - target_transport_endpoints: Vec::new(), - attempted_transport_endpoints: Vec::new(), - accepted_transport_endpoints: Vec::new(), - failed_transport_targets: Vec::new(), - idempotency_key: args.idempotency_key.clone(), - signer_mode: None, - reason: Some( - "matching signed order request evidence already exists; publish skipped".to_owned(), - ), - job: None, - issues: vec![issue.clone()], - actions: Vec::new(), - }); - } - - if app_order_issue_present(issues, APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE) { - return Some(OrderSubmitView { - state: "invalid".to_owned(), - source: ORDER_SUBMIT_SOURCE.to_owned(), - order_id: loaded.document.order.order_id.clone(), - locator: order_locator_view_from_key(loaded.document.order.order_id.as_str()), - file: loaded.file.display().to_string(), - listing_lookup: loaded.document.listing_lookup.clone(), - listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), - listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()), - listing_relays: order_listing_relays(&loaded.document), - buyer_account_id: buyer_account_id(&loaded.document), - buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), - buyer_actor_source: buyer_actor_source(&loaded.document), - buyer_custody: None, - buyer_write_capable: None, - seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()), - event_id: None, - event_kind: Some(KIND_ORDER_REQUEST), - dry_run: config.output.dry_run, - deduplicated: false, - target_transport_endpoints: Vec::new(), - attempted_transport_endpoints: Vec::new(), - accepted_transport_endpoints: Vec::new(), - failed_transport_targets: Vec::new(), - idempotency_key: args.idempotency_key.clone(), - signer_mode: None, - reason: Some( - "signed order request evidence conflicts with the app-authored local order" - .to_owned(), - ), - job: None, - issues: issues.to_vec(), - actions: vec![format!( - "radroots trade get {}", - loaded.document.order.order_id - )], - }); - } - - None -} - -fn order_submit_request_from_event( - event: &RadrootsNostrEvent, - loaded: &LoadedOrderDraft, -) -> Result<ResolvedOrderSubmitRequest, RuntimeError> { - let event = radroots_event_from_nostr(event); - let envelope = order_request_from_event(&event) - .map_err(|error| RuntimeError::Config(format!("decode order request event: {error}")))?; - let event_tags = event.tags_as_vec(); - let context = - order_event_context_from_tags(RadrootsOrderEventType::OrderRequested, &event_tags) - .map_err(|error| RuntimeError::Config(format!("decode order request tags: {error}")))?; - - if envelope.order_id != loaded.document.order.order_id - || envelope.payload.order_id != loaded.document.order.order_id - { - return Err(RuntimeError::Config( - "order request does not match local order id".to_owned(), - )); - } - if context.counterparty_pubkey != envelope.payload.seller_pubkey { - return Err(RuntimeError::Config( - "order request p tag does not match seller_pubkey".to_owned(), - )); - } - let listing_addr = - parse_listing_addr(envelope.payload.listing_addr.as_str()).map_err(|error| { - RuntimeError::Config(format!("order request listing_addr is invalid: {error}")) - })?; - if listing_addr.seller_pubkey != envelope.payload.seller_pubkey { - return Err(RuntimeError::Config( - "order request listing address is outside seller authority".to_owned(), - )); - } - let payload = canonicalize_order_request_for_signer(envelope.payload, event.author_str()) - .map_err(|error| RuntimeError::Config(format!("canonicalize order request: {error}")))?; - let listing_event_id = context.listing_event.as_ref().map(|event| event.id.clone()); - - Ok(ResolvedOrderSubmitRequest { - request_event_id: event.id_str().to_owned(), - listing_event_id, - payload, - }) -} - -fn order_submit_request_matches_draft( - request: &ResolvedOrderSubmitRequest, - loaded: &LoadedOrderDraft, - payload: &RadrootsOrderRequest, -) -> bool { - request.payload == *payload - && request.listing_event_id.as_deref() - == Some(loaded.document.order.listing_event_id.as_str()) -} - -fn order_submit_dry_run_view( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, - args: &TradeRequestArgs, - plan: TradeSubmitPlan, - target_transport_endpoints: Vec<String>, -) -> OrderSubmitView { - OrderSubmitView { - state: "dry_run".to_owned(), - source: ORDER_SUBMIT_SOURCE.to_owned(), - order_id: loaded.document.order.order_id.clone(), - locator: OrderTradeLocatorView { - trade_id: loaded.document.order.order_id.clone(), - root_event_id: Some(plan.expected_event_id.to_string()), - listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), - buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), - seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()), - }, - file: loaded.file.display().to_string(), - listing_lookup: loaded.document.listing_lookup.clone(), - listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), - listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()), - listing_relays: order_listing_relays(&loaded.document), - buyer_account_id: buyer_account_id(&loaded.document), - buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), - buyer_actor_source: buyer_actor_source(&loaded.document), - buyer_custody: None, - buyer_write_capable: None, - seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()), - event_id: Some(plan.expected_event_id.as_str().to_owned()), - event_kind: Some(KIND_ORDER_REQUEST), - dry_run: true, - deduplicated: false, - target_transport_endpoints, - attempted_transport_endpoints: Vec::new(), - accepted_transport_endpoints: Vec::new(), - failed_transport_targets: Vec::new(), - idempotency_key: args.idempotency_key.clone(), - signer_mode: Some(config.signer.backend.as_str().to_owned()), - reason: Some("dry run requested; SDK enqueue and transport push skipped".to_owned()), - job: None, - issues: Vec::new(), - actions: vec![format!( - "radroots trade request {}", - loaded.document.order.order_id - )], - } -} - -fn canonical_order_request_payload_from_loaded( - loaded: &LoadedOrderDraft, - signer_pubkey: &str, -) -> Result<RadrootsOrderRequest, RuntimeError> { - let economics = - loaded.document.order.economics.clone().ok_or_else(|| { - RuntimeError::Config("trade draft is missing quote economics".to_owned()) - })?; - let items = loaded - .document - .order - .items - .iter() - .map(|item| { - Ok(RadrootsOrderItem { - bin_id: protocol_inventory_bin_id(item.bin_id.as_str(), "order item bin_id")?, - bin_count: item.bin_count, - }) - }) - .collect::<Result<Vec<_>, RuntimeError>>()?; - let payload = RadrootsOrderRequest { - order_id: protocol_order_id(loaded.document.order.order_id.as_str(), "order_id")?, - listing_addr: protocol_listing_addr( - loaded.document.order.listing_addr.as_str(), - "listing_addr", - )?, - buyer_pubkey: protocol_pubkey(loaded.document.order.buyer_pubkey.as_str(), "buyer_pubkey")?, - seller_pubkey: protocol_pubkey( - loaded.document.order.seller_pubkey.as_str(), - "seller_pubkey", - )?, - items, - economics, - }; - canonicalize_order_request_for_signer(payload, signer_pubkey) - .map_err(|error| RuntimeError::Config(format!("canonicalize order request: {error}"))) -} - -fn order_submit_listing_event_ptr( - loaded: &LoadedOrderDraft, -) -> Result<RadrootsEventPtr, RuntimeError> { - let listing_relays = - normalize_listing_relay_set(loaded.document.order.listing_relays.iter()) - .map_err(|error| RuntimeError::Config(format!("listing provenance relays: {error}")))?; - Ok(RadrootsEventPtr { - id: loaded.document.order.listing_event_id.clone(), - relays: listing_relays.first().cloned(), - }) -} - -fn propose_trade_via_sdk( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, - args: &TradeRequestArgs, - account: &account::AccountRecordView, -) -> Result<OrderSubmitView, CliSdkAdapterError> { - let actor = sdk_trade_actor(account, RadrootsActorRole::Buyer, "propose")?; - let publish_mode = trade_publish_mode(config); - let ack_policy = trade_satisfaction_policy(publish_mode)?; - let order_request = canonical_order_request_payload_from_loaded( - loaded, - account.record.public_identity.public_key_hex.as_str(), - )?; - let mut request = TradeProposeRequest::new( - actor, - order_submit_listing_event_ptr(loaded)?, - order_request, - trade_target_policy(), - publish_mode, - ack_policy, - ) - .with_privacy_confirmation(trade_privacy_confirmation(args.confirm_public_note)); - if let Some(idempotency_key) = args.idempotency_key.as_deref() { - request = request.try_with_idempotency_key(idempotency_key)?; - } - - let session = connect_sdk_for_trade_actor(config, account, "trade propose")?; - let outcome = session.block_on(session.sdk().trades().buyer().propose_trade(request))?; - Ok(sdk_trade_submit_outcome_view(config, loaded, args, outcome)) -} - -fn sdk_trade_submit_outcome_view( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, - args: &TradeRequestArgs, - outcome: TradeMutationOutcome<TradeSubmitPlan, TradeSubmitReceipt>, -) -> OrderSubmitView { - match outcome { - TradeMutationOutcome::DryRun { plan } => order_submit_dry_run_view( - config, - loaded, - args, - plan, - config.transport.nostr_relay_urls.clone(), - ), - TradeMutationOutcome::Enqueued { receipt } => { - sdk_enqueued_order_submit_view(config, loaded, args, receipt, None) - } - TradeMutationOutcome::Published { receipt, publish } => { - sdk_enqueued_order_submit_view(config, loaded, args, receipt, Some(&publish)) - } - } -} - -fn sdk_enqueued_order_submit_view( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, - args: &TradeRequestArgs, - enqueue: TradeSubmitReceipt, - push: Option<&PushOutboxReceipt>, -) -> OrderSubmitView { - let push_event = push.and_then(|push| sdk_push_event_for_order_submit(&enqueue, push)); - OrderSubmitView { - state: sdk_order_submit_state(push_event), - source: ORDER_SUBMIT_SOURCE.to_owned(), - order_id: loaded.document.order.order_id.clone(), - locator: order_locator_view_from_locator(&enqueue.locator), - file: loaded.file.display().to_string(), - listing_lookup: loaded.document.listing_lookup.clone(), - listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), - listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()), - listing_relays: order_listing_relays(&loaded.document), - buyer_account_id: buyer_account_id(&loaded.document), - buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), - buyer_actor_source: buyer_actor_source(&loaded.document), - buyer_custody: None, - buyer_write_capable: None, - seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()), - event_id: Some(enqueue.signed_event_id.as_str().to_owned()), - event_kind: Some(KIND_ORDER_REQUEST), - dry_run: false, - deduplicated: matches!(enqueue.state, SdkMutationState::AlreadyQueued), - target_transport_endpoints: push_event - .map(sdk_push_target_transport_endpoints) - .unwrap_or_else(|| config.transport.nostr_relay_urls.clone()), - attempted_transport_endpoints: push_event - .map(sdk_push_attempted_transport_endpoints) - .unwrap_or_default(), - accepted_transport_endpoints: push_event - .map(sdk_push_accepted_transport_endpoints) - .unwrap_or_default(), - failed_transport_targets: push_event - .map(sdk_push_failed_transport_targets) - .unwrap_or_default(), - idempotency_key: args.idempotency_key.clone(), - signer_mode: Some(config.signer.backend.as_str().to_owned()), - reason: sdk_order_submit_reason(&enqueue.workflow, push_event), - job: None, - issues: Vec::new(), - actions: sdk_order_submit_actions(push_event), - } -} - -fn sdk_push_event_for_order_submit<'a>( - enqueue: &TradeSubmitReceipt, - push: &'a PushOutboxReceipt, -) -> Option<&'a PushOutboxEventReceipt> { - push.events - .iter() - .find(|event| event.event_id == enqueue.signed_event_id) -} - -fn sdk_order_submit_state(push_event: Option<&PushOutboxEventReceipt>) -> String { - match push_event.map(|event| event.final_state) { - Some(PushOutboxEventState::Published) => "submitted", - Some(PushOutboxEventState::PublishRetryable | PushOutboxEventState::FailedTerminal) => { - "unavailable" - } - Some(_) | None => "queued", - } - .to_owned() -} - -fn sdk_order_submit_reason( - enqueue: &TradeWorkflowEnqueueReceipt, - push_event: Option<&PushOutboxEventReceipt>, -) -> Option<String> { - match push_event.map(|event| event.final_state) { - Some(PushOutboxEventState::Published) => None, - Some(PushOutboxEventState::PublishRetryable) => Some(format!( - "{}; SDK transport publish did not reach accepted quorum; outbox event remains retryable; {}", - sdk_order_enqueue_summary(enqueue), - sdk_order_enqueue_retry_summary(enqueue) - )), - Some(PushOutboxEventState::FailedTerminal) => Some(format!( - "{}; SDK transport publish failed terminally; {}", - sdk_order_enqueue_summary(enqueue), - sdk_order_enqueue_retry_summary(enqueue) - )), - Some(state) => Some(format!( - "{}; SDK transport push left event in state `{state:?}`; {}", - sdk_order_enqueue_summary(enqueue), - sdk_order_enqueue_retry_summary(enqueue) - )), - None => Some(format!( - "{}; trade request queued in SDK outbox; no ready SDK outbox event was pushed; {}", - sdk_order_enqueue_summary(enqueue), - sdk_order_enqueue_retry_summary(enqueue) - )), - } -} - -fn sdk_order_submit_actions(push_event: Option<&PushOutboxEventReceipt>) -> Vec<String> { - if !matches!( - push_event.map(|event| event.final_state), - Some(PushOutboxEventState::Published) - ) { - return sdk_order_push_recovery_actions(); - } - Vec::new() -} - -fn sdk_push_target_transport_endpoints(event: &PushOutboxEventReceipt) -> Vec<String> { - event - .targets - .iter() - .map(|target| target.endpoint_uri.clone()) - .collect() -} - -fn sdk_push_attempted_transport_endpoints(event: &PushOutboxEventReceipt) -> Vec<String> { - event - .targets - .iter() - .filter(|target| target.attempted) - .map(|target| target.endpoint_uri.clone()) - .collect() -} - -fn sdk_push_accepted_transport_endpoints(event: &PushOutboxEventReceipt) -> Vec<String> { - event - .targets - .iter() - .filter(|target| { - matches!( - target.outcome_kind, - PushOutboxTargetOutcomeKind::Accepted - | PushOutboxTargetOutcomeKind::DuplicateAccepted - ) - }) - .map(|target| target.endpoint_uri.clone()) - .collect() -} - -fn sdk_push_failed_transport_targets( - event: &PushOutboxEventReceipt, -) -> Vec<TransportTargetFailureView> { - event - .targets - .iter() - .filter(|target| { - !matches!( - target.outcome_kind, - PushOutboxTargetOutcomeKind::Accepted - | PushOutboxTargetOutcomeKind::DuplicateAccepted - ) - }) - .map(|target| TransportTargetFailureView { - transport_kind: target.transport_kind.clone(), - endpoint_uri: target.endpoint_uri.clone(), - target_scope: target.target_scope.clone(), - target_label: target.target_label.clone(), - transport_outcome_kind: target - .transport_outcome_kind - .map(sdk_transport_outcome_kind_label), - reason: target - .message - .clone() - .unwrap_or_else(|| sdk_target_outcome_kind_label(target.outcome_kind)), - }) - .collect() -} - -fn validate_bound_order_buyer_account( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, -) -> Result<account::AccountRecordView, RuntimeError> { - let document = &loaded.document; - let account_id = document.buyer_actor.account_id.trim(); - let buyer_pubkey = document.buyer_actor.pubkey.trim(); - let snapshot = account::snapshot(config)?; - let Some(account) = snapshot - .accounts - .iter() - .find(|account| account.record.account_id.as_str() == account_id) - .cloned() - else { - return Err(account::AccountRuntimeFailure::unresolved_with_detail( - format!( - "order-bound buyer account `{account_id}` is not present in the local account store" - ), - order_buyer_failure_detail( - loaded, - json!({ - "actions": [ - "radroots account import <path>", - format!("radroots trade rebind {} <selector>", document.order.order_id), - format!("radroots trade get {}", document.order.order_id), - ], - }), - ), - ) - .into()); - }; - - let account_pubkey = account.record.public_identity.public_key_hex.as_str(); - if !account_pubkey.eq_ignore_ascii_case(buyer_pubkey) - || !document - .order - .buyer_pubkey - .eq_ignore_ascii_case(buyer_pubkey) - { - return Err(account::AccountRuntimeFailure::mismatch_with_detail( - format!( - "order-bound buyer account `{account_id}` does not match order buyer pubkey `{buyer_pubkey}`" - ), - order_buyer_failure_detail( - loaded, - json!({ - "attempted_buyer_account_id": account_id, - "attempted_buyer_pubkey": account_pubkey, - "actions": [ - format!("radroots trade rebind {} <selector>", document.order.order_id), - format!("radroots trade get {}", document.order.order_id), - ], - }), - ), - ) - .into()); - } - - if !account.write_capable { - return Err(account::AccountRuntimeFailure::watch_only_with_detail( - account_id, - order_buyer_failure_detail( - loaded, - json!({ - "actions": [ - format!("radroots account attach-secret {account_id} <path>"), - format!("radroots trade get {}", document.order.order_id), - ], - }), - ), - ) - .into()); - } - - if let Some(selector) = config.account.selector.as_deref() { - let attempted = account::resolve_account_selector(config, selector).map_err(|_| { - account::AccountRuntimeFailure::unresolved_with_detail( - format!("account override `{selector}` did not resolve to a local buyer account"), - order_buyer_failure_detail( - loaded, - json!({ - "attempted_buyer_account_id": selector, - "actions": [ - "radroots account list", - format!("radroots trade get {}", document.order.order_id), - ], - }), - ), - ) - })?; - if attempted.record.account_id.as_str() != account_id { - let attempted_pubkey = attempted.record.public_identity.public_key_hex.as_str(); - return Err(account::AccountRuntimeFailure::mismatch_with_detail( - format!( - "account override `{}` cannot retarget order `{}` bound to buyer account `{account_id}`", - attempted.record.account_id, document.order.order_id - ), - order_buyer_failure_detail( - loaded, - json!({ - "attempted_buyer_account_id": attempted.record.account_id.to_string(), - "attempted_buyer_pubkey": attempted_pubkey, - "actions": [ - format!("radroots --account-id {account_id} trade request {}", document.order.order_id), - format!("radroots trade rebind {} <selector>", document.order.order_id), - format!("radroots trade get {}", document.order.order_id), - ], - }), - ), - ) - .into()); - } - } - - Ok(account) -} - -fn order_buyer_failure_detail( - loaded: &LoadedOrderDraft, - mut extra: serde_json::Value, -) -> serde_json::Value { - let mut detail = json!({ - "buyer_actor_source": loaded.document.buyer_actor.source.as_str(), - "order_buyer_account_id": loaded.document.buyer_actor.account_id.as_str(), - "order_buyer_pubkey": loaded.document.buyer_actor.pubkey.as_str(), - "order_file": loaded.file.display().to_string(), - "trade_id": loaded.document.order.order_id.as_str(), - }); - if let (Some(detail), Some(extra)) = (detail.as_object_mut(), extra.as_object_mut()) { - for (key, value) in std::mem::take(extra) { - detail.insert(key, value); - } - } - detail -} - -fn order_relay_fetch_error(error: RadrootsRelayTransportError) -> RuntimeError { - RuntimeError::Network(error.to_string()) -} - -fn relay_fetch_failure_reason(failed_transport_targets: &[RadrootsRelayFetchFailure]) -> String { - if failed_transport_targets.is_empty() { - return "no relay acknowledged the fetch".to_owned(); - } - failed_transport_targets - .iter() - .map(|failure| format!("{}: {}", failure.relay_url, failure.reason)) - .collect::<Vec<_>>() - .join("; ") -} - -fn relay_failures(failures: Vec<RadrootsRelayFetchFailure>) -> Vec<TransportTargetFailureView> { - failures - .into_iter() - .map(|failure| TransportTargetFailureView { - transport_kind: "nostr".to_owned(), - endpoint_uri: failure.relay_url, - target_scope: None, - target_label: None, - transport_outcome_kind: None, - reason: failure.reason, - }) - .collect() -} - -fn load_draft(path: &Path) -> Result<LoadedOrderDraft, String> { - let contents = fs::read_to_string(path) - .map_err(|error| format!("read trade draft {}: {error}", path.display()))?; - let document = toml::from_str::<OrderDraftDocument>(contents.as_str()) - .map_err(|error| format!("parse trade draft {}: {error}", path.display()))?; - Ok(LoadedOrderDraft { - file: path.to_path_buf(), - updated_at_unix: modified_unix(path).unwrap_or_default(), - document, - }) -} - -fn save_draft(path: &Path, draft: &OrderDraftDocument) -> Result<(), RuntimeError> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(path, scaffold_contents(draft)?)?; - Ok(()) -} - -fn scaffold_contents(draft: &OrderDraftDocument) -> Result<String, RuntimeError> { - let toml = toml::to_string_pretty(draft) - .map_err(|error| RuntimeError::Config(format!("render trade draft: {error}")))?; - Ok(format!( - "# radroots trade draft v1\n# fill listing_addr and any missing order items before submit\n\n{toml}" - )) -} - -fn drafts_dir(config: &RuntimeConfig) -> PathBuf { - config.paths.app_data_root.join(ORDERS_DIR) -} - -fn draft_lookup_path(config: &RuntimeConfig, lookup: &str) -> PathBuf { - let candidate = PathBuf::from(lookup); - if candidate.is_absolute() || lookup.contains(std::path::MAIN_SEPARATOR) { - return candidate; - } - let file_name = if lookup.ends_with(".toml") { - lookup.to_owned() - } else { - format!("{lookup}.toml") - }; - drafts_dir(config).join(file_name) -} - -#[derive(Debug, Clone)] -struct ParsedListingAddress { - kind: u32, - seller_pubkey: String, - listing_id: String, -} - -fn parse_listing_addr(raw: &str) -> Result<ParsedListingAddress, String> { - let parsed = RadrootsListingAddress::parse(raw).map_err(|error| error.to_string())?; - let (kind, rest) = parsed - .as_str() - .split_once(':') - .ok_or_else(|| "listing address has invalid format".to_owned())?; - let (seller_pubkey, listing_id) = rest - .split_once(':') - .ok_or_else(|| "listing address has invalid format".to_owned())?; - let kind = kind - .parse::<u32>() - .map_err(|_| "listing address kind is invalid".to_owned())?; - Ok(ParsedListingAddress { - kind, - seller_pubkey: seller_pubkey.to_owned(), - listing_id: listing_id.to_owned(), - }) -} - -fn issue(field: impl Into<String>, message: impl Into<String>) -> OrderIssueView { - let field = field.into(); - issue_with_code(validation_issue_code(&field), field, message) -} - -fn issue_with_code( - code: impl Into<String>, - field: impl Into<String>, - message: impl Into<String>, -) -> OrderIssueView { - OrderIssueView { - code: code.into(), - field: field.into(), - message: message.into(), - event_ids: Vec::new(), - } -} - -fn issue_with_events( - code: impl Into<String>, - field: impl Into<String>, - message: impl Into<String>, - event_ids: Vec<impl ToString>, -) -> OrderIssueView { - let mut event_ids = event_ids - .into_iter() - .map(|event_id| event_id.to_string()) - .collect::<Vec<_>>(); - event_ids.sort(); - event_ids.dedup(); - OrderIssueView { - code: code.into(), - field: field.into(), - message: message.into(), - event_ids, - } -} - -fn validation_issue_code(field: &str) -> String { - let mut code = String::new(); - let mut previous_separator = false; - for character in field.chars() { - if character.is_ascii_alphanumeric() { - code.push(character.to_ascii_lowercase()); - previous_separator = false; - } else if !previous_separator { - code.push('_'); - previous_separator = true; - } - } - let code = code.trim_matches('_'); - if code.is_empty() { - "validation_failed".to_owned() - } else { - format!("{code}_invalid") - } -} - -fn normalize_optional(value: Option<&str>) -> Option<String> { - let value = value?; - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_owned()) - } -} - -fn normalize_listing_relay_set<I, S>(values: I) -> Result<Vec<String>, String> -where - I: IntoIterator<Item = S>, - S: AsRef<str>, -{ - let mut normalized = Vec::new(); - for value in values { - let relay = RadrootsRelayUrl::parse(value.as_ref(), RadrootsRelayUrlPolicy::Public) - .map_err(|error| error.to_string())? - .into_string(); - if !normalized.contains(&relay) { - normalized.push(relay); - } - } - Ok(normalized) -} - -fn order_listing_relays(document: &OrderDraftDocument) -> Vec<String> { - normalize_listing_relay_set(document.order.listing_relays.iter()) - .unwrap_or_else(|_| document.order.listing_relays.clone()) -} - -fn non_empty_string(value: String) -> Option<String> { - if value.trim().is_empty() { - None - } else { - Some(value) - } -} - -fn non_empty_ref(value: &str) -> Option<&str> { - if value.trim().is_empty() { - None - } else { - Some(value) - } -} - -fn modified_unix(path: &Path) -> Option<u64> { - let modified = fs::metadata(path).ok()?.modified().ok()?; - modified - .duration_since(UNIX_EPOCH) - .ok() - .map(|value| value.as_secs()) -} - -fn now_unix() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|value| value.as_secs()) - .unwrap_or_default() -} - -fn now_unix_ms() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|value| i64::try_from(value.as_millis()).unwrap_or(i64::MAX)) - .unwrap_or_default() -} - -fn next_order_id() -> String { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or_default(); - let counter = ORDER_COUNTER.fetch_add(1, Ordering::Relaxed) as u128; - format!( - "ord_{}", - encode_base64url_no_pad((nanos ^ counter).to_be_bytes()) - ) -} - -fn is_valid_order_id(value: &str) -> bool { - if let Some(encoded) = value.strip_prefix("ord_") { - return encoded.len() == 22 && is_d_tag_base64url(encoded); - } - is_canonical_uuid(value) -} - -fn is_canonical_uuid(value: &str) -> bool { - if value.len() != 36 { - return false; - } - for (index, character) in value.chars().enumerate() { - if matches!(index, 8 | 13 | 18 | 23) { - if character != '-' { - return false; - } - } else if !character.is_ascii_hexdigit() { - return false; - } - } - true -} - -fn is_valid_event_id(value: &str) -> bool { - value.len() == 64 && value.chars().all(|ch| ch.is_ascii_hexdigit()) -} - -fn encode_base64url_no_pad(bytes: [u8; 16]) -> String { - const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - let mut output = String::with_capacity(22); - let mut index = 0usize; - while index + 3 <= bytes.len() { - let block = ((bytes[index] as u32) << 16) - | ((bytes[index + 1] as u32) << 8) - | (bytes[index + 2] as u32); - output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); - output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); - output.push(ALPHABET[((block >> 6) & 0x3f) as usize] as char); - output.push(ALPHABET[(block & 0x3f) as usize] as char); - index += 3; - } - let remaining = bytes.len() - index; - if remaining == 1 { - let block = (bytes[index] as u32) << 16; - output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); - output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); - } else if remaining == 2 { - let block = ((bytes[index] as u32) << 16) | ((bytes[index + 1] as u32) << 8); - output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); - output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); - output.push(ALPHABET[((block >> 6) & 0x3f) as usize] as char); - } - output -} - -#[derive(Debug, Clone)] -struct OrderInspection { - state: String, - ready_for_submit: bool, - listing_addr: Option<String>, - listing_event_id: Option<String>, - seller_pubkey: Option<String>, - buyer_custody: Option<String>, - buyer_write_capable: Option<bool>, - issues: Vec<OrderIssueView>, -} - -impl From<OrderGetView> for OrderNewView { - fn from(view: OrderGetView) -> Self { - Self { - state: "draft_created".to_owned(), - source: view.source, - order_id: view.order_id.unwrap_or_default(), - file: view.file.unwrap_or_default(), - listing_lookup: view.listing_lookup, - listing_addr: view.listing_addr, - listing_event_id: view.listing_event_id, - listing_relays: view.listing_relays, - buyer_account_id: view.buyer_account_id, - buyer_pubkey: view.buyer_pubkey, - buyer_actor_source: view.buyer_actor_source, - buyer_custody: view.buyer_custody, - buyer_write_capable: view.buyer_write_capable, - seller_pubkey: view.seller_pubkey, - ready_for_submit: view.ready_for_submit, - items: view.items, - economics: view.economics, - issues: view.issues, - actions: view.actions, - } - } -} diff --git a/src/runtime/order/sdk_status.rs b/src/runtime/order/sdk_status.rs @@ -1,327 +0,0 @@ -use radroots_event::ids::RadrootsEventId; -#[cfg(test)] -use radroots_event::ids::RadrootsOrderId; -use radroots_sdk::{ - SdkTradeStatusIssue, TradeStatusAmbiguityCandidate, TradeStatusEligibility, - TradeStatusEvidenceSummary, TradeStatusKind, TradeStatusNextActionKind, TradeStatusReceipt, - TradeStatusRequest, TradeValidationTrustDecision, -}; -use radroots_trade::identity::RadrootsTradeLocator; - -use crate::view::runtime::{ - OrderIssueView, OrderStatusAmbiguityCandidateView, OrderStatusEligibilityView, - OrderStatusEvidenceSummaryView, OrderStatusLifecycleCancellationView, OrderStatusLifecycleView, - OrderStatusSdkReceiptView, OrderStatusValidationTrustView, OrderStatusView, - OrderTradeLocatorView, -}; - -use super::{ORDER_ACTOR_CONTEXT_SDK_LOCAL, ORDER_STATUS_SDK_SOURCE}; - -pub(super) fn sdk_order_status_view(receipt: TradeStatusReceipt) -> OrderStatusView { - let state = sdk_order_status_state(receipt.status).to_owned(); - let reducer_issues = receipt - .issues - .iter() - .map(sdk_order_status_issue_view) - .collect::<Vec<_>>(); - let reason = sdk_order_status_reason(receipt.status, receipt.order_id.as_str()); - let lifecycle = sdk_order_status_lifecycle_view(&receipt, reducer_issues.as_slice()); - let sdk_receipt = Some(sdk_order_status_receipt_view(&receipt)); - let ambiguity_candidates = receipt - .ambiguity_candidates - .iter() - .map(sdk_status_ambiguity_candidate_view) - .collect::<Vec<_>>(); - let actions = ambiguity_candidates - .iter() - .map(|candidate| candidate.status_command.clone()) - .collect::<Vec<_>>(); - - OrderStatusView { - state, - source: ORDER_STATUS_SDK_SOURCE.to_owned(), - order_id: receipt.order_id.to_string(), - locator: sdk_trade_locator_view(&receipt.locator), - actor_context_source: ORDER_ACTOR_CONTEXT_SDK_LOCAL.to_owned(), - request_event_id: sdk_event_id_string(receipt.request_event_id.as_ref()), - decision_event_id: sdk_event_id_string(receipt.decision_event_id.as_ref()), - agreement_event_id: sdk_order_status_agreement_event_id(&receipt), - listing_event_id: None, - listing_addr: receipt.listing_addr.as_ref().map(ToString::to_string), - buyer_pubkey: receipt.buyer_pubkey.as_ref().map(ToString::to_string), - seller_pubkey: receipt.seller_pubkey.as_ref().map(ToString::to_string), - economics: receipt.economics.clone(), - last_event_id: sdk_event_id_string(receipt.last_event_id.as_ref()), - inventory: None, - lifecycle: Some(lifecycle), - sdk_receipt, - ambiguity_candidates, - reducer_issues, - target_transport_endpoints: Vec::new(), - attempted_transport_endpoints: Vec::new(), - failed_transport_targets: Vec::new(), - fetched_count: 0, - decoded_count: receipt.event_count, - skipped_count: 0, - reason, - actions, - } -} - -fn sdk_trade_locator_view(locator: &RadrootsTradeLocator) -> OrderTradeLocatorView { - OrderTradeLocatorView { - trade_id: locator.trade_id.as_str().to_owned(), - root_event_id: locator.root_event_id.as_ref().map(ToString::to_string), - listing_addr: locator.listing_addr.as_ref().map(ToString::to_string), - buyer_pubkey: locator.buyer_pubkey.as_ref().map(ToString::to_string), - seller_pubkey: locator.seller_pubkey.as_ref().map(ToString::to_string), - } -} - -fn sdk_status_ambiguity_candidate_view( - candidate: &TradeStatusAmbiguityCandidate, -) -> OrderStatusAmbiguityCandidateView { - let status_selector = TradeStatusRequest::locator_selector(&candidate.locator); - OrderStatusAmbiguityCandidateView { - locator: sdk_trade_locator_view(&candidate.locator), - status_command: format!("radroots trade get {status_selector}"), - status_selector, - } -} - -fn sdk_order_status_receipt_view(receipt: &TradeStatusReceipt) -> OrderStatusSdkReceiptView { - OrderStatusSdkReceiptView { - next_action: sdk_status_next_action(receipt.next_action).to_owned(), - evidence: sdk_status_evidence_view(&receipt.evidence), - eligibility: sdk_status_eligibility_view(&receipt.eligibility), - validation_trust: receipt - .validation_trust - .as_ref() - .map(sdk_status_validation_trust_view), - } -} - -fn sdk_status_evidence_view( - evidence: &TradeStatusEvidenceSummary, -) -> OrderStatusEvidenceSummaryView { - OrderStatusEvidenceSummaryView { - event_count: evidence.event_count, - limit_applied: evidence.limit_applied, - has_request: evidence.has_request, - has_decision: evidence.has_decision, - has_agreement: evidence.has_agreement, - has_validation_receipt: evidence.has_validation_receipt, - has_cancellation: evidence.has_cancellation, - has_issues: evidence.has_issues, - } -} - -fn sdk_status_validation_trust_view( - decision: &TradeValidationTrustDecision, -) -> OrderStatusValidationTrustView { - OrderStatusValidationTrustView { - state: decision.state.as_str().to_owned(), - validator_count: decision.validator_count, - validator_set_addr: decision.validator_set_addr.clone(), - validator_set_event_id: decision.validator_set_event_id.clone(), - require_cryptographic_proof: decision.require_cryptographic_proof, - receipt_event_id: decision.receipt_event_id.as_ref().map(ToString::to_string), - receipt_author: decision.receipt_author.as_ref().map(ToString::to_string), - result_event_id: decision.result_event_id.as_ref().map(ToString::to_string), - result_author: decision.result_author.as_ref().map(ToString::to_string), - proof_system: decision.proof_system.clone(), - validation_authority: decision - .validation_authority - .map(|authority| authority.as_str().to_owned()), - commitment_confidence: decision - .commitment_confidence - .map(|confidence| confidence.as_str().to_owned()), - cryptographic_proof_required: decision.cryptographic_proof_required, - cryptographic_proof_verified: decision.cryptographic_proof_verified, - production_committed: decision.production_committed, - reason_code: decision.reason_code.clone(), - reason: decision.reason.clone(), - } -} - -fn sdk_status_eligibility_view(eligibility: &TradeStatusEligibility) -> OrderStatusEligibilityView { - OrderStatusEligibilityView { - can_decide: eligibility.can_decide, - can_cancel: eligibility.can_cancel, - } -} - -fn sdk_status_next_action(kind: TradeStatusNextActionKind) -> &'static str { - match kind { - TradeStatusNextActionKind::NoLocalOrder => "no_local_order", - TradeStatusNextActionKind::InspectEvidenceIssues => "inspect_evidence_issues", - TradeStatusNextActionKind::AwaitSellerDecision => "await_seller_decision", - TradeStatusNextActionKind::AwaitValidation => "await_validation", - TradeStatusNextActionKind::Terminal => "terminal", - _ => "unknown", - } -} - -fn sdk_order_status_state(status: TradeStatusKind) -> &'static str { - match status { - TradeStatusKind::Missing => "missing", - TradeStatusKind::Ambiguous => "ambiguous", - TradeStatusKind::Requested => "requested", - TradeStatusKind::AgreedPendingValidation => "pending_validation", - TradeStatusKind::Committed => "committed", - TradeStatusKind::Declined => "declined", - TradeStatusKind::Cancelled => "cancelled", - TradeStatusKind::ValidationExpired => "validation_expired", - TradeStatusKind::Invalid => "invalid", - _ => "unknown", - } -} - -fn sdk_order_status_reason(status: TradeStatusKind, order_id: &str) -> Option<String> { - match status { - TradeStatusKind::Missing => Some(format!("no local SDK trade events matched `{order_id}`")), - TradeStatusKind::Ambiguous => Some(format!( - "local SDK trade events for `{order_id}` matched multiple roots" - )), - TradeStatusKind::Invalid => Some(format!( - "local SDK trade events for `{order_id}` failed reducer validation" - )), - _ => None, - } -} - -fn sdk_order_status_agreement_event_id(receipt: &TradeStatusReceipt) -> Option<String> { - sdk_event_id_string(receipt.agreement_event_id.as_ref()) -} - -fn sdk_order_status_lifecycle_view( - receipt: &TradeStatusReceipt, - issues: &[OrderIssueView], -) -> OrderStatusLifecycleView { - let cancellation = receipt.cancellation_event_id.as_ref().map(|event_id| { - OrderStatusLifecycleCancellationView { - event_id: event_id.to_string(), - root_event_id: sdk_event_id_string(receipt.request_event_id.as_ref()), - prev_event_id: sdk_event_id_string(receipt.decision_event_id.as_ref()), - reason: None, - } - }); - OrderStatusLifecycleView { - phase: sdk_order_status_lifecycle_phase(receipt).to_owned(), - terminal: receipt.lifecycle_terminal, - event_id: sdk_event_id_string(receipt.last_event_id.as_ref()), - root_event_id: sdk_event_id_string(receipt.request_event_id.as_ref()), - prev_event_id: None, - cancellation, - issues: issues.to_vec(), - } -} - -fn sdk_order_status_lifecycle_phase(receipt: &TradeStatusReceipt) -> &'static str { - match receipt.status { - TradeStatusKind::Missing => "missing", - TradeStatusKind::Ambiguous => "ambiguous", - TradeStatusKind::Requested => "requested", - TradeStatusKind::AgreedPendingValidation => "pending_validation", - TradeStatusKind::Committed => "committed", - TradeStatusKind::Declined => "declined", - TradeStatusKind::Cancelled => "cancelled", - TradeStatusKind::ValidationExpired => "validation_expired", - TradeStatusKind::Invalid => "invalid", - _ => "unknown", - } -} - -fn sdk_order_status_issue_view(issue: &SdkTradeStatusIssue) -> OrderIssueView { - let code = issue.code(); - OrderIssueView { - code: code.clone(), - field: "sdk_order_status".to_owned(), - message: format!("SDK trade status reported `{code}`"), - event_ids: issue - .event_ids - .iter() - .map(RadrootsEventId::to_string) - .collect(), - } -} - -fn sdk_event_id_string(event_id: Option<&RadrootsEventId>) -> Option<String> { - event_id.map(RadrootsEventId::to_string) -} - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - #[test] - fn sdk_order_status_view_exposes_ambiguity_candidates() { - let order_id = RadrootsOrderId::parse("order-1").expect("order id"); - let root_event_id = RadrootsEventId::parse( - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ) - .expect("root event id"); - let receipt = TradeStatusReceipt { - locator: RadrootsTradeLocator::from_order_id(order_id.clone()), - order_id, - root_event_id: None, - ambiguity_candidates: vec![TradeStatusAmbiguityCandidate { - locator: RadrootsTradeLocator::from_order_id( - RadrootsOrderId::parse("order-1").expect("candidate order id"), - ) - .with_root_event_id(root_event_id.clone()), - }], - source: radroots_sdk::SdkTradeStatusSource::LocalOnly, - found: false, - event_count: 2, - limit_applied: 500, - status: TradeStatusKind::Ambiguous, - lifecycle_terminal: false, - listing_addr: None, - buyer_pubkey: None, - seller_pubkey: None, - economics: None, - evidence: TradeStatusEvidenceSummary { - event_count: 2, - limit_applied: 500, - has_request: true, - has_decision: false, - has_agreement: false, - has_validation_receipt: false, - has_cancellation: false, - has_issues: false, - }, - validation_trust: None, - online_evidence: None, - eligibility: TradeStatusEligibility { - can_decide: false, - can_cancel: false, - }, - next_action: TradeStatusNextActionKind::InspectEvidenceIssues, - event_ids: vec![root_event_id.clone()], - request_event_id: None, - decision_event_id: None, - agreement_event_id: None, - rhi_receipt_event_id: None, - cancellation_event_id: None, - last_event_id: None, - issues: Vec::new(), - }; - - let view_json = serde_json::to_value(sdk_order_status_view(receipt)).expect("view json"); - - assert_eq!( - view_json["ambiguity_candidates"][0]["status_selector"], - json!(format!("order-1@{}", root_event_id.as_str())) - ); - assert_eq!( - view_json["actions"][0], - json!(format!( - "radroots trade get order-1@{}", - root_event_id.as_str() - )) - ); - } -} diff --git a/src/runtime/sdk.rs b/src/runtime/sdk.rs @@ -22,8 +22,7 @@ use radroots_sdk::{ RadrootsSdkMycNip46RequestPolicy, RadrootsSdkMycNip46Signer, RadrootsSdkNip46Transport, RadrootsSdkNip46TransportFuture, RadrootsSdkSignerProvider, RadrootsSdkStorageConfig, RadrootsdExecutionProfile, ReticulumAgentEndpoint, ReticulumBehavior as SdkReticulumBehavior, - ReticulumProfile, TargetPolicy, TradeValidationReceiptNostrRelayOutcomeKind, - TradeValidationReceiptNostrRelayTransportOutcomeKind, TransportProfile, + ReticulumProfile, TargetPolicy, TransportProfile, }; use radroots_transport_nostr::{ RadrootsNostrClientFetchAdapter, RadrootsRelayFetchRequest, RadrootsRelayFetchedEventsReceipt, @@ -61,18 +60,6 @@ pub fn sdk_target_outcome_kind_label(kind: PushOutboxTargetOutcomeKind) -> Strin kind.as_str().to_owned() } -pub fn sdk_validation_transport_outcome_kind_label( - kind: TradeValidationReceiptNostrRelayTransportOutcomeKind, -) -> String { - kind.as_str().to_owned() -} - -pub fn sdk_validation_relay_outcome_kind_label( - kind: TradeValidationReceiptNostrRelayOutcomeKind, -) -> String { - kind.as_str().to_owned() -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct CliSdkConfig { pub storage_root: PathBuf, @@ -806,8 +793,7 @@ mod tests { use radroots_authority::RadrootsEventSigner; use radroots_sdk::{ PushOutboxTargetOutcomeKind, PushOutboxTransportOutcomeKind, RadrootsdExecutionAuth, - SdkStorageKind, StorageStatusRequest, TradeValidationReceiptNostrRelayOutcomeKind, - TradeValidationReceiptNostrRelayTransportOutcomeKind, + SdkStorageKind, StorageStatusRequest, }; use radroots_secret_vault::RadrootsSecretBackend; use tempfile::tempdir; @@ -1044,10 +1030,6 @@ mod tests { concat!("fn sdk_target", "_outcome_kind("), "local SDK target outcome label helper", ), - ( - concat!("fn sdk_relay", "_outcome_kind("), - "local SDK validation receipt relay label helper", - ), ]; const SDK_OUTCOME_LABEL_HELPER_GUARDS: &[SdkOutcomeLabelHelperGuard] = &[ @@ -1059,16 +1041,6 @@ mod tests { SdkOutcomeLabelHelperGuard { label: "push outbox target outcome label helper", start: "pub fn sdk_target_outcome_kind_label(", - end: "pub fn sdk_validation_transport_outcome_kind_label(", - }, - SdkOutcomeLabelHelperGuard { - label: "validation transport outcome label helper", - start: "pub fn sdk_validation_transport_outcome_kind_label(", - end: "pub fn sdk_validation_relay_outcome_kind_label(", - }, - SdkOutcomeLabelHelperGuard { - label: "validation relay outcome label helper", - start: "pub fn sdk_validation_relay_outcome_kind_label(", end: "#[derive(Debug, Clone, PartialEq, Eq)]", }, ]; @@ -1111,115 +1083,51 @@ mod tests { required_tokens: &["session.sdk().sync().push_outbox", "PushOutboxRequest::new"], }, MigratedCliPathGuard { - label: "order public status", - path: "src/runtime/order.rs", - start: "pub fn status(\n config: &RuntimeConfig", - end: "fn decide_trade_via_sdk(", - required_tokens: &[ - "TradeStatusRequest::parse", - ".with_validation_trust_policy(", - "trade_validation_trust_policy(config)?", - "session.sdk().trades().status", - ], - }, - MigratedCliPathGuard { - label: "order locator status helper", - path: "src/runtime/order.rs", - start: "fn trade_status_for_locator(", - end: "fn inventory_commitments_from_status(", + label: "trade release product commands", + path: "src/runtime/trade.rs", + start: "pub fn submit_proposal(", + end: "pub fn get_trade(", required_tokens: &[ - "TradeStatusRequest::new(locator)", - "SdkTradeStatusSource::ResyncThenLocal", - "session.block_on(", - ".sdk()", - ".trades()", - ".with_source(SdkTradeStatusSource::ResyncThenLocal)", - ".with_validation_trust_policy(", - "trade_validation_trust_policy(config)?", + "SubmitProposalRequest::new", + "ProposeRevisionRequest::new", + "DecideCandidateRequest::new", + "CancelTradeRequest::new", + "ResumeOperationRequest::new", + "session.sdk().trades().commands().submit_proposal", + "session.sdk().trades().commands().propose_revision", + "session.sdk().trades().commands().decide_candidate", + "session.sdk().trades().commands().cancel_trade", + "session.sdk().trades().commands().resume_operation", ], }, MigratedCliPathGuard { - label: "order SDK status adapter", - path: "src/runtime/order/sdk_status.rs", - start: "pub(super) fn sdk_order_status_view(", - end: "fn sdk_event_id_string(", + label: "trade release product queries", + path: "src/runtime/trade.rs", + start: "pub fn get_trade(", + end: "pub fn seal_private_artifact(", required_tokens: &[ - "TradeStatusReceipt", - "OrderStatusView", - "OrderStatusLifecycleView", - "OrderStatusSdkReceiptView", - "TradeValidationTrustDecision", - "validation_trust", - "has_validation_receipt", - "ambiguity_candidates", - "TradeStatusAmbiguityCandidate", - "TradeStatusRequest::locator_selector", + "GetTradeRequest::new", + "ListTradesRequest::new", + "RefreshTradeEvidenceRequest::new", + "InspectEvidenceRequest::new", + "session.sdk().trades().queries().get_trade", + "session.sdk().trades().queries().list_trades", + "session.sdk().trades().queries().refresh_evidence", + "session.sdk().trades().queries().inspect_evidence", ], }, MigratedCliPathGuard { - label: "validation receipt SDK list", - path: "src/runtime/validation_receipt.rs", - start: "pub fn list(", - end: "fn inspect_event(", + label: "trade private artifact SDK", + path: "src/runtime/trade.rs", + start: "pub fn seal_private_artifact(", + end: "pub fn scaffold_proposal_draft(", required_tokens: &[ - "TradeValidationReceiptListRequest::parse", - ".validation_receipts()", - ".list(request)", - ], - }, - MigratedCliPathGuard { - label: "validation receipt SDK inspection", - path: "src/runtime/validation_receipt.rs", - start: "fn inspect_event(", - end: "fn inspection_from_sdk_receipt(", - required_tokens: &[ - "TradeValidationReceiptInspectRequest::parse", - "TradeValidationReceiptVerifyRequest::parse", - ".validation_receipts()", - ".inspect(request)", - ".verify(request)", - ], - }, - MigratedCliPathGuard { - label: "trade request", - path: "src/runtime/order.rs", - start: "fn canonical_order_request_payload_from_loaded(", - end: "fn sdk_trade_submit_outcome_view(", - required_tokens: &[ - "propose_trade_via_sdk", - "TradeProposeRequest::new", - "protocol_order_id", - "protocol_listing_addr", - "protocol_pubkey", - "args.confirm_public_note", - "session.sdk().trades().buyer().propose_trade", - "trade_publish_mode(config)", - ], - }, - MigratedCliPathGuard { - label: "order decision", - path: "src/runtime/order.rs", - start: "fn decide_trade_via_sdk(", - end: "fn cancel_trade_via_sdk(", - required_tokens: &[ - "decide_trade_via_sdk", - "TradeAcceptRequest::new", - "TradeDeclineRequest::new", - "TradeEvidenceMode::ResyncBeforeMutation", - "session.sdk().trades().seller().accept_trade", - "session.sdk().trades().seller().decline_trade", - ], - }, - MigratedCliPathGuard { - label: "order lifecycle", - path: "src/runtime/order.rs", - start: "fn cancel_trade_via_sdk(", - end: "fn trade_status_for_locator(", - required_tokens: &[ - "cancel_trade_via_sdk", - "TradeCancelRequest::new", - "TradeEvidenceMode::ResyncBeforeMutation", - "session.sdk().trades().buyer().cancel_trade", + "TradePrivateArtifactSealRequest::binding_terms", + "TradePrivateArtifactOpenRequest::new", + "TradePrivateArtifactDeleteRequest::new", + "session.sdk().trades().seal_private_artifact", + "session.sdk().trades().open_private_artifact", + "session.sdk().trades().delete_private_artifact", ], }, MigratedCliPathGuard { @@ -1659,93 +1567,6 @@ mod tests { ] { assert_eq!(sdk_transport_outcome_kind_label(kind), label); } - - for (kind, label) in [ - (TradeValidationReceiptNostrRelayOutcomeKind::Eose, "eose"), - ( - TradeValidationReceiptNostrRelayOutcomeKind::Closed, - "closed", - ), - ( - TradeValidationReceiptNostrRelayOutcomeKind::Notice, - "notice", - ), - ] { - assert_eq!(sdk_validation_relay_outcome_kind_label(kind), label); - } - - for (kind, label) in [ - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::Accepted, - "accepted", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::DuplicateAccepted, - "duplicate_accepted", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::Blocked, - "blocked", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::RateLimited, - "rate_limited", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::Invalid, - "invalid", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::PowRequired, - "pow_required", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::Restricted, - "restricted", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::AuthRequired, - "auth_required", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::Muted, - "muted", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::Unsupported, - "unsupported", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::PaymentRequired, - "payment_required", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::Error, - "error", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::Timeout, - "timeout", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::ConnectionFailed, - "connection_failed", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::RelayUrlRejected, - "relay_url_rejected", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::SkippedAlreadyAccepted, - "skipped_already_accepted", - ), - ( - TradeValidationReceiptNostrRelayTransportOutcomeKind::Unknown, - "unknown", - ), - ] { - assert_eq!(sdk_validation_transport_outcome_kind_label(kind), label); - } } #[test] @@ -2367,16 +2188,6 @@ mod tests { pub fn sdk_target_outcome_kind_label(kind: PushOutboxTargetOutcomeKind) -> String {{\n\ kind.as_str().to_owned()\n\ }}\n\ -pub fn sdk_validation_transport_outcome_kind_label(\n\ - kind: TradeValidationReceiptNostrRelayTransportOutcomeKind,\n\ -) -> String {{\n\ - kind.as_str().to_owned()\n\ -}}\n\ -pub fn sdk_validation_relay_outcome_kind_label(\n\ - kind: TradeValidationReceiptNostrRelayOutcomeKind,\n\ -) -> String {{\n\ - kind.as_str().to_owned()\n\ -}}\n\ #[derive(Debug, Clone, PartialEq, Eq)]\n\ struct FixtureConfig;\n\ {extra_source}" diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs @@ -10,8 +10,8 @@ use crate::view::runtime::{ SignerStatusView, SignerWriteKindReadinessView, }; use radroots_event::kinds::{ - KIND_FARM, KIND_LISTING, KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST, - KIND_PROFILE, + KIND_FARM, KIND_LISTING, KIND_PROFILE, KIND_TRADE_CANCELLATION, KIND_TRADE_DECISION, + KIND_TRADE_PROPOSAL, KIND_TRADE_REVISION_DECISION, KIND_TRADE_REVISION_PROPOSAL, }; use radroots_nostr_accounts::prelude::RadrootsNostrAccountStatus; use radroots_nostr_connect::prelude::RadrootsNostrConnectPermissions; @@ -294,7 +294,7 @@ fn disabled_binding_status() -> SignerBindingStatusView { } } -fn cli_write_kinds() -> [CliWriteKind; 9] { +fn cli_write_kinds() -> [CliWriteKind; 10] { [ CliWriteKind { command: "sync.push", @@ -317,20 +317,24 @@ fn cli_write_kinds() -> [CliWriteKind; 9] { event_kind: KIND_LISTING, }, CliWriteKind { - command: "trade.request", - event_kind: KIND_ORDER_REQUEST, + command: "trade.proposal.submit", + event_kind: KIND_TRADE_PROPOSAL, }, CliWriteKind { - command: "trade.accept", - event_kind: KIND_ORDER_DECISION, + command: "trade.candidate.decide", + event_kind: KIND_TRADE_DECISION, }, CliWriteKind { - command: "trade.decline", - event_kind: KIND_ORDER_DECISION, + command: "trade.revision.propose", + event_kind: KIND_TRADE_REVISION_PROPOSAL, }, CliWriteKind { - command: "trade.cancel", - event_kind: KIND_ORDER_CANCELLATION, + command: "trade.revision.candidate.decide", + event_kind: KIND_TRADE_REVISION_DECISION, + }, + CliWriteKind { + command: "trade.cancellation.submit", + event_kind: KIND_TRADE_CANCELLATION, }, ] } @@ -582,16 +586,15 @@ fn sign_event_permission_for_kind(event_kind: u32) -> String { #[cfg(test)] mod tests { use super::{ - KIND_FARM, KIND_LISTING, KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST, - KIND_PROFILE, cli_write_kinds, myc_managed_account_ref_matches, myc_write_kind_readiness, + KIND_FARM, KIND_LISTING, KIND_PROFILE, KIND_TRADE_CANCELLATION, KIND_TRADE_DECISION, + KIND_TRADE_PROPOSAL, KIND_TRADE_REVISION_DECISION, KIND_TRADE_REVISION_PROPOSAL, + cli_write_kinds, myc_managed_account_ref_matches, myc_write_kind_readiness, myc_write_kind_readiness_for_permissions, sign_event_permission_for_kind, }; use radroots_nostr_connect::prelude::{ RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, }; - const RESERVED_ORDER_KIND_3431: u32 = 3431; - #[test] fn write_kind_readiness_matches_active_signed_mutations() { let commands: Vec<&str> = cli_write_kinds() @@ -607,50 +610,38 @@ mod tests { "listing.publish", "listing.update", "listing.withdraw", - "trade.request", - "trade.accept", - "trade.decline", - "trade.cancel", + "trade.proposal.submit", + "trade.candidate.decide", + "trade.revision.propose", + "trade.revision.candidate.decide", + "trade.cancellation.submit", ] ); assert!(!commands.contains(&"signer.status")); } #[test] - fn order_submit_readiness_uses_active_order_request_kind() { - let write_kind = cli_write_kinds() - .into_iter() - .find(|kind| kind.command == "trade.request") - .expect("order submit readiness"); - - assert_eq!(write_kind.event_kind, KIND_ORDER_REQUEST); - assert_ne!(write_kind.event_kind, RESERVED_ORDER_KIND_3431); - } - - #[test] - fn order_decision_readiness_uses_active_order_decision_kind() { - for command in ["trade.accept", "trade.decline"] { + fn trade_readiness_uses_canonical_mutation_kinds() { + for (command, event_kind) in [ + ("trade.proposal.submit", KIND_TRADE_PROPOSAL), + ("trade.candidate.decide", KIND_TRADE_DECISION), + ("trade.revision.propose", KIND_TRADE_REVISION_PROPOSAL), + ( + "trade.revision.candidate.decide", + KIND_TRADE_REVISION_DECISION, + ), + ("trade.cancellation.submit", KIND_TRADE_CANCELLATION), + ] { let write_kind = cli_write_kinds() .into_iter() .find(|kind| kind.command == command) - .expect("order decision readiness"); + .expect("trade readiness"); - assert_eq!(write_kind.event_kind, KIND_ORDER_DECISION); - assert_ne!(write_kind.event_kind, RESERVED_ORDER_KIND_3431); + assert_eq!(write_kind.event_kind, event_kind); } } #[test] - fn order_cancel_readiness_uses_order_cancellation_kind() { - let cancel = cli_write_kinds() - .into_iter() - .find(|kind| kind.command == "trade.cancel") - .expect("order cancel readiness"); - assert_eq!(cancel.event_kind, KIND_ORDER_CANCELLATION); - assert_ne!(cancel.event_kind, RESERVED_ORDER_KIND_3431); - } - - #[test] fn myc_write_readiness_requires_exact_permissions() { let readiness = myc_write_kind_readiness(true, None); let sync = readiness @@ -669,7 +660,7 @@ mod tests { for (command, event_kind) in [ ("farm.publish", KIND_FARM), ("listing.publish", KIND_LISTING), - ("trade.request", KIND_ORDER_REQUEST), + ("trade.proposal.submit", KIND_TRADE_PROPOSAL), ] { let entry = readiness .iter() diff --git a/src/runtime/sync.rs b/src/runtime/sync.rs @@ -1134,6 +1134,7 @@ pub(crate) fn freshness_for_scope( freshness_for_scope_from_executor(config, &executor, scope) } +#[cfg(test)] pub(crate) fn relay_provenance_relays_for_scope( config: &RuntimeConfig, scope: RelayIngestScope, diff --git a/src/runtime/trade.rs b/src/runtime/trade.rs @@ -0,0 +1,1028 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use radroots_authority::RadrootsActorContext; +use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreUnit, convert_unit_decimal, +}; +use radroots_event::contract::RadrootsActorRole; +use radroots_event::ids::{ + RadrootsAddressableCoordinate, RadrootsDTag, RadrootsInventoryBinId, RadrootsListingAddress, + RadrootsPublicKey, RadrootsTradeCandidateId, RadrootsTradeId, RadrootsTradeMutationId, +}; +use radroots_event::kinds::KIND_LISTING; +use radroots_event::trade::{ + RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, RADROOTS_TRADE_SCHEMA_VERSION, + RadrootsFulfillmentProfileV1, RadrootsTradeCancellationProfileV1, RadrootsTradeCandidateLineV1, + RadrootsTradeCandidateTermsV1, RadrootsTradeEconomicsProfileV1, RadrootsTradeMutationBodyV1, + RadrootsTradeMutationEnvelopeV1, canonical_trade_mutation_content, +}; +use radroots_replica_schema::nostr_event_head::{ + INostrEventHeadFindOne, INostrEventHeadFindOneArgs, NostrEventHeadQueryBindValues, +}; +use radroots_replica_schema::trade_product::{ITradeProductFieldsFilter, ITradeProductFindMany}; +use radroots_replica_store::{ReplicaSql, nostr_event_head, trade_product}; +use radroots_sdk::{ + CancelTradeRequest, DecideCandidateRequest, EvidenceRefreshReceipt, EvidenceView, + GetTradeRequest, InspectEvidenceRequest, ListTradesRequest, Page, ProposeRevisionRequest, + RefreshTradeEvidenceRequest, ResumeOperationRequest, SdkIdempotencyKey, SubmitProposalRequest, + TradeCommandReceipt, TradePrivateArtifactDeleteReceipt, TradePrivateArtifactDeleteRequest, + TradePrivateArtifactKind, TradePrivateArtifactOpenReceipt, TradePrivateArtifactOpenRequest, + TradePrivateArtifactSealReceipt, TradePrivateArtifactSealRequest, TradeStatusView, + TradeSummaryView, +}; +use radroots_sql_core::SqlxSqliteExecutor; +use serde::Serialize; + +use crate::runtime::RuntimeError; +use crate::runtime::account; +use crate::runtime::config::RuntimeConfig; +use crate::runtime::sdk::{CliSdkAdapterError, CliSdkSession, sdk_target_policy}; + +const TRADE_CANDIDATE_DRAFTS_DIR: &str = "trades/candidates"; +const TRADE_CANDIDATE_DRAFT_SOURCE: &str = "SDK trade proposal candidate draft"; +const DEFAULT_PROPOSAL_TTL_SECONDS: u64 = 7 * 24 * 60 * 60; +const DEFAULT_FULFILLMENT_START_OFFSET_SECONDS: u64 = 24 * 60 * 60; +const DEFAULT_FULFILLMENT_WINDOW_SECONDS: u64 = 2 * 60 * 60; + +static TRADE_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone)] +pub struct TradeEnvelopeFileRuntimeArgs { + pub file: PathBuf, + pub idempotency_key: Option<String>, + pub acknowledge_private_terms: bool, + pub operation_kind: Option<String>, +} + +#[derive(Debug, Clone)] +pub struct TradeIdRuntimeArgs { + pub trade_id: String, +} + +#[derive(Debug, Clone)] +pub struct TradePageRuntimeArgs { + pub limit: Option<u32>, + pub cursor: Option<String>, +} + +#[derive(Debug, Clone)] +pub struct TradeEvidenceInspectRuntimeArgs { + pub trade_id: String, + pub limit: Option<u32>, + pub cursor: Option<String>, +} + +#[derive(Debug, Clone)] +pub struct TradePrivateArtifactSealRuntimeArgs { + pub trade_id: String, + pub artifact_id: String, + pub schema_id: String, + pub input: PathBuf, + pub kind: String, + pub candidate_id: Option<String>, + pub retention_class: Option<String>, + pub expires_at_ms: Option<i64>, +} + +#[derive(Debug, Clone)] +pub struct TradePrivateArtifactOpenRuntimeArgs { + pub artifact_id: String, + pub output: PathBuf, +} + +#[derive(Debug, Clone)] +pub struct TradePrivateArtifactDeleteRuntimeArgs { + pub artifact_id: String, +} + +#[derive(Debug, Clone)] +pub struct TradeCandidateDraftCreateArgs { + pub listing: Option<String>, + pub listing_addr: Option<String>, + pub bin_id: String, + pub quantity: u32, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TradeCandidateDraftView { + pub state: String, + pub source: String, + pub trade_id: String, + pub candidate_id: Option<String>, + pub mutation_id: Option<String>, + pub file: String, + pub listing_addr: String, + pub listing_event_id: String, + pub listing_snapshot_sha256: String, + pub buyer_pubkey: String, + pub seller_pubkey: String, + pub farm_id: String, + pub ready_for_submit: bool, + pub economics: TradeCandidateDraftEconomicsView, + pub issues: Vec<TradeCandidateDraftIssue>, + pub actions: Vec<String>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TradeCandidateDraftEconomicsView { + pub currency_code: String, + pub currency_exponent: u32, + pub subtotal_mantissa: String, + pub discount_total_mantissa: String, + pub adjustment_total_mantissa: String, + pub total_mantissa: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TradeCandidateDraftIssue { + pub code: String, + pub field: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TradePrivateArtifactOpenView { + pub state: String, + pub artifact_id: String, + pub trade_id: Option<String>, + pub candidate_id: Option<String>, + pub artifact_kind: Option<TradePrivateArtifactKind>, + pub schema_id: Option<String>, + pub retention_class: Option<String>, + pub output: String, + pub bytes_written: usize, + pub created_at_ms: Option<i64>, + pub expires_at_ms: Option<i64>, + pub deleted_at_ms: Option<i64>, +} + +pub fn submit_proposal( + config: &RuntimeConfig, + args: &TradeEnvelopeFileRuntimeArgs, +) -> Result<TradeCommandReceipt, CliSdkAdapterError> { + let envelope = load_trade_envelope(args.file.as_path())?; + let (actor, session) = actor_session_for_envelope(config, &envelope, "trade proposal")?; + let mut request = SubmitProposalRequest::new(actor, envelope, sdk_target_policy(config)); + if let Some(idempotency_key) = idempotency_key(args.idempotency_key.as_deref())? { + request = request.with_idempotency_key(idempotency_key); + } + Ok(session.block_on(session.sdk().trades().commands().submit_proposal(request))?) +} + +pub fn propose_revision( + config: &RuntimeConfig, + args: &TradeEnvelopeFileRuntimeArgs, +) -> Result<TradeCommandReceipt, CliSdkAdapterError> { + let envelope = load_trade_envelope(args.file.as_path())?; + let (actor, session) = actor_session_for_envelope(config, &envelope, "trade revision")?; + let mut request = ProposeRevisionRequest::new(actor, envelope, sdk_target_policy(config)); + if let Some(idempotency_key) = idempotency_key(args.idempotency_key.as_deref())? { + request = request.with_idempotency_key(idempotency_key); + } + Ok(session.block_on(session.sdk().trades().commands().propose_revision(request))?) +} + +pub fn decide_candidate( + config: &RuntimeConfig, + args: &TradeEnvelopeFileRuntimeArgs, +) -> Result<TradeCommandReceipt, CliSdkAdapterError> { + let envelope = load_trade_envelope(args.file.as_path())?; + let (actor, session) = + actor_session_for_envelope(config, &envelope, "trade candidate decision")?; + let mut request = DecideCandidateRequest::new(actor, envelope, sdk_target_policy(config)); + if args.acknowledge_private_terms { + request = request.acknowledge_private_terms(); + } + if let Some(idempotency_key) = idempotency_key(args.idempotency_key.as_deref())? { + request = request.with_idempotency_key(idempotency_key); + } + Ok(session.block_on(session.sdk().trades().commands().decide_candidate(request))?) +} + +pub fn cancel_trade( + config: &RuntimeConfig, + args: &TradeEnvelopeFileRuntimeArgs, +) -> Result<TradeCommandReceipt, CliSdkAdapterError> { + let envelope = load_trade_envelope(args.file.as_path())?; + let (actor, session) = actor_session_for_envelope(config, &envelope, "trade cancellation")?; + let mut request = CancelTradeRequest::new(actor, envelope, sdk_target_policy(config)); + if let Some(idempotency_key) = idempotency_key(args.idempotency_key.as_deref())? { + request = request.with_idempotency_key(idempotency_key); + } + Ok(session.block_on(session.sdk().trades().commands().cancel_trade(request))?) +} + +pub fn resume_operation( + config: &RuntimeConfig, + args: &TradeEnvelopeFileRuntimeArgs, +) -> Result<TradeCommandReceipt, CliSdkAdapterError> { + let operation_kind = args.operation_kind.as_deref().ok_or_else(|| { + RuntimeError::Config("trade operation resume requires `--operation-kind`".to_owned()) + })?; + let envelope = load_trade_envelope(args.file.as_path())?; + let (actor, session) = actor_session_for_envelope(config, &envelope, "trade operation resume")?; + let mut request = ResumeOperationRequest::new( + actor, + envelope, + resume_operation_kind(operation_kind)?, + sdk_target_policy(config), + ); + if args.acknowledge_private_terms { + request = request.acknowledge_private_terms(); + } + if let Some(idempotency_key) = idempotency_key(args.idempotency_key.as_deref())? { + request = request.with_idempotency_key(idempotency_key); + } + Ok(session.block_on(session.sdk().trades().commands().resume_operation(request))?) +} + +pub fn get_trade( + config: &RuntimeConfig, + args: &TradeIdRuntimeArgs, +) -> Result<TradeStatusView, CliSdkAdapterError> { + let session = CliSdkSession::connect(config)?; + let trade_id = trade_id(args.trade_id.as_str(), "trade_id")?; + let request = GetTradeRequest::new(trade_id); + Ok(session.block_on(session.sdk().trades().queries().get_trade(request))?) +} + +pub fn list_trades( + config: &RuntimeConfig, + args: &TradePageRuntimeArgs, +) -> Result<Page<TradeSummaryView>, CliSdkAdapterError> { + let session = CliSdkSession::connect(config)?; + let mut request = ListTradesRequest::new(); + if let Some(limit) = args.limit { + request = request.with_limit(limit); + } + if let Some(cursor) = args.cursor.as_deref().and_then(non_empty_ref) { + request = request.with_cursor(cursor); + } + Ok(session.block_on(session.sdk().trades().queries().list_trades(request))?) +} + +pub fn refresh_evidence( + config: &RuntimeConfig, + args: &TradeIdRuntimeArgs, +) -> Result<EvidenceRefreshReceipt, CliSdkAdapterError> { + let session = CliSdkSession::connect(config)?; + let trade_id = trade_id(args.trade_id.as_str(), "trade_id")?; + let request = RefreshTradeEvidenceRequest::new(trade_id); + Ok(session.block_on(session.sdk().trades().queries().refresh_evidence(request))?) +} + +pub fn inspect_evidence( + config: &RuntimeConfig, + args: &TradeEvidenceInspectRuntimeArgs, +) -> Result<Page<EvidenceView>, CliSdkAdapterError> { + let session = CliSdkSession::connect(config)?; + let mut request = InspectEvidenceRequest::new(trade_id(args.trade_id.as_str(), "trade_id")?); + if let Some(limit) = args.limit { + request = request.with_limit(limit); + } + if let Some(cursor) = args.cursor.as_deref().and_then(non_empty_ref) { + request = request.with_cursor(cursor); + } + Ok(session.block_on(session.sdk().trades().queries().inspect_evidence(request))?) +} + +pub fn seal_private_artifact( + config: &RuntimeConfig, + args: &TradePrivateArtifactSealRuntimeArgs, +) -> Result<TradePrivateArtifactSealReceipt, CliSdkAdapterError> { + let session = CliSdkSession::connect(config)?; + let plaintext = fs::read(args.input.as_path()).map_err(RuntimeError::from)?; + let mut request = TradePrivateArtifactSealRequest::binding_terms( + args.artifact_id.as_str(), + trade_id(args.trade_id.as_str(), "trade_id")?, + args.schema_id.as_str(), + plaintext, + ); + request.artifact_kind = private_artifact_kind(args.kind.as_str())?; + if let Some(candidate_id) = args.candidate_id.as_deref().and_then(non_empty_ref) { + request = request.with_candidate_id(trade_candidate_id(candidate_id, "candidate_id")?); + } + if let Some(retention_class) = args.retention_class.as_deref().and_then(non_empty_ref) { + request = request.with_retention_class(retention_class); + } + if let Some(expires_at_ms) = args.expires_at_ms { + request = request.with_expires_at_ms(expires_at_ms); + } + Ok(session.block_on(session.sdk().trades().seal_private_artifact(request))?) +} + +pub fn open_private_artifact( + config: &RuntimeConfig, + args: &TradePrivateArtifactOpenRuntimeArgs, +) -> Result<TradePrivateArtifactOpenView, CliSdkAdapterError> { + let session = CliSdkSession::connect(config)?; + let request = TradePrivateArtifactOpenRequest::new(args.artifact_id.as_str()); + let receipt = session.block_on(session.sdk().trades().open_private_artifact(request))?; + Ok(match receipt { + Some(receipt) => private_artifact_open_view(args, receipt)?, + None => TradePrivateArtifactOpenView { + state: "missing".to_owned(), + artifact_id: args.artifact_id.clone(), + trade_id: None, + candidate_id: None, + artifact_kind: None, + schema_id: None, + retention_class: None, + output: args.output.display().to_string(), + bytes_written: 0, + created_at_ms: None, + expires_at_ms: None, + deleted_at_ms: None, + }, + }) +} + +pub fn delete_private_artifact( + config: &RuntimeConfig, + args: &TradePrivateArtifactDeleteRuntimeArgs, +) -> Result<TradePrivateArtifactDeleteReceipt, CliSdkAdapterError> { + let session = CliSdkSession::connect(config)?; + let request = TradePrivateArtifactDeleteRequest::new(args.artifact_id.as_str()); + Ok(session.block_on(session.sdk().trades().delete_private_artifact(request))?) +} + +pub fn scaffold_proposal_draft( + config: &RuntimeConfig, + args: &TradeCandidateDraftCreateArgs, +) -> Result<TradeCandidateDraftView, RuntimeError> { + scaffold_proposal_draft_inner(config, args, false) +} + +pub fn scaffold_proposal_draft_preflight( + config: &RuntimeConfig, + args: &TradeCandidateDraftCreateArgs, +) -> Result<TradeCandidateDraftView, RuntimeError> { + scaffold_proposal_draft_inner(config, args, true) +} + +fn scaffold_proposal_draft_inner( + config: &RuntimeConfig, + args: &TradeCandidateDraftCreateArgs, + dry_run: bool, +) -> Result<TradeCandidateDraftView, RuntimeError> { + if args.quantity == 0 { + return Err(RuntimeError::Config( + "basket item quantity must be greater than zero".to_owned(), + )); + } + let buyer = account::resolve_account(config)?.ok_or_else(|| { + RuntimeError::Config("trade proposal draft requires a selected buyer account".to_owned()) + })?; + let product = resolve_product(config, args)?; + let parsed_listing = parse_listing_addr(product.listing_addr.as_str())?; + if parsed_listing.kind != KIND_LISTING { + return Err(RuntimeError::Config( + "trade proposal draft requires a public listing address".to_owned(), + )); + } + let listing_state = + resolve_active_listing_state(config, product.listing_addr.as_str(), &parsed_listing)?; + let farm_id = resolve_farm_id(config, parsed_listing.seller_pubkey.as_str())?; + let buyer_pubkey = pubkey( + buyer.record.public_identity.public_key_hex.as_str(), + "buyer_pubkey", + )?; + let seller_pubkey = pubkey(parsed_listing.seller_pubkey.as_str(), "seller_pubkey")?; + let candidate = candidate_terms( + &product, + &listing_state, + buyer_pubkey.clone(), + seller_pubkey.clone(), + farm_id.clone(), + args.quantity, + )?; + let envelope = RadrootsTradeMutationEnvelopeV1 { + mutation_id: None, + contract_id: RADROOTS_TRADE_PROPOSAL_CONTRACT_ID.to_owned(), + schema_version: RADROOTS_TRADE_SCHEMA_VERSION, + trade_id: next_trade_id()?, + root_mutation_id: None, + buyer_pubkey, + seller_pubkey: seller_pubkey.clone(), + farm_id: farm_id.clone(), + parent_mutation_ids: Vec::new(), + author_pubkey: pubkey( + buyer.record.public_identity.public_key_hex.as_str(), + "author_pubkey", + )?, + counterparty_pubkey: seller_pubkey, + authored_at_unix_s: now_unix(), + body: RadrootsTradeMutationBodyV1::Proposal { candidate }, + }; + let canonical = canonical_trade_mutation_content(envelope) + .map_err(|error| RuntimeError::Config(format!("build trade proposal envelope: {error}")))?; + let file = candidate_draft_file(config, canonical.envelope.trade_id.as_str()); + if !dry_run { + if let Some(parent) = file.parent() { + fs::create_dir_all(parent)?; + } + let contents = serde_json::to_string_pretty(&canonical.envelope)?; + fs::write(file.as_path(), contents)?; + } + let economics = proposal_economics_view(&canonical.envelope); + let candidate_id = proposal_candidate_id(&canonical.envelope); + Ok(TradeCandidateDraftView { + state: if dry_run { "dry_run" } else { "drafted" }.to_owned(), + source: TRADE_CANDIDATE_DRAFT_SOURCE.to_owned(), + trade_id: canonical.envelope.trade_id.to_string(), + candidate_id, + mutation_id: canonical + .envelope + .mutation_id + .as_ref() + .map(ToString::to_string), + file: file.display().to_string(), + listing_addr: product.listing_addr, + listing_event_id: listing_state.last_event_id, + listing_snapshot_sha256: listing_state.content_hash, + buyer_pubkey: buyer.record.public_identity.public_key_hex, + seller_pubkey: parsed_listing.seller_pubkey, + farm_id: farm_id.to_string(), + ready_for_submit: true, + economics, + issues: Vec::new(), + actions: if dry_run { + Vec::new() + } else { + vec![format!("radroots trade proposal submit {}", file.display())] + }, + }) +} + +fn load_trade_envelope(path: &Path) -> Result<RadrootsTradeMutationEnvelopeV1, RuntimeError> { + let contents = fs::read_to_string(path)?; + serde_json::from_str(contents.as_str()).map_err(|error| { + RuntimeError::Config(format!( + "invalid trade envelope {}: {error}", + path.display() + )) + }) +} + +fn actor_session_for_envelope( + config: &RuntimeConfig, + envelope: &RadrootsTradeMutationEnvelopeV1, + operation: &str, +) -> Result<(RadrootsActorContext, CliSdkSession), CliSdkAdapterError> { + let account = account::resolve_account(config)?.ok_or_else(|| { + RuntimeError::Config(format!("{operation} requires a selected signer account")) + })?; + let author_pubkey = envelope.author_pubkey.as_str(); + let account_pubkey = account.record.public_identity.public_key_hex.as_str(); + if !account_pubkey.eq_ignore_ascii_case(author_pubkey) { + return Err(RuntimeError::Config(format!( + "{operation} envelope author `{author_pubkey}` does not match selected account `{}` public key `{account_pubkey}`", + account.record.account_id + )) + .into()); + } + let role = if envelope + .buyer_pubkey + .as_str() + .eq_ignore_ascii_case(author_pubkey) + { + RadrootsActorRole::Buyer + } else if envelope + .seller_pubkey + .as_str() + .eq_ignore_ascii_case(author_pubkey) + { + RadrootsActorRole::Seller + } else { + return Err(RuntimeError::Config(format!( + "{operation} envelope author must be the buyer or seller" + )) + .into()); + }; + let actor = RadrootsActorContext::local_account( + author_pubkey, + account.record.account_id.to_string(), + [role], + ) + .map_err(|error| RuntimeError::Config(format!("invalid trade SDK actor: {error}")))?; + let session = CliSdkSession::connect_for_actor( + config, + Some(account.record.account_id.as_str()), + author_pubkey, + operation, + )?; + Ok((actor, session)) +} + +fn idempotency_key(value: Option<&str>) -> Result<Option<SdkIdempotencyKey>, RuntimeError> { + value + .and_then(non_empty_ref) + .map(SdkIdempotencyKey::new) + .transpose() + .map_err(|error| RuntimeError::Config(error.to_string())) +} + +fn resume_operation_kind(value: &str) -> Result<&'static str, RuntimeError> { + match value { + "trade.submit_proposal.v1" => Ok("trade.submit_proposal.v1"), + "trade.propose_revision.v1" => Ok("trade.propose_revision.v1"), + "trade.decide_candidate.v1" => Ok("trade.decide_candidate.v1"), + "trade.cancel.v1" => Ok("trade.cancel.v1"), + other => Err(RuntimeError::Config(format!( + "unsupported trade operation kind `{other}`" + ))), + } +} + +fn private_artifact_open_view( + args: &TradePrivateArtifactOpenRuntimeArgs, + receipt: TradePrivateArtifactOpenReceipt, +) -> Result<TradePrivateArtifactOpenView, RuntimeError> { + if let Some(parent) = args.output.parent() { + fs::create_dir_all(parent)?; + } + fs::write(args.output.as_path(), &receipt.plaintext)?; + Ok(TradePrivateArtifactOpenView { + state: "opened".to_owned(), + artifact_id: receipt.artifact_id, + trade_id: Some(receipt.trade_id.to_string()), + candidate_id: receipt.candidate_id.as_ref().map(ToString::to_string), + artifact_kind: Some(receipt.artifact_kind), + schema_id: Some(receipt.schema_id), + retention_class: Some(receipt.retention_class), + output: args.output.display().to_string(), + bytes_written: receipt.plaintext.len(), + created_at_ms: Some(receipt.created_at_ms), + expires_at_ms: receipt.expires_at_ms, + deleted_at_ms: receipt.deleted_at_ms, + }) +} + +fn private_artifact_kind(value: &str) -> Result<TradePrivateArtifactKind, RuntimeError> { + match value { + "binding_terms" => Ok(TradePrivateArtifactKind::BindingTerms), + "message" => Ok(TradePrivateArtifactKind::Message), + "contact_bundle" => Ok(TradePrivateArtifactKind::ContactBundle), + "delivery_instruction" => Ok(TradePrivateArtifactKind::DeliveryInstruction), + other => Err(RuntimeError::Config(format!( + "unsupported private artifact kind `{other}`" + ))), + } +} + +#[derive(Debug, Clone)] +struct ProductFacts { + key: String, + qty_amt_exact: String, + qty_unit: String, + price_amt_exact: String, + price_currency: String, + price_qty_amt_exact: String, + price_qty_unit: String, + listing_addr: String, + primary_bin_id: String, +} + +#[derive(Debug, Clone)] +struct ParsedListingAddress { + kind: u32, + seller_pubkey: String, + listing_id: String, +} + +#[derive(Debug, Clone)] +struct ActiveListingState { + last_event_id: String, + content_hash: String, +} + +fn resolve_product( + config: &RuntimeConfig, + args: &TradeCandidateDraftCreateArgs, +) -> Result<ProductFacts, RuntimeError> { + if !config.local.replica_store_path.exists() { + return Err(RuntimeError::Config( + "trade proposal draft requires local market data; run `radroots store inspect` and `radroots market pull`".to_owned(), + )); + } + if let Some(listing_addr) = args.listing_addr.as_deref().and_then(non_empty_ref) { + let executor = SqlxSqliteExecutor::open(&config.local.replica_store_path)?; + let rows = trade_product::find_many( + &executor, + &ITradeProductFindMany { + filter: Some(trade_product_listing_addr_filter(listing_addr)), + }, + ) + .map_err(|error| RuntimeError::Config(format!("resolve listing product state: {error:?}")))? + .results; + let product = match rows.len() { + 0 => { + return Err(RuntimeError::Config(format!( + "listing address `{listing_addr}` is not available in the local replica" + ))); + } + 1 => rows.into_iter().next().expect("one row"), + count => { + return Err(RuntimeError::Config(format!( + "listing address `{listing_addr}` matched {count} local listing rows" + ))); + } + }; + let facts = ProductFacts { + key: product.key, + qty_amt_exact: required(product.qty_amt_exact, "qty_amt_exact")?, + qty_unit: product.qty_unit, + price_amt_exact: required(product.price_amt_exact, "price_amt_exact")?, + price_currency: product.price_currency, + price_qty_amt_exact: required(product.price_qty_amt_exact, "price_qty_amt_exact")?, + price_qty_unit: product.price_qty_unit, + listing_addr: required(product.listing_addr, "listing_addr")?, + primary_bin_id: verified_primary_bin( + product.primary_bin_id, + product.verified_primary_bin_id, + )?, + }; + validate_product_bin(&facts, args.bin_id.as_str())?; + return Ok(facts); + } + let listing = args + .listing + .as_deref() + .and_then(non_empty_ref) + .ok_or_else(|| { + RuntimeError::Config( + "trade proposal draft requires `listing` or `listing_addr`".to_owned(), + ) + })?; + let db = ReplicaSql::new(SqlxSqliteExecutor::open(&config.local.replica_store_path)?); + let rows = db.trade_product_lookup(listing)?; + let product = match rows.len() { + 0 => { + return Err(RuntimeError::Config(format!( + "listing `{listing}` is not available in the local replica" + ))); + } + 1 => rows.into_iter().next().expect("one row"), + count => { + return Err(RuntimeError::Config(format!( + "listing `{listing}` matched {count} local listings" + ))); + } + }; + let facts = ProductFacts { + key: product.key, + qty_amt_exact: required(product.qty_amt_exact, "qty_amt_exact")?, + qty_unit: product.qty_unit, + price_amt_exact: required(product.price_amt_exact, "price_amt_exact")?, + price_currency: product.price_currency, + price_qty_amt_exact: required(product.price_qty_amt_exact, "price_qty_amt_exact")?, + price_qty_unit: product.price_qty_unit, + listing_addr: required(product.listing_addr, "listing_addr")?, + primary_bin_id: verified_primary_bin( + product.primary_bin_id, + product.verified_primary_bin_id, + )?, + }; + validate_product_bin(&facts, args.bin_id.as_str())?; + Ok(facts) +} + +fn candidate_terms( + product: &ProductFacts, + listing_state: &ActiveListingState, + buyer_pubkey: RadrootsPublicKey, + seller_pubkey: RadrootsPublicKey, + farm_id: RadrootsDTag, + bin_count: u32, +) -> Result<RadrootsTradeCandidateTermsV1, RuntimeError> { + let currency = product + .price_currency + .parse::<RadrootsCoreCurrency>() + .map_err(|error| { + RuntimeError::Config(format!("listing price_currency is invalid: {error}")) + })?; + let quantity_amount = exact_positive_decimal(product.qty_amt_exact.as_str(), "qty_amt_exact")? + * RadrootsCoreDecimal::from(bin_count); + let quantity_unit = product + .qty_unit + .parse::<RadrootsCoreUnit>() + .map_err(|error| RuntimeError::Config(format!("listing qty_unit is invalid: {error}")))?; + let price_amount = exact_positive_decimal(product.price_amt_exact.as_str(), "price_amt_exact")?; + let price_quantity_amount = + exact_positive_decimal(product.price_qty_amt_exact.as_str(), "price_qty_amt_exact")?; + let price_unit = product + .price_qty_unit + .parse::<RadrootsCoreUnit>() + .map_err(|error| { + RuntimeError::Config(format!("listing price_qty_unit is invalid: {error}")) + })?; + let quantity_unit_in_price_units = + convert_unit_decimal(RadrootsCoreDecimal::ONE, quantity_unit, price_unit).map_err( + |error| { + RuntimeError::Config(format!( + "listing quantity and price units are incompatible: {error}" + )) + }, + )?; + let unit_price_amount = (price_amount / price_quantity_amount) * quantity_unit_in_price_units; + let subtotal = unit_price_amount * quantity_amount; + let quantity_scale = u8::try_from(quantity_amount.scale()) + .map_err(|_| RuntimeError::Config("trade quantity scale exceeds u8".to_owned()))?; + let currency_exponent = currency.minor_unit_exponent(); + let subtotal_mantissa = decimal_mantissa_at_scale(subtotal, currency_exponent); + let line = RadrootsTradeCandidateLineV1 { + line_id: RadrootsDTag::parse("line-1") + .map_err(|error| RuntimeError::Config(format!("invalid line id: {error}")))?, + listing_addr: RadrootsAddressableCoordinate::parse(product.listing_addr.as_str()) + .map_err(|error| RuntimeError::Config(format!("invalid listing address: {error}")))?, + listing_event_id: listing_state + .last_event_id + .parse() + .map_err(|error| RuntimeError::Config(format!("invalid listing event id: {error}")))?, + listing_snapshot_sha256: listing_state.content_hash.clone(), + product_id: product.key.clone(), + option_id: None, + bin_id: inventory_bin_id(product.primary_bin_id.as_str(), "bin_id")?, + quantity_mantissa: decimal_mantissa_at_scale(quantity_amount, u32::from(quantity_scale)), + quantity_scale, + unit_code: product.qty_unit.clone(), + unit_profile: "radroots-core-unit".to_owned(), + unit_price_mantissa: decimal_mantissa_at_scale(unit_price_amount, currency_exponent), + currency_code: product.price_currency.clone(), + line_subtotal_mantissa: subtotal_mantissa.clone(), + replaces_line_id: None, + }; + let now = now_unix(); + Ok(RadrootsTradeCandidateTermsV1 { + candidate_id: None, + schema_version: RADROOTS_TRADE_SCHEMA_VERSION, + base_candidate_id: None, + supersession_intent: None, + buyer_pubkey, + seller_pubkey, + farm_id, + lines: vec![line], + line_tombstones: Vec::new(), + economics: RadrootsTradeEconomicsProfileV1 { + profile_id: "mvp-fixed".to_owned(), + currency_code: product.price_currency.clone(), + currency_exponent: u8::try_from(currency_exponent) + .map_err(|_| RuntimeError::Config("currency exponent exceeds u8".to_owned()))?, + rounding_profile: "half-even".to_owned(), + subtotal_mantissa: subtotal_mantissa.clone(), + discount_total_mantissa: "0".to_owned(), + adjustment_total_mantissa: "0".to_owned(), + total_mantissa: subtotal_mantissa, + adjustments: Vec::new(), + }, + fulfillment: RadrootsFulfillmentProfileV1 { + profile_id: "market-pickup".to_owned(), + method: "pickup".to_owned(), + starts_at_unix_s: now + DEFAULT_FULFILLMENT_START_OFFSET_SECONDS, + ends_at_unix_s: now + + DEFAULT_FULFILLMENT_START_OFFSET_SECONDS + + DEFAULT_FULFILLMENT_WINDOW_SECONDS, + timezone: "UTC".to_owned(), + utc_offset_seconds: 0, + fold: 0, + location_class: "seller_public_listing".to_owned(), + requires_private_terms: false, + }, + cancellation: RadrootsTradeCancellationProfileV1 { + profile_id: "buyer-pre-agreement".to_owned(), + buyer_pre_agreement: true, + post_agreement_cutoff_unix_s: None, + }, + private_terms: None, + proposal_expires_at_unix_s: now + DEFAULT_PROPOSAL_TTL_SECONDS, + }) +} + +fn resolve_active_listing_state( + config: &RuntimeConfig, + listing_addr: &str, + parsed: &ParsedListingAddress, +) -> Result<ActiveListingState, RuntimeError> { + let executor = SqlxSqliteExecutor::open(&config.local.replica_store_path)?; + let key = format!( + "{}:{}:{}", + parsed.kind, parsed.seller_pubkey, parsed.listing_id + ); + let state = nostr_event_head::find_one( + &executor, + &INostrEventHeadFindOne::On(INostrEventHeadFindOneArgs { + on: NostrEventHeadQueryBindValues::Key { key }, + }), + ) + .map_err(|error| RuntimeError::Config(format!("resolve listing event state: {error:?}")))? + .result + .ok_or_else(|| { + RuntimeError::Config(format!( + "listing address `{listing_addr}` is missing latest listing event state; run `radroots market pull`" + )) + })?; + RadrootsTradeMutationId::parse(state.content_hash.as_str()).map_err(|error| { + RuntimeError::Config(format!( + "listing content hash is not a 32-byte hex digest: {error}" + )) + })?; + state + .last_event_id + .parse::<radroots_event::ids::RadrootsEventId>() + .map_err(|error| { + RuntimeError::Config(format!("listing latest event id is invalid: {error}")) + })?; + Ok(ActiveListingState { + last_event_id: state.last_event_id, + content_hash: state.content_hash, + }) +} + +fn resolve_farm_id( + config: &RuntimeConfig, + seller_pubkey: &str, +) -> Result<RadrootsDTag, RuntimeError> { + let db = ReplicaSql::new(SqlxSqliteExecutor::open(&config.local.replica_store_path)?); + let d_tag = db.farm_unique_d_tag_by_pubkey(seller_pubkey)?.ok_or_else(|| { + RuntimeError::Config(format!( + "seller `{seller_pubkey}` must have exactly one farm profile in the local replica before creating a trade proposal draft" + )) + })?; + RadrootsDTag::parse(d_tag.as_str()) + .map_err(|error| RuntimeError::Config(format!("farm d tag is invalid: {error}"))) +} + +fn parse_listing_addr(raw: &str) -> Result<ParsedListingAddress, RuntimeError> { + let parsed = RadrootsListingAddress::parse(raw) + .map_err(|error| RuntimeError::Config(format!("listing address is invalid: {error}")))?; + let (kind, rest) = parsed + .as_str() + .split_once(':') + .ok_or_else(|| RuntimeError::Config("listing address has invalid format".to_owned()))?; + let (seller_pubkey, listing_id) = rest + .split_once(':') + .ok_or_else(|| RuntimeError::Config("listing address has invalid format".to_owned()))?; + let kind = kind + .parse::<u32>() + .map_err(|_| RuntimeError::Config("listing address kind is invalid".to_owned()))?; + Ok(ParsedListingAddress { + kind, + seller_pubkey: seller_pubkey.to_owned(), + listing_id: listing_id.to_owned(), + }) +} + +fn proposal_economics_view( + envelope: &RadrootsTradeMutationEnvelopeV1, +) -> TradeCandidateDraftEconomicsView { + let RadrootsTradeMutationBodyV1::Proposal { candidate } = &envelope.body else { + unreachable!("proposal draft envelope is a proposal") + }; + TradeCandidateDraftEconomicsView { + currency_code: candidate.economics.currency_code.clone(), + currency_exponent: u32::from(candidate.economics.currency_exponent), + subtotal_mantissa: candidate.economics.subtotal_mantissa.clone(), + discount_total_mantissa: candidate.economics.discount_total_mantissa.clone(), + adjustment_total_mantissa: candidate.economics.adjustment_total_mantissa.clone(), + total_mantissa: candidate.economics.total_mantissa.clone(), + } +} + +fn proposal_candidate_id(envelope: &RadrootsTradeMutationEnvelopeV1) -> Option<String> { + let RadrootsTradeMutationBodyV1::Proposal { candidate } = &envelope.body else { + return None; + }; + candidate.candidate_id.as_ref().map(ToString::to_string) +} + +fn trade_product_listing_addr_filter(listing_addr: &str) -> ITradeProductFieldsFilter { + ITradeProductFieldsFilter { + id: None, + created_at: None, + updated_at: None, + key: None, + category: None, + title: None, + summary: None, + process: None, + lot: None, + profile: None, + year: None, + qty_amt: None, + qty_amt_exact: None, + qty_unit: None, + qty_label: None, + qty_avail: None, + price_amt: None, + price_amt_exact: None, + price_currency: None, + price_qty_amt: None, + price_qty_amt_exact: None, + price_qty_unit: None, + listing_addr: Some(listing_addr.to_owned()), + primary_bin_id: None, + verified_primary_bin_id: None, + notes: None, + } +} + +fn required(value: Option<String>, field: &str) -> Result<String, RuntimeError> { + value + .and_then(|value| non_empty_ref(value.as_str()).map(str::to_owned)) + .ok_or_else(|| RuntimeError::Config(format!("listing {field} is missing"))) +} + +fn verified_primary_bin( + primary_bin_id: Option<String>, + verified_primary_bin_id: Option<String>, +) -> Result<String, RuntimeError> { + let primary = required(primary_bin_id, "primary_bin_id")?; + let verified = required(verified_primary_bin_id, "verified_primary_bin_id")?; + if primary != verified { + return Err(RuntimeError::Config(format!( + "listing primary bin `{primary}` does not match verified primary bin `{verified}`" + ))); + } + Ok(primary) +} + +fn validate_product_bin(product: &ProductFacts, bin_id: &str) -> Result<(), RuntimeError> { + if product.primary_bin_id != bin_id { + return Err(RuntimeError::Config(format!( + "basket bin `{bin_id}` does not match listing primary bin `{}`", + product.primary_bin_id + ))); + } + Ok(()) +} + +fn exact_positive_decimal(value: &str, field: &str) -> Result<RadrootsCoreDecimal, RuntimeError> { + let parsed = value + .trim() + .parse::<RadrootsCoreDecimal>() + .map_err(|error| RuntimeError::Config(format!("listing {field} is invalid: {error}")))?; + if parsed.is_zero() || parsed.is_sign_negative() { + return Err(RuntimeError::Config(format!( + "listing {field} must be greater than zero" + ))); + } + Ok(parsed) +} + +fn decimal_mantissa_at_scale(mut value: RadrootsCoreDecimal, scale: u32) -> String { + value.rescale(scale); + value.0.mantissa().to_string() +} + +fn candidate_draft_file(config: &RuntimeConfig, trade_id: &str) -> PathBuf { + config + .paths + .app_data_root + .join(TRADE_CANDIDATE_DRAFTS_DIR) + .join(format!("{trade_id}.json")) +} + +fn next_trade_id() -> Result<RadrootsTradeId, RuntimeError> { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + let counter = u128::from(TRADE_COUNTER.fetch_add(1, Ordering::Relaxed)); + let value = format!("{:032x}", nanos ^ counter); + trade_id(value.as_str(), "trade_id") +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|value| value.as_secs()) + .unwrap_or_default() +} + +fn trade_id(value: &str, field: &str) -> Result<RadrootsTradeId, RuntimeError> { + RadrootsTradeId::parse(value) + .map_err(|error| RuntimeError::Config(format!("{field} is invalid: {error}"))) +} + +fn trade_candidate_id(value: &str, field: &str) -> Result<RadrootsTradeCandidateId, RuntimeError> { + RadrootsTradeCandidateId::parse(value) + .map_err(|error| RuntimeError::Config(format!("{field} is invalid: {error}"))) +} + +fn inventory_bin_id(value: &str, field: &str) -> Result<RadrootsInventoryBinId, RuntimeError> { + RadrootsInventoryBinId::parse(value) + .map_err(|error| RuntimeError::Config(format!("{field} is invalid: {error}"))) +} + +fn pubkey(value: &str, field: &str) -> Result<RadrootsPublicKey, RuntimeError> { + RadrootsPublicKey::parse(value) + .map_err(|error| RuntimeError::Config(format!("{field} is invalid: {error}"))) +} + +fn non_empty_ref(value: &str) -> Option<&str> { + let value = value.trim(); + if value.is_empty() { None } else { Some(value) } +} diff --git a/src/runtime/validation_receipt.rs b/src/runtime/validation_receipt.rs @@ -1,25 +1,7 @@ -use radroots_sdk::{ - RadrootsSdkError, TradeValidationReceiptEvent, TradeValidationReceiptInspectReceipt, - TradeValidationReceiptInspectRequest, TradeValidationReceiptInvalidCandidate, - TradeValidationReceiptListReceipt, TradeValidationReceiptListRequest, - TradeValidationReceiptNostrRelayOutcomeKind, TradeValidationReceiptNostrRelayOutcomeReceipt, - TradeValidationReceiptTags, TradeValidationReceiptVerifyRequest, -}; -use radroots_trade::validation_receipt::{ - RadrootsTradeCommitmentConfidence, RadrootsTradeValidationAuthority, - RadrootsTradeValidationReceipt, RadrootsValidationReceiptProofSystem, - RadrootsValidationReceiptResult, RadrootsValidationReceiptType, -}; -use radroots_trade_sp1_host::RadrootsSp1TradeHostError; -use radroots_trade_sp1_host::verify_order_acceptance_validation_receipt_inline_sp1_proof; use serde::Serialize; use serde_json::Value; use crate::runtime::config::RuntimeConfig; -use crate::runtime::sdk::{ - CliSdkAdapterError, CliSdkSession, sdk_validation_relay_outcome_kind_label, - sdk_validation_transport_outcome_kind_label, -}; use crate::view::runtime::{CommandDisposition, TransportTargetFailureView}; #[derive(Debug, Clone)] @@ -27,22 +9,16 @@ 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 proof_verification: Option<Value>, + pub receipt: Option<Value>, + pub receipt_tags: Option<Value>, + pub event: Option<Value>, pub target_transport_endpoints: Vec<String>, pub attempted_transport_endpoints: Vec<String>, pub failed_transport_targets: Vec<TransportTargetFailureView>, @@ -60,36 +36,7 @@ impl ValidationReceiptInspectionView { "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_transport_endpoints: Vec<String>, - pub attempted_transport_endpoints: Vec<String>, - pub failed_transport_targets: Vec<TransportTargetFailureView>, - pub reason_code: Option<String>, - pub reason: Option<String>, - pub sdk_error: Option<Value>, - 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, + "unsupported" => CommandDisposition::Unsupported, _ => CommandDisposition::InternalError, } } @@ -101,565 +48,33 @@ pub struct ValidationReceiptResourceView { pub id: String, } -#[derive(Debug, Clone, Serialize)] -pub struct ValidationReceiptEventView { - pub id: String, - pub author: String, - pub created_at: u64, - 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 listing_event_id: 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 validation_authority: Option<String>, - pub commitment_confidence: Option<String>, - pub production_verification: bool, - 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, - pub validator_evidence: Option<ValidationReceiptValidatorEvidenceView>, - pub untrusted_validator_evidence: Option<ValidationReceiptValidatorEvidenceView>, - pub reason_code: Option<String>, - pub reason: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ValidationReceiptValidatorEvidenceView { - pub result_event_id: String, - pub author: String, - pub validation_authority: Option<String>, - pub commitment_confidence: Option<String>, - pub status: String, - pub prover_backend: String, - pub proof_mode: String, - pub proof_system: String, - pub proof_generated: bool, - pub sp1_execute_checked: bool, - pub sp1_execute_public_values_hash: Option<String>, - pub cryptographic_proof_verified: bool, - pub public_values_hash: String, -} - -#[derive(Clone, Debug, Default)] -struct ValidationReceiptValidatorEvidenceSelection { - trusted: Option<ValidationReceiptValidatorEvidenceView>, - untrusted: Option<ValidationReceiptValidatorEvidenceView>, -} - -#[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: u64, - pub receipt_type: String, - pub result: String, - pub proof_system: String, - pub proof_verification_state: String, - pub validation_authority: Option<String>, - pub commitment_confidence: Option<String>, - pub production_verification: bool, - 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_code: String, - pub reason: String, - pub proof_verification: Option<ValidationReceiptProofVerificationView>, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ValidationReceiptCommandIntent { - Inspect, - Verify, -} - pub fn get( - config: &RuntimeConfig, + _config: &RuntimeConfig, args: &ValidationReceiptEventArgs, ) -> ValidationReceiptInspectionView { - inspect_event( - config, - &args.receipt_event_id, - "valid", - ValidationReceiptCommandIntent::Inspect, - ) + unsupported_validation_receipt_view(args, "inspect") } pub fn verify( - config: &RuntimeConfig, + _config: &RuntimeConfig, args: &ValidationReceiptEventArgs, ) -> ValidationReceiptInspectionView { - inspect_event( - config, - &args.receipt_event_id, - "verified", - ValidationReceiptCommandIntent::Verify, - ) -} - -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 request = match TradeValidationReceiptListRequest::parse(order_id) { - Ok(request) => request, - Err(error) => { - return list_sdk_error_view(order_id, CliSdkAdapterError::Sdk(error)); - } - }; - let session = match CliSdkSession::connect(config) { - Ok(session) => session, - Err(error) => return list_sdk_error_view(order_id, error), - }; - match session.block_on(session.sdk().trades().validation_receipts().list(request)) { - Ok(receipt) => list_from_sdk_receipt(receipt), - Err(error) => list_sdk_error_view(order_id, CliSdkAdapterError::Sdk(error)), - } -} - -fn inspect_event( - config: &RuntimeConfig, - receipt_event_id: &str, - success_state: &str, - intent: ValidationReceiptCommandIntent, -) -> 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 session = match CliSdkSession::connect(config) { - Ok(session) => session, - Err(error) => return inspection_sdk_error_view(receipt_event_id, error), - }; - match intent { - ValidationReceiptCommandIntent::Inspect => { - let request = match TradeValidationReceiptInspectRequest::parse(receipt_event_id) { - Ok(request) => request, - Err(error) => { - return inspection_sdk_error_view( - receipt_event_id, - CliSdkAdapterError::Sdk(error), - ); - } - }; - match session.block_on( - session - .sdk() - .trades() - .validation_receipts() - .inspect(request), - ) { - Ok(receipt) => { - inspection_from_sdk_receipt(receipt_event_id, success_state, intent, receipt) - } - Err(error) => { - inspection_sdk_error_view(receipt_event_id, CliSdkAdapterError::Sdk(error)) - } - } - } - ValidationReceiptCommandIntent::Verify => { - let request = match TradeValidationReceiptVerifyRequest::parse(receipt_event_id) { - Ok(request) => request, - Err(error) => { - return inspection_sdk_error_view( - receipt_event_id, - CliSdkAdapterError::Sdk(error), - ); - } - }; - match session.block_on(session.sdk().trades().validation_receipts().verify(request)) { - Ok(receipt) => { - inspection_from_sdk_receipt(receipt_event_id, success_state, intent, receipt) - } - Err(error) => { - inspection_sdk_error_view(receipt_event_id, CliSdkAdapterError::Sdk(error)) - } - } - } - } -} - -fn inspection_from_sdk_receipt( - receipt_event_id: &str, - success_state: &str, - intent: ValidationReceiptCommandIntent, - sdk_receipt: TradeValidationReceiptInspectReceipt, -) -> ValidationReceiptInspectionView { - let target_transport_endpoints = sdk_receipt.nostr_relay_urls; - let attempted_transport_endpoints = - attempted_transport_endpoints(&sdk_receipt.nostr_evidence.nostr_relay_outcomes); - let failed_transport_targets = - sdk_relay_failures(&sdk_receipt.nostr_evidence.nostr_relay_outcomes); - let reason_code = - (!failed_transport_targets.is_empty()).then_some("nostr_fetch_partial".to_owned()); - if let Some(invalid) = sdk_receipt.invalid_receipt { - return invalid_inspected_event_view( - invalid, - target_transport_endpoints, - attempted_transport_endpoints, - failed_transport_targets, - ); - } - let Some(receipt) = sdk_receipt.receipt 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_transport_endpoints, - attempted_transport_endpoints, - failed_transport_targets, - reason_code: Some("validation_receipt_not_found".to_owned()), - reason: Some(format!( - "validation receipt event `{receipt_event_id}` was not found on configured Nostr relays" - )), - sdk_error: None, - actions: Vec::new(), - }; - }; - inspected_event_view( - receipt, - success_state, - intent, - target_transport_endpoints, - attempted_transport_endpoints, - failed_transport_targets, - reason_code, - ) + unsupported_validation_receipt_view(args, "verify") } -fn inspected_event_view( - sdk_receipt: TradeValidationReceiptEvent, - success_state: &str, - intent: ValidationReceiptCommandIntent, - target_transport_endpoints: Vec<String>, - attempted_transport_endpoints: Vec<String>, - failed_transport_targets: Vec<TransportTargetFailureView>, - relay_reason_code: Option<String>, -) -> ValidationReceiptInspectionView { - let event_id = sdk_receipt.event.id_str().to_owned(); - let order_id = sdk_receipt.tags.order_id.clone(); - let proof_verification = proof_verification_view_for_receipt( - &sdk_receipt.receipt, - ValidationReceiptValidatorEvidenceSelection::default(), - ); - let accepted = match intent { - ValidationReceiptCommandIntent::Inspect => { - !proof_state_is_invalid(proof_verification.state.as_str()) - } - ValidationReceiptCommandIntent::Verify => { - proof_verification.production_verification - && proof_state_is_verification_success(proof_verification.state.as_str()) - } - }; - if !accepted { - return ValidationReceiptInspectionView { - state: "invalid".to_owned(), - resource: Some(validation_receipt_resource(&event_id)), - receipt_event_id: Some(event_id), - order_id: Some(order_id), - validation_state: "invalid".to_owned(), - proof_verification: Some(proof_verification.clone()), - receipt: Some(sdk_receipt.receipt), - receipt_tags: Some(tags_view(&sdk_receipt.tags)), - event: Some(event_view(sdk_receipt.event)), - target_transport_endpoints, - attempted_transport_endpoints, - failed_transport_targets, - reason_code: proof_verification.reason_code.clone(), - reason: proof_verification.reason.clone(), - sdk_error: None, - actions: Vec::new(), - }; - } - 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(sdk_receipt.receipt), - receipt_tags: Some(tags_view(&sdk_receipt.tags)), - event: Some(event_view(sdk_receipt.event)), - target_transport_endpoints, - attempted_transport_endpoints, - failed_transport_targets, - reason_code: relay_reason_code, - reason: None, - sdk_error: None, - actions: Vec::new(), - } -} - -fn invalid_inspected_event_view( - invalid: TradeValidationReceiptInvalidCandidate, - target_transport_endpoints: Vec<String>, - attempted_transport_endpoints: Vec<String>, - failed_transport_targets: Vec<TransportTargetFailureView>, -) -> ValidationReceiptInspectionView { - ValidationReceiptInspectionView { - state: "invalid".to_owned(), - resource: Some(validation_receipt_resource(invalid.event.id_str())), - receipt_event_id: Some(invalid.event.id_str().to_owned()), - order_id: None, - validation_state: "invalid".to_owned(), - proof_verification: None, - receipt: None, - receipt_tags: None, - event: Some(event_view(invalid.event)), - target_transport_endpoints, - attempted_transport_endpoints, - failed_transport_targets, - reason_code: Some(invalid.reason_code), - reason: Some(invalid.reason), - sdk_error: None, - actions: Vec::new(), - } -} - -fn list_from_sdk_receipt( - sdk_receipt: TradeValidationReceiptListReceipt, -) -> ValidationReceiptListView { - let target_transport_endpoints = sdk_receipt.nostr_relay_urls; - let attempted_transport_endpoints = - attempted_transport_endpoints(&sdk_receipt.nostr_evidence.nostr_relay_outcomes); - let failed_transport_targets = - sdk_relay_failures(&sdk_receipt.nostr_evidence.nostr_relay_outcomes); - let mut invalid_receipts = sdk_receipt - .invalid_receipts - .into_iter() - .map(invalid_candidate_view) - .collect::<Vec<_>>(); - let mut receipts = Vec::new(); - for sdk_event in sdk_receipt.receipts { - let proof_verification = proof_verification_view_for_receipt( - &sdk_event.receipt, - ValidationReceiptValidatorEvidenceSelection::default(), - ); - if proof_state_is_invalid(proof_verification.state.as_str()) { - invalid_receipts.push(ValidationReceiptInvalidCandidateView { - receipt_event_id: sdk_event.event.id_str().to_owned(), - kind: sdk_event.event.kind_u32(), - reason_code: proof_verification - .reason_code - .clone() - .unwrap_or_else(|| proof_verification.state.clone()), - reason: proof_verification.reason.clone().unwrap_or_else(|| { - "validation receipt proof material did not verify".to_owned() - }), - proof_verification: Some(proof_verification), - }); - } else { - receipts.push(summary_view( - &sdk_event.event, - &sdk_event.receipt, - &sdk_event.tags, - &proof_verification, - )); - } - } - - 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_transport_targets.is_empty() { - Some("nostr_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: sdk_receipt.order_id.as_str().to_owned(), - count: valid_count + invalid_count, - valid_count, - invalid_count, - receipts, - invalid_receipts, - target_transport_endpoints, - attempted_transport_endpoints, - failed_transport_targets, - reason_code, - reason, - sdk_error: None, - actions: Vec::new(), - } -} - -fn inspection_sdk_error_view( - receipt_event_id: &str, - error: CliSdkAdapterError, -) -> ValidationReceiptInspectionView { - let mapped = validation_receipt_sdk_error_parts(error); - ValidationReceiptInspectionView { - state: mapped.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_transport_endpoints: Vec::new(), - attempted_transport_endpoints: Vec::new(), - failed_transport_targets: Vec::new(), - reason_code: Some(mapped.reason_code), - reason: Some(mapped.reason), - sdk_error: mapped.sdk_error, - actions: mapped.actions, - } -} - -fn list_sdk_error_view(order_id: &str, error: CliSdkAdapterError) -> ValidationReceiptListView { - let mapped = validation_receipt_sdk_error_parts(error); - ValidationReceiptListView { - state: mapped.state, - order_id: order_id.to_owned(), - count: 0, - valid_count: 0, - invalid_count: 0, - receipts: Vec::new(), - invalid_receipts: Vec::new(), - target_transport_endpoints: Vec::new(), - attempted_transport_endpoints: Vec::new(), - failed_transport_targets: Vec::new(), - reason_code: Some(mapped.reason_code), - reason: Some(mapped.reason), - sdk_error: mapped.sdk_error, - actions: mapped.actions, - } -} - -struct ValidationReceiptSdkErrorParts { - state: String, - reason_code: String, - reason: String, - sdk_error: Option<Value>, - actions: Vec<String>, -} - -fn validation_receipt_sdk_error_parts(error: CliSdkAdapterError) -> ValidationReceiptSdkErrorParts { - match error { - CliSdkAdapterError::Sdk(error) => sdk_error_parts(error), - CliSdkAdapterError::Runtime(error) => ValidationReceiptSdkErrorParts { - state: "network_unavailable".to_owned(), - reason_code: "sdk_runtime_failed".to_owned(), - reason: error.to_string(), - sdk_error: None, - actions: Vec::new(), - }, - } -} - -fn sdk_error_parts(error: RadrootsSdkError) -> ValidationReceiptSdkErrorParts { - let state = match error.code() { - "empty_transport_targets" => "unconfigured", - _ => match error.class() { - radroots_sdk::RadrootsSdkErrorClass::Configuration - | radroots_sdk::RadrootsSdkErrorClass::Unsupported => "unconfigured", - radroots_sdk::RadrootsSdkErrorClass::Request => "invalid", - radroots_sdk::RadrootsSdkErrorClass::Transport - | radroots_sdk::RadrootsSdkErrorClass::Storage - | radroots_sdk::RadrootsSdkErrorClass::Clock - | radroots_sdk::RadrootsSdkErrorClass::Authorization => "network_unavailable", - _ => "network_unavailable", - }, - }; - let actions = if error.code() == "empty_transport_targets" { - vec![ - "radroots transport config update --kind nostr --nostr-relay wss://relay.example.com" - .to_owned(), - ] - } else { - Vec::new() - }; - ValidationReceiptSdkErrorParts { - state: state.to_owned(), - reason_code: error.code().to_owned(), - reason: error.to_string(), - sdk_error: Some(error.detail_json()), - actions, - } -} - -fn invalid_inspection_view( - receipt_event_id: Option<String>, - reason_code: &str, - reason: impl Into<String>, +fn unsupported_validation_receipt_view( + args: &ValidationReceiptEventArgs, + intent: &str, ) -> ValidationReceiptInspectionView { + let receipt_event_id = args.receipt_event_id.trim().to_owned(); 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(), + state: "unsupported".to_owned(), + resource: Some(ValidationReceiptResourceView { + kind: "validation_receipt".to_owned(), + id: receipt_event_id.clone(), + }), + receipt_event_id: Some(receipt_event_id), + validation_state: "not_applicable".to_owned(), proof_verification: None, receipt: None, receipt_tags: None, @@ -667,937 +82,14 @@ fn invalid_inspection_view( target_transport_endpoints: Vec::new(), attempted_transport_endpoints: Vec::new(), failed_transport_targets: Vec::new(), - reason_code: Some(reason_code.to_owned()), - reason: Some(reason.into()), - sdk_error: None, - 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_transport_endpoints: Vec::new(), - attempted_transport_endpoints: Vec::new(), - failed_transport_targets: Vec::new(), - reason_code: Some(reason_code.to_owned()), - reason: Some(reason.into()), + reason_code: Some("validation_receipts_not_agreement_authority".to_owned()), + reason: Some(format!( + "validation receipt {intent} is not part of the release-product V1 trade agreement authority" + )), sdk_error: None, - 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_event::RadrootsEventEnvelope) -> ValidationReceiptEventView { - ValidationReceiptEventView { - id: event.id_str().to_owned(), - author: event.author_str().to_owned(), - created_at: event.created_at_u64(), - kind: event.kind_u32(), - sig: event.sig_str().to_owned(), - tags: event.tags_as_vec(), - content: event.content().to_owned(), - } -} - -fn tags_view(tags: &TradeValidationReceiptTags) -> ValidationReceiptTagsView { - ValidationReceiptTagsView { - order_id: tags.order_id.clone(), - event_set_root: tags.event_set_root.clone(), - listing_event_id: tags.listing_event_id.clone(), - reducer_output_root: tags.reducer_output_root.clone(), - public_values_hash: tags.public_values_hash.clone(), - proof_system: tags.proof_system.clone(), - receipt_type: tags.receipt_type.clone(), - root_event_id: tags.root_event_id.clone(), - target_event_id: tags.target_event_id.clone(), - } -} - -struct ValidationReceiptTrustSummary { - state: &'static str, - validation_authority: Option<String>, - commitment_confidence: Option<String>, - production_verification: bool, - reason_code: Option<&'static str>, - reason: Option<&'static str>, -} - -fn local_only_trust_summary() -> ValidationReceiptTrustSummary { - ValidationReceiptTrustSummary { - state: "validator_set_deterministic_receipt", - validation_authority: Some( - RadrootsTradeValidationAuthority::ValidatorSetDeterministic - .as_str() - .to_owned(), - ), - commitment_confidence: Some( - RadrootsTradeCommitmentConfidence::CommittedByValidatorSet - .as_str() - .to_owned(), - ), - production_verification: false, - reason_code: None, - reason: None, - } -} - -fn sp1_execute_checked_trust_summary() -> ValidationReceiptTrustSummary { - ValidationReceiptTrustSummary { - state: "sp1_execute_checked", - validation_authority: None, - commitment_confidence: None, - production_verification: false, - reason_code: Some("validation_receipt_trust_metadata_missing"), - reason: Some( - "validator evidence reports SP1 execution but omits validation authority or commitment confidence", - ), - } -} - -fn missing_trust_metadata_summary() -> ValidationReceiptTrustSummary { - ValidationReceiptTrustSummary { - state: "validator_evidence_trust_metadata_missing", - validation_authority: None, - commitment_confidence: None, - production_verification: false, - reason_code: Some("validation_receipt_trust_metadata_missing"), - reason: Some("validator evidence omits validation authority or commitment confidence"), - } -} - -fn mismatched_trust_metadata_summary() -> ValidationReceiptTrustSummary { - ValidationReceiptTrustSummary { - state: "validator_evidence_trust_metadata_mismatch", - validation_authority: None, - commitment_confidence: None, - production_verification: false, - reason_code: Some("validation_receipt_trust_metadata_mismatch"), - reason: Some( - "validator evidence validation authority does not match commitment confidence", - ), - } -} - -fn invalid_validator_evidence_summary() -> ValidationReceiptTrustSummary { - ValidationReceiptTrustSummary { - state: "validation_receipt_validator_evidence_invalid", - validation_authority: None, - commitment_confidence: Some( - RadrootsTradeCommitmentConfidence::Invalid - .as_str() - .to_owned(), - ), - production_verification: false, - reason_code: Some("validation_receipt_validator_evidence_invalid"), - reason: Some("validator evidence marks the validation receipt invalid"), - } -} - -fn trusted_validator_summary( - state: &'static str, - authority: &str, - confidence: &str, - production_verification: bool, -) -> ValidationReceiptTrustSummary { - ValidationReceiptTrustSummary { - state, - validation_authority: Some(authority.to_owned()), - commitment_confidence: Some(confidence.to_owned()), - production_verification, - reason_code: None, - reason: None, - } -} - -fn none_proof_trust_summary( - validator_evidence: &ValidationReceiptValidatorEvidenceSelection, -) -> ValidationReceiptTrustSummary { - let Some(evidence) = validator_evidence.trusted.as_ref() else { - return local_only_trust_summary(); - }; - let authority = evidence.validation_authority.as_deref(); - let confidence = evidence.commitment_confidence.as_deref(); - if authority.is_none() && confidence.is_none() { - return if evidence.sp1_execute_checked { - sp1_execute_checked_trust_summary() - } else { - local_only_trust_summary() - }; - } - let (Some(authority), Some(confidence)) = (authority, confidence) else { - return missing_trust_metadata_summary(); - }; - match (authority, confidence) { - ("validator_set_deterministic", "awaiting_validation") => { - trusted_validator_summary("pending_validator_validation", authority, confidence, false) - } - ("validator_set_deterministic", "committed_by_validator_set") => { - trusted_validator_summary("validator_set_validated", authority, confidence, false) - } - ("cryptographic_proof_verified", "committed_by_cryptographic_proof") => { - trusted_validator_summary("cryptographic_proof_verified", authority, confidence, true) - } - ("validator_set_and_proof_verified", "committed_by_validator_set_and_proof") => { - trusted_validator_summary( - "validator_set_and_proof_verified", - authority, - confidence, - true, - ) - } - (_, "invalid") => invalid_validator_evidence_summary(), - _ => mismatched_trust_metadata_summary(), - } -} - -fn verified_sp1_trust_summary( - validator_evidence: &ValidationReceiptValidatorEvidenceSelection, -) -> ValidationReceiptTrustSummary { - if let Some(evidence) = validator_evidence.trusted.as_ref() - && evidence.validation_authority.as_deref() == Some("validator_set_and_proof_verified") - && evidence.commitment_confidence.as_deref() == Some("committed_by_validator_set_and_proof") - { - return trusted_validator_summary( - "validator_set_and_proof_verified", - "validator_set_and_proof_verified", - "committed_by_validator_set_and_proof", - true, - ); - } - trusted_validator_summary( - "sp1_inline_proof_verified", - "cryptographic_proof_verified", - "committed_by_cryptographic_proof", - true, - ) -} - -fn proof_verification_view_for_receipt( - receipt: &RadrootsTradeValidationReceipt, - validator_evidence: ValidationReceiptValidatorEvidenceSelection, -) -> ValidationReceiptProofVerificationView { - let proof = &receipt.proof; - let cryptographic_proof_required = proof.system != RadrootsValidationReceiptProofSystem::None; - if proof.system == RadrootsValidationReceiptProofSystem::None { - let trust = none_proof_trust_summary(&validator_evidence); - return ValidationReceiptProofVerificationView { - state: trust.state.to_owned(), - verifier: "radroots_cli_validation_receipt_v1".to_owned(), - proof_system: proof.system.as_str().to_owned(), - validation_authority: trust.validation_authority, - commitment_confidence: trust.commitment_confidence, - production_verification: trust.production_verification, - public_values_hash_binding: "verified".to_owned(), - proof_metadata_binding: "not_required".to_owned(), - cryptographic_proof_required, - cryptographic_proof_verified: false, - 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(), - validator_evidence: validator_evidence.trusted, - untrusted_validator_evidence: validator_evidence.untrusted, - reason_code: trust.reason_code.map(str::to_owned), - reason: trust.reason.map(str::to_owned), - }; - } - if proof.proof_reference.is_some() { - return sp1_unverified_proof_view( - receipt, - validator_evidence, - "sp1_reference_unresolved", - "unverified", - "reference_unresolved", - Some("sp1_reference_unresolved"), - Some("SP1 proof reference resolution is not implemented by this CLI"), - ); - } - if proof.inline_proof_base64.is_none() { - return sp1_unverified_proof_view( - receipt, - validator_evidence, - "sp1_proof_material_missing", - "unverified", - "missing_proof_material", - Some("sp1_proof_material_missing"), - Some("SP1 proof material is missing"), - ); - } - if proof.system != RadrootsValidationReceiptProofSystem::Sp1Core { - return sp1_unverified_proof_view( - receipt, - validator_evidence, - "sp1_metadata_consistent_unverified", - "unverified", - "metadata_consistent_unverified", - Some("sp1_inline_proof_verification_unsupported"), - Some("only inline sp1_core proof verification is active in this CLI"), - ); - } - - match verify_inline_sp1_receipt(receipt) { - Ok(()) => { - let trust = verified_sp1_trust_summary(&validator_evidence); - ValidationReceiptProofVerificationView { - state: trust.state.to_owned(), - verifier: "radroots_cli_validation_receipt_v1".to_owned(), - proof_system: proof.system.as_str().to_owned(), - validation_authority: trust.validation_authority, - commitment_confidence: trust.commitment_confidence, - production_verification: trust.production_verification, - public_values_hash_binding: "verified".to_owned(), - proof_metadata_binding: "verified".to_owned(), - cryptographic_proof_required, - cryptographic_proof_verified: true, - 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(), - validator_evidence: validator_evidence.trusted, - untrusted_validator_evidence: validator_evidence.untrusted, - reason_code: trust.reason_code.map(str::to_owned), - reason: trust.reason.map(str::to_owned), - } - } - Err(error) => { - let mapped = proof_state_from_sp1_error(&error); - let reason = error.to_string(); - sp1_unverified_proof_view( - receipt, - validator_evidence, - mapped.state, - mapped.public_values_hash_binding, - mapped.proof_metadata_binding, - Some(mapped.reason_code), - Some(reason.as_str()), - ) - } - } -} - -fn sp1_unverified_proof_view( - receipt: &RadrootsTradeValidationReceipt, - validator_evidence: ValidationReceiptValidatorEvidenceSelection, - state: &str, - public_values_hash_binding: &str, - proof_metadata_binding: &str, - reason_code: Option<&str>, - reason: Option<&str>, -) -> ValidationReceiptProofVerificationView { - let proof = &receipt.proof; - ValidationReceiptProofVerificationView { - state: state.to_owned(), - verifier: "radroots_cli_validation_receipt_v1".to_owned(), - proof_system: proof.system.as_str().to_owned(), - validation_authority: None, - commitment_confidence: None, - production_verification: false, - public_values_hash_binding: public_values_hash_binding.to_owned(), - proof_metadata_binding: proof_metadata_binding.to_owned(), - cryptographic_proof_required: proof.system != RadrootsValidationReceiptProofSystem::None, - cryptographic_proof_verified: false, - 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(), - validator_evidence: validator_evidence.trusted, - untrusted_validator_evidence: validator_evidence.untrusted, - reason_code: reason_code.map(str::to_owned), - reason: reason.map(str::to_owned), - } -} - -#[cfg(test)] -fn validation_receipt_invalid_reason_code( - error: &radroots_trade::validation_receipt::RadrootsValidationReceiptError, -) -> &'static str { - use radroots_trade::validation_receipt::RadrootsValidationReceiptError; - - match error { - RadrootsValidationReceiptError::InvalidProofMetadata("proof.material") - | RadrootsValidationReceiptError::InvalidProofMetadata("proof.material_missing") => { - "sp1_proof_material_missing" - } - RadrootsValidationReceiptError::InvalidProofMetadata("proof.material_conflict") => { - "sp1_proof_material_conflict" - } - RadrootsValidationReceiptError::InvalidProofMetadata("proof.inline_proof_base64") => { - "sp1_inline_proof_invalid" - } - RadrootsValidationReceiptError::InvalidProofMetadata("proof.proof_reference") => { - "sp1_proof_reference_invalid" - } - RadrootsValidationReceiptError::TagMismatch("public_values_hash") => { - "public_values_hash_mismatch" - } - RadrootsValidationReceiptError::ExpectedBindingMismatch("public_values_hash") => { - "public_values_hash_mismatch" - } - RadrootsValidationReceiptError::ExpectedBindingMismatch("program_hash") => { - "sp1_program_hash_mismatch" - } - RadrootsValidationReceiptError::ExpectedBindingMismatch("verifying_key_hash") => { - "sp1_verifying_key_hash_mismatch" - } - _ => "validation_receipt_invalid", - } -} - -struct MappedSp1ProofError { - state: &'static str, - public_values_hash_binding: &'static str, - proof_metadata_binding: &'static str, - reason_code: &'static str, -} - -fn proof_state_from_sp1_error(error: &RadrootsSp1TradeHostError) -> MappedSp1ProofError { - match error { - RadrootsSp1TradeHostError::Sp1ProofReferenceUnresolved => MappedSp1ProofError { - state: "sp1_reference_unresolved", - public_values_hash_binding: "unverified", - proof_metadata_binding: "reference_unresolved", - reason_code: "sp1_reference_unresolved", - }, - RadrootsSp1TradeHostError::MissingProofMaterial => MappedSp1ProofError { - state: "sp1_proof_material_missing", - public_values_hash_binding: "unverified", - proof_metadata_binding: "missing_proof_material", - reason_code: "sp1_proof_material_missing", - }, - RadrootsSp1TradeHostError::ProofMaterialConflict => MappedSp1ProofError { - state: "sp1_proof_material_conflict", - public_values_hash_binding: "unverified", - proof_metadata_binding: "conflicting_proof_material", - reason_code: "sp1_proof_material_conflict", - }, - RadrootsSp1TradeHostError::PublicValuesHashMismatch - | RadrootsSp1TradeHostError::Sp1PublicValuesMismatch - | RadrootsSp1TradeHostError::ValidationReceiptBindingMismatch(_) => MappedSp1ProofError { - state: "sp1_public_values_mismatch", - public_values_hash_binding: "mismatch", - proof_metadata_binding: "verified", - reason_code: "sp1_public_values_mismatch", - }, - RadrootsSp1TradeHostError::Sp1ProgramHashMismatch - | RadrootsSp1TradeHostError::MissingSp1ProgramHash => MappedSp1ProofError { - state: "sp1_program_hash_mismatch", - public_values_hash_binding: "unverified", - proof_metadata_binding: "mismatch", - reason_code: "sp1_program_hash_mismatch", - }, - RadrootsSp1TradeHostError::Sp1VerifyingKeyHashMismatch - | RadrootsSp1TradeHostError::MissingVerifyingKeyHash => MappedSp1ProofError { - state: "sp1_verifying_key_hash_mismatch", - public_values_hash_binding: "unverified", - proof_metadata_binding: "mismatch", - reason_code: "sp1_verifying_key_hash_mismatch", - }, - RadrootsSp1TradeHostError::Sp1ProofVerifierUnavailable => MappedSp1ProofError { - state: "sp1_verifier_unavailable", - public_values_hash_binding: "unverified", - proof_metadata_binding: "verifier_unavailable", - reason_code: "sp1_verifier_unavailable", - }, - RadrootsSp1TradeHostError::Sp1SetupFailed(_) => MappedSp1ProofError { - state: "sp1_verifier_setup_failed", - public_values_hash_binding: "unverified", - proof_metadata_binding: "verifier_setup_failed", - reason_code: "sp1_verifier_setup_failed", - }, - _ => MappedSp1ProofError { - state: "sp1_proof_invalid", - public_values_hash_binding: "unverified", - proof_metadata_binding: "invalid", - reason_code: "sp1_proof_invalid", - }, - } -} - -fn verify_inline_sp1_receipt( - receipt: &RadrootsTradeValidationReceipt, -) -> Result<(), RadrootsSp1TradeHostError> { - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .map_err(|error| RadrootsSp1TradeHostError::Sp1SetupFailed(error.to_string()))?; - runtime - .block_on(verify_order_acceptance_validation_receipt_inline_sp1_proof( - receipt, - )) - .map(|_| ()) -} - -fn proof_state_is_invalid(state: &str) -> bool { - matches!( - state, - "sp1_proof_material_missing" - | "sp1_proof_material_conflict" - | "sp1_public_values_mismatch" - | "sp1_program_hash_mismatch" - | "sp1_verifying_key_hash_mismatch" - | "sp1_proof_invalid" - | "validation_receipt_validator_evidence_invalid" - | "validator_evidence_trust_metadata_mismatch" - ) -} - -fn proof_state_is_verification_success(state: &str) -> bool { - matches!( - state, - "validator_set_validated" - | "cryptographic_proof_verified" - | "validator_set_and_proof_verified" - | "sp1_inline_proof_verified" - ) -} - -fn invalid_candidate_view( - candidate: TradeValidationReceiptInvalidCandidate, -) -> ValidationReceiptInvalidCandidateView { - ValidationReceiptInvalidCandidateView { - receipt_event_id: candidate.event.id_str().to_owned(), - kind: candidate.event.kind_u32(), - reason_code: candidate.reason_code, - reason: candidate.reason, - proof_verification: None, - } -} - -fn attempted_transport_endpoints( - relays: &[TradeValidationReceiptNostrRelayOutcomeReceipt], -) -> Vec<String> { - relays - .iter() - .filter(|relay| relay.outcome_kind == TradeValidationReceiptNostrRelayOutcomeKind::Eose) - .map(|relay| relay.nostr_relay_url.clone()) - .collect() -} - -fn sdk_relay_failures( - relays: &[TradeValidationReceiptNostrRelayOutcomeReceipt], -) -> Vec<TransportTargetFailureView> { - relays - .iter() - .filter(|relay| relay.outcome_kind != TradeValidationReceiptNostrRelayOutcomeKind::Eose) - .map(|relay| TransportTargetFailureView { - transport_kind: "nostr".to_owned(), - endpoint_uri: relay.nostr_relay_url.clone(), - target_scope: None, - target_label: None, - transport_outcome_kind: relay - .transport_outcome_kind - .map(sdk_validation_transport_outcome_kind_label), - reason: relay - .message - .clone() - .unwrap_or_else(|| sdk_validation_relay_outcome_kind_label(relay.outcome_kind)), - }) - .collect() -} - -fn summary_view( - event: &radroots_event::RadrootsEventEnvelope, - receipt: &RadrootsTradeValidationReceipt, - tags: &TradeValidationReceiptTags, - proof_verification: &ValidationReceiptProofVerificationView, -) -> ValidationReceiptSummaryView { - ValidationReceiptSummaryView { - resource: validation_receipt_resource(event.id_str()), - receipt_event_id: event.id_str().to_owned(), - order_id: tags.order_id.clone(), - author: event.author_str().to_owned(), - created_at: event.created_at_u64(), - 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.clone(), - validation_authority: proof_verification.validation_authority.clone(), - commitment_confidence: proof_verification.commitment_confidence.clone(), - production_verification: proof_verification.production_verification, - 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", - } -} - -#[cfg(test)] -mod tests { - use super::{ - ValidationReceiptValidatorEvidenceSelection, ValidationReceiptValidatorEvidenceView, - proof_state_from_sp1_error, proof_state_is_invalid, proof_state_is_verification_success, - proof_verification_view_for_receipt, validation_receipt_invalid_reason_code, - }; - use radroots_event::ids::{RadrootsAddressableCoordinate, RadrootsPublicKey}; - use radroots_trade::validation_receipt::{ - RadrootsTradeValidationReceipt, RadrootsValidationReceiptError, - RadrootsValidationReceiptProof, RadrootsValidationReceiptProofSystem, - RadrootsValidationReceiptResult, RadrootsValidationReceiptStatement, - RadrootsValidationReceiptType, VALIDATION_RECEIPT_DOMAIN, VALIDATION_RECEIPT_VERSION, - validator_set_address, - }; - use radroots_trade_sp1_host::RadrootsSp1TradeHostError; - - fn sp1_proof_with_material() -> RadrootsValidationReceiptProof { - RadrootsValidationReceiptProof { - inline_proof_base64: Some("cHJvb2Y=".to_owned()), - mode: Some("core".to_owned()), - program_hash: Some( - "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned(), - ), - proof_reference: None, - system: RadrootsValidationReceiptProofSystem::Sp1Core, - verifying_key_hash: Some( - "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_owned(), - ), - } - } - - fn receipt_with_proof(proof: RadrootsValidationReceiptProof) -> RadrootsTradeValidationReceipt { - RadrootsTradeValidationReceipt { - changed_records_root: - "0x1111111111111111111111111111111111111111111111111111111111111111".to_owned(), - domain: VALIDATION_RECEIPT_DOMAIN.to_owned(), - error_bitmap: "0x00000000000000000000000000000000".to_owned(), - event_set_root: "0x2222222222222222222222222222222222222222222222222222222222222222" - .to_owned(), - new_state_root: "0x3333333333333333333333333333333333333333333333333333333333333333" - .to_owned(), - previous_state_root: - "0x4444444444444444444444444444444444444444444444444444444444444444".to_owned(), - proof, - public_values_hash: - "0x5555555555555555555555555555555555555555555555555555555555555555".to_owned(), - receipt_type: RadrootsValidationReceiptType::TradeTransition, - result: RadrootsValidationReceiptResult::Valid, - statement: RadrootsValidationReceiptStatement { - listing_event_id: - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned(), - root_event_id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - .to_owned(), - target_event_id: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" - .to_owned(), - validator_set_addr: validator_set_addr(), - validator_set_event_id: - "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd".to_owned(), - statement_type: RadrootsValidationReceiptType::TradeTransition, - }, - version: VALIDATION_RECEIPT_VERSION, - } - } - - fn validator_set_addr() -> RadrootsAddressableCoordinate { - let author = RadrootsPublicKey::parse( - "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - ) - .expect("validator set author"); - validator_set_address(&author, "018f3d99-7d35-7c0c-8a0f-7f3b645abcde") - .expect("validator set address") - } - - fn deterministic_receipt() -> RadrootsTradeValidationReceipt { - receipt_with_proof(RadrootsValidationReceiptProof { - inline_proof_base64: None, - mode: None, - program_hash: None, - proof_reference: None, - system: RadrootsValidationReceiptProofSystem::None, - verifying_key_hash: None, - }) - } - - fn validator_evidence( - validation_authority: Option<&str>, - commitment_confidence: Option<&str>, - sp1_execute_checked: bool, - ) -> ValidationReceiptValidatorEvidenceView { - ValidationReceiptValidatorEvidenceView { - result_event_id: "result-1".to_owned(), - author: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned(), - validation_authority: validation_authority.map(str::to_owned), - commitment_confidence: commitment_confidence.map(str::to_owned), - status: "succeeded".to_owned(), - prover_backend: "local_execute".to_owned(), - proof_mode: "none".to_owned(), - proof_system: "none".to_owned(), - proof_generated: false, - sp1_execute_checked, - sp1_execute_public_values_hash: sp1_execute_checked.then(|| { - "0x5555555555555555555555555555555555555555555555555555555555555555".to_owned() - }), - cryptographic_proof_verified: false, - public_values_hash: - "0x5555555555555555555555555555555555555555555555555555555555555555".to_owned(), - } - } - - #[test] - fn none_receipts_report_local_only_without_crypto_claim() { - let view = proof_verification_view_for_receipt( - &deterministic_receipt(), - ValidationReceiptValidatorEvidenceSelection::default(), - ); - - assert_eq!(view.state, "validator_set_deterministic_receipt"); - assert_eq!( - view.validation_authority.as_deref(), - Some("validator_set_deterministic") - ); - assert_eq!( - view.commitment_confidence.as_deref(), - Some("committed_by_validator_set") - ); - assert!(!view.production_verification); - assert!(!view.cryptographic_proof_required); - assert!(!view.cryptographic_proof_verified); - } - - #[test] - fn none_receipts_surface_advisory_sp1_execute_evidence() { - let view = proof_verification_view_for_receipt( - &deterministic_receipt(), - ValidationReceiptValidatorEvidenceSelection { - trusted: Some(validator_evidence(None, None, true)), - untrusted: None, - }, - ); - - assert_eq!(view.state, "sp1_execute_checked"); - assert_eq!( - view.reason_code.as_deref(), - Some("validation_receipt_trust_metadata_missing") - ); - assert!(!view.production_verification); - assert!(!view.cryptographic_proof_required); - assert!(!view.cryptographic_proof_verified); - } - - #[test] - fn none_receipts_surface_validator_set_confidence_without_production_verification() { - let view = proof_verification_view_for_receipt( - &deterministic_receipt(), - ValidationReceiptValidatorEvidenceSelection { - trusted: Some(validator_evidence( - Some("validator_set_deterministic"), - Some("committed_by_validator_set"), - false, - )), - untrusted: None, - }, - ); - - assert_eq!(view.state, "validator_set_validated"); - assert_eq!( - view.validation_authority.as_deref(), - Some("validator_set_deterministic") - ); - assert_eq!( - view.commitment_confidence.as_deref(), - Some("committed_by_validator_set") - ); - assert!(!view.production_verification); - assert!(proof_state_is_verification_success(view.state.as_str())); - } - - #[test] - fn invalid_validator_evidence_marks_receipt_invalid() { - let view = proof_verification_view_for_receipt( - &deterministic_receipt(), - ValidationReceiptValidatorEvidenceSelection { - trusted: Some(validator_evidence( - Some("validator_set_deterministic"), - Some("invalid"), - false, - )), - untrusted: None, - }, - ); - - assert_eq!(view.state, "validation_receipt_validator_evidence_invalid"); - assert!(proof_state_is_invalid(view.state.as_str())); - assert!(!view.production_verification); - } - - #[test] - fn untrusted_validator_evidence_does_not_upgrade_deterministic_receipts() { - let view = proof_verification_view_for_receipt( - &deterministic_receipt(), - ValidationReceiptValidatorEvidenceSelection { - trusted: None, - untrusted: Some(validator_evidence( - Some("validator_set_deterministic"), - Some("committed_by_validator_set"), - true, - )), - }, - ); - - assert_eq!(view.state, "validator_set_deterministic_receipt"); - assert!(!view.production_verification); - assert!(view.validator_evidence.is_none()); - assert!(view.untrusted_validator_evidence.is_some()); - } - - #[test] - fn validation_success_labels_exclude_local_only_and_sp1_execute_checked() { - assert!(!proof_state_is_verification_success( - "validator_set_deterministic_receipt" - )); - assert!(!proof_state_is_verification_success("sp1_execute_checked")); - assert!(proof_state_is_verification_success( - "validator_set_validated" - )); - assert!(proof_state_is_verification_success( - "sp1_inline_proof_verified" - )); - assert!(proof_state_is_verification_success( - "validator_set_and_proof_verified" - )); - } - - #[test] - fn sp1_receipts_with_references_report_unresolved_without_crypto_claim() { - let mut receipt = receipt_with_proof(sp1_proof_with_material()); - receipt.proof.inline_proof_base64 = None; - receipt.proof.proof_reference = Some(format!("radroots-proof://sha256/{}", "1".repeat(64))); - - let view = proof_verification_view_for_receipt( - &receipt, - ValidationReceiptValidatorEvidenceSelection::default(), - ); - - assert_eq!(view.state, "sp1_reference_unresolved"); - assert!(view.cryptographic_proof_required); - assert!(!view.cryptographic_proof_verified); - assert_eq!(view.proof_metadata_binding, "reference_unresolved"); - } - - #[cfg(feature = "sp1-verify")] - #[test] - fn invalid_inline_sp1_material_reports_invalid_proof_state() { - let view = proof_verification_view_for_receipt( - &receipt_with_proof(sp1_proof_with_material()), - ValidationReceiptValidatorEvidenceSelection::default(), - ); - - assert_eq!(view.state, "sp1_proof_invalid"); - assert!(view.cryptographic_proof_required); - assert!(!view.cryptographic_proof_verified); - assert_eq!(view.reason_code.as_deref(), Some("sp1_proof_invalid")); - } - - #[cfg(not(feature = "sp1-verify"))] - #[test] - fn inline_sp1_material_reports_unavailable_verifier_without_sp1_verify_feature() { - let view = proof_verification_view_for_receipt( - &receipt_with_proof(sp1_proof_with_material()), - ValidationReceiptValidatorEvidenceSelection::default(), - ); - - assert_eq!(view.state, "sp1_verifier_unavailable"); - assert!(view.cryptographic_proof_required); - assert!(!view.cryptographic_proof_verified); - assert_eq!(view.public_values_hash_binding, "unverified"); - assert_eq!(view.proof_metadata_binding, "verifier_unavailable"); - assert_eq!( - view.reason_code.as_deref(), - Some("sp1_verifier_unavailable") - ); - assert!(!proof_state_is_invalid(view.state.as_str())); - } - - #[test] - fn sp1_setup_failed_reports_verifier_setup_failure_without_invalid_proof_state() { - let mapped = proof_state_from_sp1_error(&RadrootsSp1TradeHostError::Sp1SetupFailed( - "runtime unavailable".to_owned(), - )); - - assert_eq!(mapped.state, "sp1_verifier_setup_failed"); - assert_eq!(mapped.public_values_hash_binding, "unverified"); - assert_eq!(mapped.proof_metadata_binding, "verifier_setup_failed"); - assert_eq!(mapped.reason_code, "sp1_verifier_setup_failed"); - assert!(!proof_state_is_invalid(mapped.state)); - } - - #[test] - fn invalid_receipt_errors_get_specific_reason_codes() { - assert_eq!( - validation_receipt_invalid_reason_code( - &RadrootsValidationReceiptError::InvalidProofMetadata("proof.material") - ), - "sp1_proof_material_missing" - ); - assert_eq!( - validation_receipt_invalid_reason_code( - &RadrootsValidationReceiptError::InvalidProofMetadata("proof.material_missing") - ), - "sp1_proof_material_missing" - ); - assert_eq!( - validation_receipt_invalid_reason_code( - &RadrootsValidationReceiptError::InvalidProofMetadata("proof.material_conflict") - ), - "sp1_proof_material_conflict" - ); - assert_eq!( - validation_receipt_invalid_reason_code(&RadrootsValidationReceiptError::TagMismatch( - "public_values_hash" - )), - "public_values_hash_mismatch" - ); - assert_eq!( - validation_receipt_invalid_reason_code( - &RadrootsValidationReceiptError::InvalidProofMetadata("proof.inline_proof_base64") - ), - "sp1_inline_proof_invalid" - ); - assert_eq!( - validation_receipt_invalid_reason_code( - &RadrootsValidationReceiptError::InvalidProofMetadata("proof.proof_reference") - ), - "sp1_proof_reference_invalid" - ); - assert_eq!( - validation_receipt_invalid_reason_code( - &RadrootsValidationReceiptError::ExpectedBindingMismatch("program_hash") - ), - "sp1_program_hash_mismatch" - ); - assert_eq!( - validation_receipt_invalid_reason_code( - &RadrootsValidationReceiptError::ExpectedBindingMismatch("verifying_key_hash") - ), - "sp1_verifying_key_hash_mismatch" - ); + actions: vec![ + "radroots trade get <trade-id>".to_owned(), + "radroots trade evidence inspect <trade-id>".to_owned(), + ], } } diff --git a/src/view/runtime.rs b/src/view/runtime.rs @@ -4,7 +4,6 @@ use radroots_event::farm::RadrootsFarm; use radroots_event::ids::RadrootsListingAddress; use radroots_event::kinds::KIND_LISTING; use radroots_event::listing::RadrootsListingPublicLocation; -use radroots_event::order::RadrootsOrderEconomics; use radroots_event::profile::RadrootsProfile; use radroots_nostr_accounts::prelude::RadrootsNostrAccountRecord; use serde::Serialize; @@ -1478,848 +1477,6 @@ pub struct JobWatchFrameView { } #[derive(Debug, Clone, Serialize)] -pub struct OrderNewView { - pub state: String, - pub source: String, - #[serde(rename = "trade_id")] - pub order_id: String, - pub file: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_lookup: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_addr: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_event_id: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub listing_relays: Vec<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_account_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_actor_source: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_custody: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_write_capable: Option<bool>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_pubkey: Option<String>, - pub ready_for_submit: bool, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub items: Vec<OrderDraftItemView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub economics: Option<RadrootsOrderEconomics>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub issues: Vec<OrderIssueView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl OrderNewView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderGetView { - pub state: String, - pub source: String, - pub lookup: String, - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(rename = "trade_id")] - pub order_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub file: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_lookup: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_addr: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_event_id: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub listing_relays: Vec<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_account_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_actor_source: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_custody: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_write_capable: Option<bool>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_pubkey: Option<String>, - pub ready_for_submit: bool, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub items: Vec<OrderDraftItemView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub economics: Option<RadrootsOrderEconomics>, - #[serde(skip_serializing_if = "Option::is_none")] - pub updated_at_unix: Option<u64>, - #[serde(skip_serializing_if = "Option::is_none")] - pub job: Option<OrderJobView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub workflow: Option<OrderWorkflowView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub issues: Vec<OrderIssueView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl OrderGetView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderListView { - pub state: String, - pub source: String, - pub count: usize, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - #[serde(rename = "trades")] - pub orders: Vec<OrderSummaryView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl OrderListView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderAppRecordListView { - pub state: String, - pub source: String, - pub count: usize, - pub limit: u32, - pub has_more: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub next_before_change_seq: Option<i64>, - #[serde(skip_serializing_if = "Option::is_none")] - pub next_before_seq: Option<i64>, - pub runtime_store_db: String, - pub records: Vec<OrderAppRecordSummaryView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl OrderAppRecordListView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderAppRecordSummaryView { - pub record_id: String, - pub seq: i64, - pub change_seq: i64, - pub superseded_count: usize, - pub record_kind: String, - pub status: String, - pub source_runtime: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub owner_account_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub owner_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub farm_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_addr: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub listing_relays: Vec<String>, - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(rename = "trade_id")] - pub order_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_account_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_pubkey: Option<String>, - pub ready_for_submit: bool, - pub exportable: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderAppRecordExportView { - pub state: String, - pub source: String, - pub record_id: String, - pub dry_run: bool, - pub file: String, - pub valid: bool, - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(rename = "trade_id")] - pub order_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_addr: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_event_id: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub listing_relays: Vec<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_account_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_actor_source: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_pubkey: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub issues: Vec<OrderIssueView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl OrderAppRecordExportView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "missing" => CommandDisposition::NotFound, - "already_submitted" | "conflict" | "invalid" | "stale" | "unsupported" => { - CommandDisposition::ValidationFailed - } - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderTradeLocatorView { - pub trade_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub root_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_addr: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_pubkey: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderStatusAmbiguityCandidateView { - pub locator: OrderTradeLocatorView, - pub status_selector: String, - pub status_command: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderSubmitView { - pub state: String, - pub source: String, - #[serde(rename = "trade_id")] - pub order_id: String, - pub locator: OrderTradeLocatorView, - pub file: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_lookup: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_addr: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_event_id: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub listing_relays: Vec<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_account_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_actor_source: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_custody: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_write_capable: Option<bool>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_kind: Option<u32>, - #[serde(default)] - pub dry_run: bool, - #[serde(default)] - pub deduplicated: bool, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub target_transport_endpoints: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub attempted_transport_endpoints: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub accepted_transport_endpoints: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub failed_transport_targets: Vec<TransportTargetFailureView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub idempotency_key: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub signer_mode: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub job: Option<OrderJobView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub issues: Vec<OrderIssueView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl OrderSubmitView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "missing" => CommandDisposition::NotFound, - "invalid" => CommandDisposition::ValidationFailed, - "unconfigured" => CommandDisposition::Unconfigured, - "unavailable" => CommandDisposition::ExternalUnavailable, - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderRebindView { - pub state: String, - pub source: String, - pub lookup: String, - pub file: String, - pub dry_run: bool, - #[serde(rename = "from_trade_id")] - pub from_order_id: String, - #[serde(rename = "to_trade_id")] - pub to_order_id: String, - #[serde(rename = "trade_id_changed")] - pub order_id_changed: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub from_buyer_account_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub from_buyer_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub from_buyer_actor_source: Option<String>, - pub to_buyer_account_id: String, - pub to_buyer_pubkey: String, - pub to_buyer_actor_source: String, - pub buyer_pubkey_changed: bool, - pub existing_request_check: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub existing_request_event_ids: Vec<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl OrderRebindView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "missing" => CommandDisposition::NotFound, - "invalid" => CommandDisposition::ValidationFailed, - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderDecisionView { - pub state: String, - pub source: String, - #[serde(rename = "trade_id")] - pub order_id: String, - pub locator: OrderTradeLocatorView, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_addr: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_pubkey: Option<String>, - pub decision: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub request_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub root_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub prev_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_kind: Option<u32>, - #[serde(skip_serializing_if = "Option::is_none")] - pub inventory: Option<OrderInventoryView>, - #[serde(default)] - pub dry_run: bool, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub target_transport_endpoints: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub attempted_transport_endpoints: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub accepted_transport_endpoints: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub failed_transport_targets: Vec<TransportTargetFailureView>, - #[serde(default)] - pub fetched_count: usize, - #[serde(default)] - pub decoded_count: usize, - #[serde(default)] - pub skipped_count: usize, - #[serde(skip_serializing_if = "Option::is_none")] - pub idempotency_key: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub signer_mode: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub issues: Vec<OrderIssueView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl OrderDecisionView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "missing" => CommandDisposition::NotFound, - "invalid" | "already_decided" | "terminal" => CommandDisposition::ValidationFailed, - "unconfigured" => CommandDisposition::Unconfigured, - "unavailable" => CommandDisposition::ExternalUnavailable, - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderCancellationView { - pub state: String, - pub source: String, - #[serde(rename = "trade_id")] - pub order_id: String, - pub locator: OrderTradeLocatorView, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_addr: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub request_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub decision_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub root_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub prev_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_kind: Option<u32>, - #[serde(skip_serializing_if = "Option::is_none")] - pub cancellation_reason: Option<String>, - #[serde(default)] - pub dry_run: bool, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub target_transport_endpoints: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub attempted_transport_endpoints: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub accepted_transport_endpoints: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub failed_transport_targets: Vec<TransportTargetFailureView>, - #[serde(default)] - pub fetched_count: usize, - #[serde(default)] - pub decoded_count: usize, - #[serde(default)] - pub skipped_count: usize, - #[serde(skip_serializing_if = "Option::is_none")] - pub idempotency_key: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub signer_mode: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub issues: Vec<OrderIssueView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl OrderCancellationView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "missing" => CommandDisposition::NotFound, - "invalid" | "declined" | "fulfilled" | "terminal" | "forked" => { - CommandDisposition::ValidationFailed - } - "unconfigured" => CommandDisposition::Unconfigured, - "unavailable" => CommandDisposition::ExternalUnavailable, - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderStatusView { - pub state: String, - pub source: String, - #[serde(rename = "trade_id")] - pub order_id: String, - pub locator: OrderTradeLocatorView, - pub actor_context_source: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub request_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub decision_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub agreement_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_addr: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub economics: Option<RadrootsOrderEconomics>, - #[serde(skip_serializing_if = "Option::is_none")] - pub last_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub inventory: Option<OrderInventoryView>, - pub lifecycle: Option<OrderStatusLifecycleView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub sdk_receipt: Option<OrderStatusSdkReceiptView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub ambiguity_candidates: Vec<OrderStatusAmbiguityCandidateView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub reducer_issues: Vec<OrderIssueView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub target_transport_endpoints: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub attempted_transport_endpoints: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub failed_transport_targets: Vec<TransportTargetFailureView>, - #[serde(default)] - pub fetched_count: usize, - #[serde(default)] - pub decoded_count: usize, - #[serde(default)] - pub skipped_count: usize, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderStatusSdkReceiptView { - pub next_action: String, - pub evidence: OrderStatusEvidenceSummaryView, - pub eligibility: OrderStatusEligibilityView, - #[serde(skip_serializing_if = "Option::is_none")] - pub validation_trust: Option<OrderStatusValidationTrustView>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderStatusValidationTrustView { - pub state: String, - pub validator_count: usize, - #[serde(skip_serializing_if = "Option::is_none")] - pub validator_set_addr: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub validator_set_event_id: Option<String>, - pub require_cryptographic_proof: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub receipt_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub receipt_author: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub result_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub result_author: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub proof_system: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub validation_authority: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub commitment_confidence: Option<String>, - pub cryptographic_proof_required: bool, - pub cryptographic_proof_verified: bool, - pub production_committed: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason_code: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderStatusEvidenceSummaryView { - pub event_count: usize, - pub limit_applied: u32, - pub has_request: bool, - pub has_decision: bool, - pub has_agreement: bool, - pub has_validation_receipt: bool, - pub has_cancellation: bool, - pub has_issues: bool, -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderStatusEligibilityView { - pub can_decide: bool, - pub can_cancel: bool, -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderStatusLifecycleView { - pub phase: String, - #[serde(default)] - pub terminal: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub root_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub prev_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub cancellation: Option<OrderStatusLifecycleCancellationView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub issues: Vec<OrderIssueView>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderStatusLifecycleCancellationView { - pub event_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub root_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub prev_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderInventoryView { - pub state: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_event_id: Option<String>, - #[serde(default)] - pub commitment_valid: bool, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub bins: Vec<OrderInventoryBinView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub issues: Vec<OrderIssueView>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderInventoryBinView { - pub bin_id: String, - #[serde(default)] - pub committed_count: u64, - #[serde(skip_serializing_if = "Option::is_none")] - pub available_count: Option<u64>, - #[serde(skip_serializing_if = "Option::is_none")] - pub remaining_count: Option<u64>, - #[serde(default)] - pub over_reserved: bool, -} - -impl OrderStatusView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - "unavailable" => CommandDisposition::ExternalUnavailable, - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderWorkflowView { - pub state: String, - pub source: String, - #[serde(rename = "trade_id")] - pub order_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_addr: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub validated_listing_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub root_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub last_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderEventListView { - pub state: String, - pub source: String, - pub actor_context_source: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_pubkey: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub target_transport_endpoints: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub attempted_transport_endpoints: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub failed_transport_targets: Vec<TransportTargetFailureView>, - #[serde(default)] - pub fetched_count: usize, - #[serde(default)] - pub decoded_count: usize, - #[serde(default)] - pub skipped_count: usize, - pub count: usize, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - #[serde(rename = "trades")] - pub orders: Vec<OrderEventListEntryView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl OrderEventListView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - "unavailable" => CommandDisposition::ExternalUnavailable, - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderEventListEntryView { - pub id: String, - pub state: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_kind: Option<u32>, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_lookup: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_addr: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_account_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub item_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub created_at_unix: Option<u64>, - #[serde(skip_serializing_if = "Option::is_none")] - pub submitted_at_unix: Option<u64>, - pub updated_at_unix: u64, - #[serde(skip_serializing_if = "Option::is_none")] - pub job: Option<OrderJobView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub workflow: Option<OrderWorkflowView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub issues: Vec<OrderIssueView>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderSummaryView { - pub id: String, - pub state: String, - pub ready_for_submit: bool, - pub file: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_lookup: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_addr: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_event_id: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub listing_relays: Vec<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_account_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_actor_source: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_custody: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_write_capable: Option<bool>, - pub item_count: usize, - #[serde(skip_serializing_if = "Option::is_none")] - pub economics: Option<RadrootsOrderEconomics>, - pub updated_at_unix: u64, - #[serde(skip_serializing_if = "Option::is_none")] - pub job: Option<OrderJobView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub issues: Vec<OrderIssueView>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderDraftItemView { - pub bin_id: String, - pub bin_count: u32, -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderIssueView { - pub code: String, - pub field: String, - pub message: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub event_ids: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderJobView { - pub job_id: String, - pub state: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub command: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub signer_mode: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_addr: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] pub struct ListingNewView { pub state: String, pub source: String,