cli

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

commit 4f740383dd0d0b20a0f8ced330bc2d099f3e3b12
parent 45023069b422f3b717f3b7b02d4fe9c346dad9b7
Author: triesap <tyson@radroots.org>
Date:   Thu,  7 May 2026 01:42:37 +0000

cli: normalize account write gates

Diffstat:
Msrc/domain/runtime.rs | 10++++++++++
Msrc/operation_adapter.rs | 48+++++++++++++++++++++++++++++++-----------------
Msrc/operation_listing.rs | 18+++---------------
Msrc/runtime/accounts.rs | 71++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Msrc/runtime/farm.rs | 21++++++++++-----------
Msrc/runtime/listing.rs | 30+++++++++++++++++++-----------
Msrc/runtime/mod.rs | 4+++-
Msrc/runtime/order.rs | 174+++++++++++++++++++++++++++++++++++--------------------------------------------
Msrc/runtime/signer.rs | 73+++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mtests/signer_runtime_modes.rs | 41+++++++++++++++++++++++++++++++++++++++--
Mtests/target_cli.rs | 4++++
11 files changed, 295 insertions(+), 199 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -445,6 +445,8 @@ pub struct AccountSummaryView { #[serde(skip_serializing_if = "Option::is_none")] pub display_name: Option<String>, pub signer: String, + pub custody: String, + pub write_capable: bool, pub is_default: bool, } @@ -454,10 +456,18 @@ impl AccountSummaryView { signer: &str, is_default: bool, ) -> Self { + let write_capable = signer == "local"; Self { id: record.account_id.to_string(), display_name: record.label.clone(), signer: signer.to_owned(), + custody: if write_capable { + "secret_backed" + } else { + "watch_only" + } + .to_owned(), + write_capable, is_default, } } diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -13,6 +13,7 @@ use crate::output_contract::{ OutputError, OutputWarning, }; use crate::runtime::RuntimeError; +use crate::runtime::accounts::AccountRuntimeFailure; use crate::target_cli::{TargetCliArgs, TargetOutputFormat}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -514,6 +515,7 @@ impl OperationAdapterError { operation_id: operation_id.to_owned(), message, }, + RuntimeError::Account(failure) => account_runtime_failure(operation_id, failure), RuntimeError::Config(_) if contains_any( &lowered, @@ -524,9 +526,6 @@ impl OperationAdapterError { "account mismatch", "did not match any local account", "unresolved account", - "watch_only", - "not secret-backed", - "selected local account", ], ) => { @@ -748,6 +747,27 @@ enum RuntimeFailureAvailability { Unavailable, } +fn account_runtime_failure( + operation_id: &str, + failure: &AccountRuntimeFailure, +) -> OperationAdapterError { + let message = failure.to_string(); + match failure { + AccountRuntimeFailure::Unresolved(_) => OperationAdapterError::AccountUnresolved { + operation_id: operation_id.to_owned(), + message, + }, + AccountRuntimeFailure::WatchOnly(_) => OperationAdapterError::AccountWatchOnly { + operation_id: operation_id.to_owned(), + message, + }, + AccountRuntimeFailure::Mismatch(_) => OperationAdapterError::AccountMismatch { + operation_id: operation_id.to_owned(), + message, + }, + } +} + fn classify_runtime_failure( operation_id: &str, message: String, @@ -760,14 +780,7 @@ fn classify_runtime_failure( message, }; } - if contains_any( - &lowered, - &[ - "account mismatch", - "selected local account", - "cannot sign listing seller_pubkey", - ], - ) { + if contains_any(&lowered, &["account mismatch"]) { return OperationAdapterError::AccountMismatch { operation_id: operation_id.to_owned(), message, @@ -1464,6 +1477,7 @@ mod tests { }; use crate::operation_registry::OPERATION_REGISTRY; use crate::runtime::RuntimeError; + use crate::runtime::accounts::AccountRuntimeFailure; use crate::target_cli::TargetCliArgs; #[test] @@ -2013,7 +2027,8 @@ mod tests { ( OperationAdapterError::unconfigured( "listing.publish", - "watch_only account cannot sign".to_owned(), + "resolved account `a` is watch_only and cannot sign because it is not secret-backed" + .to_owned(), ), "account_watch_only", "account", @@ -2022,7 +2037,7 @@ mod tests { ( OperationAdapterError::unconfigured( "listing.publish", - "selected local account pubkey `b` cannot sign listing seller_pubkey `a`" + "account mismatch: resolved account pubkey `b` cannot sign listing seller_pubkey `a`" .to_owned(), ), "account_mismatch", @@ -2086,10 +2101,9 @@ mod tests { ( OperationAdapterError::runtime_failure( "listing.archive", - RuntimeError::Config( - "selected local account pubkey `b` cannot sign listing seller_pubkey `a`" - .to_owned(), - ), + RuntimeError::Account(AccountRuntimeFailure::mismatch( + "account mismatch: resolved account pubkey `b` cannot sign listing seller_pubkey `a`", + )), ), "account_mismatch", "account", diff --git a/src/operation_listing.rs b/src/operation_listing.rs @@ -154,8 +154,9 @@ impl OperationService<ListingPublishRequest> for ListingOperationService<'_> { } let args = mutation_args(&request)?; let config = mutation_config(self.config, &request); - let view = crate::runtime::listing::publish(&config, &args) - .map_err(|error| publish_runtime_error(request.operation_id(), error))?; + let view = crate::runtime::listing::publish(&config, &args).map_err(|error| { + OperationAdapterError::runtime_failure(request.operation_id(), error) + })?; mutation_result::<ListingPublishResult>(request.operation_id(), &view) } } @@ -279,19 +280,6 @@ fn map_runtime<T>( result.map_err(|error| OperationAdapterError::runtime_failure(operation_id, error)) } -fn publish_runtime_error(operation_id: &str, error: RuntimeError) -> OperationAdapterError { - let message = error.to_string(); - let lowered = message.to_ascii_lowercase(); - if lowered.contains("no local account") - || lowered.contains("watch_only") - || lowered.contains("not secret-backed") - || lowered.contains("selected local account") - { - return OperationAdapterError::unconfigured(operation_id, message); - } - OperationAdapterError::runtime_failure(operation_id, error) -} - fn required_string<P>( request: &OperationRequest<P>, key: &str, diff --git a/src/runtime/accounts.rs b/src/runtime/accounts.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::{fmt, path::Path}; use radroots_identity::{ IdentityError, RadrootsIdentity, RadrootsIdentityPublic, load_identity_profile, @@ -22,6 +22,41 @@ const HOST_VAULT_SERVICE_NAME: &str = "org.radroots.cli.local-account"; const HOST_VAULT_PROBE_SLOT: &str = "__radroots_cli_host_vault_probe__"; pub const SHARED_ACCOUNT_STORE_SOURCE: &str = "shared account store ยท local first"; +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AccountRuntimeFailure { + Unresolved(String), + WatchOnly(String), + Mismatch(String), +} + +impl AccountRuntimeFailure { + pub fn unresolved(message: impl Into<String>) -> Self { + Self::Unresolved(message.into()) + } + + pub fn watch_only(account_id: &radroots_identity::RadrootsIdentityId) -> Self { + Self::WatchOnly(format!( + "resolved account `{account_id}` is watch_only and cannot sign because it is not secret-backed" + )) + } + + pub fn mismatch(message: impl Into<String>) -> Self { + Self::Mismatch(message.into()) + } +} + +impl fmt::Display for AccountRuntimeFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unresolved(message) | Self::WatchOnly(message) | Self::Mismatch(message) => { + formatter.write_str(message) + } + } + } +} + +impl std::error::Error for AccountRuntimeFailure {} + #[derive(Debug, Clone)] pub struct AccountSnapshot { pub accounts: Vec<AccountRecordView>, @@ -362,15 +397,10 @@ pub fn resolve_local_signing_identity( let manager = account_manager(config)?; let resolution = resolve_account_resolution(config)?; let Some(account) = resolution.resolved_account else { - return Err(RuntimeError::Config( - "no local account is selected for signing".to_owned(), - )); + return Err(AccountRuntimeFailure::unresolved(unresolved_account_reason(config)?).into()); }; let Some(identity) = manager.get_signing_identity(&account.record.account_id)? else { - return Err(RuntimeError::Config(format!( - "watch_only account {} is present but not secret-backed", - account.record.account_id - ))); + return Err(AccountRuntimeFailure::watch_only(&account.record.account_id).into()); }; Ok(AccountSigningIdentity { account, identity }) } @@ -502,12 +532,18 @@ fn selector_runtime_error(selector: &str, error: RadrootsNostrAccountsError) -> let normalized = selector.trim(); match error { RadrootsNostrAccountsError::InvalidAccountSelector(reason) => RuntimeError::Config(reason), - RadrootsNostrAccountsError::AccountNotFound(_) => RuntimeError::Config(format!( - "account selector `{normalized}` did not match any local account" - )), - RadrootsNostrAccountsError::AmbiguousAccountSelector(_) => RuntimeError::Config(format!( - "account selector `{normalized}` matched multiple local accounts; use account id or npub" - )), + RadrootsNostrAccountsError::AccountNotFound(_) => { + AccountRuntimeFailure::unresolved(format!( + "account selector `{normalized}` did not match any local account" + )) + .into() + } + RadrootsNostrAccountsError::AmbiguousAccountSelector(_) => { + AccountRuntimeFailure::unresolved(format!( + "account selector `{normalized}` matched multiple local accounts; use account id or npub" + )) + .into() + } other => RuntimeError::Accounts(other), } } @@ -565,10 +601,11 @@ fn validate_identity_secret_matches_account( return Ok(()); } - Err(RuntimeError::Config(format!( - "account mismatch: account `{}` public key `{}` does not match secret public key `{}`", + Err(AccountRuntimeFailure::mismatch(format!( + "account mismatch: resolved account `{}` public key `{}` does not match secret public key `{}`", record.account_id, record.public_identity.public_key_hex, secret_public_key_hex - ))) + )) + .into()) } fn account_manager(config: &RuntimeConfig) -> Result<RadrootsNostrAccountsManager, RuntimeError> { diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs @@ -351,6 +351,7 @@ pub fn publish( let signing = match resolve_farm_signing_identity(config, account_pubkey.as_str()) { Ok(signing) => signing, + Err(ActorWriteBindingError::Account(failure)) => return Err(failure.into()), Err(error) => { return Ok(binding_error_publish_view( config, @@ -489,7 +490,7 @@ fn resolve_farm_signing_identity( }); } let signing = accounts::resolve_local_signing_identity(config) - .map_err(|error| ActorWriteBindingError::Unconfigured(error.to_string()))?; + .map_err(ActorWriteBindingError::from_runtime)?; let selected_pubkey = signing .account .record @@ -497,9 +498,11 @@ fn resolve_farm_signing_identity( .public_key_hex .as_str(); if !selected_pubkey.eq_ignore_ascii_case(account_pubkey) { - return Err(ActorWriteBindingError::Unconfigured(format!( - "selected local account pubkey `{selected_pubkey}` cannot sign farm pubkey `{account_pubkey}`" - ))); + return Err(ActorWriteBindingError::Account( + accounts::AccountRuntimeFailure::mismatch(format!( + "account mismatch: resolved account pubkey `{selected_pubkey}` cannot sign farm pubkey `{account_pubkey}`" + )), + )); } Ok(signing) } @@ -638,13 +641,9 @@ fn binding_error_publish_view( farm_idempotency_key: Option<String>, error: ActorWriteBindingError, ) -> FarmPublishView { - let (state, reason, actions) = match error { - ActorWriteBindingError::Unconfigured(reason) => ( - "unconfigured".to_owned(), - reason, - vec!["run radroots signer status get".to_owned()], - ), - }; + let reason = error.reason(); + let state = "unconfigured".to_owned(); + let actions = vec!["run radroots signer status get".to_owned()]; base_publish_view( state.as_str(), config, diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -821,6 +821,17 @@ fn mutate( })?; let context = validation_context(config)?; let mut canonical = canonicalize_draft(&parsed, &contents, &context).map_err(|issue| { + if issue.field == "listing.seller_pubkey" + && issue + .message + .contains("no resolved account pubkey is available") + { + return accounts::AccountRuntimeFailure::unresolved(format!( + "{} ({})", + issue.message, issue.field + )) + .into(); + } RuntimeError::Config(format!( "invalid listing draft {}: {} ({})", args.file.display(), @@ -1006,7 +1017,7 @@ fn canonicalize_draft( return Err(issue_for_field( contents, "listing.seller_pubkey", - "missing seller_pubkey and no local account is selected", + "missing seller_pubkey and no resolved account pubkey is available", )); }; @@ -1433,10 +1444,11 @@ fn resolve_listing_signing_identity( .public_key_hex .as_str(); if !account_pubkey.eq_ignore_ascii_case(canonical.seller_pubkey.as_str()) { - return Err(RuntimeError::Config(format!( - "selected local account pubkey `{account_pubkey}` cannot sign listing seller_pubkey `{}`", + return Err(accounts::AccountRuntimeFailure::mismatch(format!( + "account mismatch: resolved account pubkey `{account_pubkey}` cannot sign listing seller_pubkey `{}`", canonical.seller_pubkey - ))); + )) + .into()); } Ok(signing) } @@ -1450,13 +1462,9 @@ fn binding_error_view( event_preview: ListingMutationEventView, error: ActorWriteBindingError, ) -> ListingMutationView { - let (state, reason, actions) = match error { - ActorWriteBindingError::Unconfigured(reason) => ( - "unconfigured".to_owned(), - reason, - vec!["run radroots signer status get".to_owned()], - ), - }; + let reason = error.reason(); + let state = "unconfigured".to_owned(); + let actions = vec!["run radroots signer status get".to_owned()]; ListingMutationView { state: state.clone(), diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs @@ -22,6 +22,8 @@ use std::process::ExitCode; pub enum RuntimeError { #[error("{0}")] Config(String), + #[error("{0}")] + Account(#[from] accounts::AccountRuntimeFailure), #[error("failed to initialize logging: {0}")] Logging(#[from] radroots_log::Error), #[error("accounts error: {0}")] @@ -41,7 +43,7 @@ pub enum RuntimeError { impl RuntimeError { pub fn exit_code(&self) -> ExitCode { match self { - Self::Config(_) => ExitCode::from(2), + Self::Config(_) | Self::Account(_) => ExitCode::from(2), Self::Logging(_) | Self::Accounts(_) | Self::Sql(_) diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -666,6 +666,7 @@ pub fn submit( loaded.document.order.buyer_pubkey.as_str(), ) { Ok(signing) => signing, + Err(ActorWriteBindingError::Account(failure)) => return Err(failure.into()), Err(error) => return Ok(order_binding_error_view(config, &loaded, args, error)), }; let payload = canonical_order_request_payload_from_loaded( @@ -896,6 +897,7 @@ pub fn decide( args.decision, ) { Ok(signing) => signing, + Err(ActorWriteBindingError::Account(failure)) => return Err(failure.into()), Err(error) => { return Ok(order_decision_binding_error_view( config, args, request, resolution, error, @@ -1010,6 +1012,7 @@ pub fn revision_propose( })?; let signing = match resolve_local_order_fulfillment_signing_identity(config, seller_pubkey) { Ok(signing) => signing, + Err(ActorWriteBindingError::Account(failure)) => return Err(failure.into()), Err(error) => { return Ok(order_revision_binding_error_view( config, @@ -1165,6 +1168,7 @@ pub fn revision_decide( let signing = match resolve_local_order_revision_decision_signing_identity(config, buyer_pubkey, args) { Ok(signing) => signing, + Err(ActorWriteBindingError::Account(failure)) => return Err(failure.into()), Err(error) => { return Ok(order_revision_decision_binding_error_view( config, @@ -1300,6 +1304,7 @@ pub fn fulfillment_update( })?; let signing = match resolve_local_order_fulfillment_signing_identity(config, seller_pubkey) { Ok(signing) => signing, + Err(ActorWriteBindingError::Account(failure)) => return Err(failure.into()), Err(error) => { return Ok(order_fulfillment_binding_error_view( config, @@ -1386,6 +1391,7 @@ pub fn cancel( .ok_or_else(|| RuntimeError::Config("order is missing buyer_pubkey".to_owned()))?; let signing = match resolve_local_order_cancellation_signing_identity(config, buyer_pubkey) { Ok(signing) => signing, + Err(ActorWriteBindingError::Account(failure)) => return Err(failure.into()), Err(error) => { return Ok(order_cancellation_binding_error_view( config, @@ -1469,6 +1475,7 @@ pub fn receipt_record( })?; let signing = match resolve_local_order_receipt_signing_identity(config, buyer_pubkey) { Ok(signing) => signing, + Err(ActorWriteBindingError::Account(failure)) => return Err(failure.into()), Err(error) => { return Ok(order_receipt_binding_error_view( config, @@ -1558,6 +1565,7 @@ pub fn payment_record( .ok_or_else(|| RuntimeError::Config("payable order is missing buyer_pubkey".to_owned()))?; let signing = match resolve_local_order_payment_signing_identity(config, buyer_pubkey) { Ok(signing) => signing, + Err(ActorWriteBindingError::Account(failure)) => return Err(failure.into()), Err(error) => { return Ok(order_payment_binding_error_view( config, @@ -1648,6 +1656,7 @@ pub fn settlement_decision( })?; let signing = match resolve_local_order_settlement_signing_identity(config, seller_pubkey) { Ok(signing) => signing, + Err(ActorWriteBindingError::Account(failure)) => return Err(failure.into()), Err(error) => { return Ok(order_settlement_binding_error_view( config, @@ -7700,19 +7709,23 @@ fn published_order_settlement_view( view } +fn order_actor_write_binding_error_parts( + error: ActorWriteBindingError, +) -> (String, String, Vec<String>) { + ( + "unconfigured".to_owned(), + error.reason(), + vec!["run radroots signer status get".to_owned()], + ) +} + fn order_fulfillment_binding_error_view( config: &RuntimeConfig, args: &OrderFulfillmentArgs, status: &OrderStatusView, error: ActorWriteBindingError, ) -> OrderFulfillmentView { - let (state, reason, actions) = match error { - ActorWriteBindingError::Unconfigured(reason) => ( - "unconfigured".to_owned(), - reason, - vec!["run radroots signer status get".to_owned()], - ), - }; + let (state, reason, actions) = order_actor_write_binding_error_parts(error); let mut view = order_fulfillment_base_view(config, args, state.as_str(), config.output.dry_run); apply_order_fulfillment_status(&mut view, status); view.reason = Some(reason); @@ -7726,13 +7739,7 @@ fn order_revision_binding_error_view( status: &OrderStatusView, error: ActorWriteBindingError, ) -> OrderRevisionProposalView { - let (state, reason, actions) = match error { - ActorWriteBindingError::Unconfigured(reason) => ( - "unconfigured".to_owned(), - reason, - vec!["run radroots signer status get".to_owned()], - ), - }; + let (state, reason, actions) = order_actor_write_binding_error_parts(error); let mut view = order_revision_base_view(config, args, state.as_str(), config.output.dry_run); apply_order_revision_status(&mut view, status); view.reason = Some(reason); @@ -7746,13 +7753,7 @@ fn order_revision_decision_binding_error_view( status: &OrderStatusView, error: ActorWriteBindingError, ) -> OrderRevisionDecisionView { - let (state, reason, actions) = match error { - ActorWriteBindingError::Unconfigured(reason) => ( - "unconfigured".to_owned(), - reason, - vec!["run radroots signer status get".to_owned()], - ), - }; + let (state, reason, actions) = order_actor_write_binding_error_parts(error); let mut view = order_revision_decision_base_view(config, args, state.as_str(), config.output.dry_run); apply_order_revision_decision_status(&mut view, status); @@ -7767,13 +7768,7 @@ fn order_cancellation_binding_error_view( status: &OrderStatusView, error: ActorWriteBindingError, ) -> OrderCancellationView { - let (state, reason, actions) = match error { - ActorWriteBindingError::Unconfigured(reason) => ( - "unconfigured".to_owned(), - reason, - vec!["run radroots signer status get".to_owned()], - ), - }; + let (state, reason, actions) = order_actor_write_binding_error_parts(error); let mut view = order_cancellation_base_view(config, args, state.as_str(), config.output.dry_run); apply_order_cancellation_status(&mut view, status); @@ -7788,13 +7783,7 @@ fn order_receipt_binding_error_view( status: &OrderStatusView, error: ActorWriteBindingError, ) -> OrderReceiptView { - let (state, reason, actions) = match error { - ActorWriteBindingError::Unconfigured(reason) => ( - "unconfigured".to_owned(), - reason, - vec!["run radroots signer status get".to_owned()], - ), - }; + let (state, reason, actions) = order_actor_write_binding_error_parts(error); let mut view = order_receipt_base_view(config, args, state.as_str(), config.output.dry_run); apply_order_receipt_status(&mut view, status); view.reason = Some(reason); @@ -7808,13 +7797,7 @@ fn order_payment_binding_error_view( status: &OrderStatusView, error: ActorWriteBindingError, ) -> OrderPaymentView { - let (state, reason, actions) = match error { - ActorWriteBindingError::Unconfigured(reason) => ( - "unconfigured".to_owned(), - reason, - vec!["run radroots signer status get".to_owned()], - ), - }; + let (state, reason, actions) = order_actor_write_binding_error_parts(error); let mut view = order_payment_base_view(config, args, state.as_str(), config.output.dry_run); apply_order_payment_status(&mut view, status); view.reason = Some(reason); @@ -7828,13 +7811,7 @@ fn order_settlement_binding_error_view( status: &OrderStatusView, error: ActorWriteBindingError, ) -> OrderSettlementView { - let (state, reason, actions) = match error { - ActorWriteBindingError::Unconfigured(reason) => ( - "unconfigured".to_owned(), - reason, - vec!["run radroots signer status get".to_owned()], - ), - }; + let (state, reason, actions) = order_actor_write_binding_error_parts(error); let mut view = order_settlement_base_view(config, args, state.as_str(), config.output.dry_run); apply_order_settlement_status(&mut view, status); view.reason = Some(reason); @@ -8096,13 +8073,7 @@ fn order_decision_binding_error_view( resolution: SellerOrderRequestResolution, error: ActorWriteBindingError, ) -> OrderDecisionView { - let (state, reason, actions) = match error { - ActorWriteBindingError::Unconfigured(reason) => ( - "unconfigured".to_owned(), - reason, - vec!["run radroots signer status get".to_owned()], - ), - }; + let (state, reason, actions) = order_actor_write_binding_error_parts(error); let mut view = order_decision_base_view(config, args, state.as_str(), config.output.dry_run); apply_order_decision_resolution(&mut view, &resolution); apply_order_decision_request(&mut view, &request); @@ -9771,13 +9742,7 @@ fn order_binding_error_view( args: &OrderSubmitArgs, error: ActorWriteBindingError, ) -> OrderSubmitView { - let (state, reason, actions) = match error { - ActorWriteBindingError::Unconfigured(reason) => ( - "unconfigured".to_owned(), - reason, - vec!["run radroots signer status get".to_owned()], - ), - }; + let (state, reason, actions) = order_actor_write_binding_error_parts(error); let mut actions = actions; actions.push(format!( @@ -9824,7 +9789,7 @@ fn resolve_local_order_signing_identity( )); } let signing = accounts::resolve_local_signing_identity(config) - .map_err(|error| ActorWriteBindingError::Unconfigured(error.to_string()))?; + .map_err(ActorWriteBindingError::from_runtime)?; let selected_pubkey = signing .account .record @@ -9832,9 +9797,11 @@ fn resolve_local_order_signing_identity( .public_key_hex .as_str(); if !selected_pubkey.eq_ignore_ascii_case(buyer_pubkey) { - return Err(ActorWriteBindingError::Unconfigured(format!( - "selected local account pubkey `{selected_pubkey}` cannot sign order buyer_pubkey `{buyer_pubkey}`" - ))); + return Err(ActorWriteBindingError::Account( + accounts::AccountRuntimeFailure::mismatch(format!( + "account mismatch: resolved account pubkey `{selected_pubkey}` cannot sign order buyer_pubkey `{buyer_pubkey}`" + )), + )); } Ok(signing) } @@ -9851,7 +9818,7 @@ fn resolve_local_order_decision_signing_identity( ))); } let signing = accounts::resolve_local_signing_identity(config) - .map_err(|error| ActorWriteBindingError::Unconfigured(error.to_string()))?; + .map_err(ActorWriteBindingError::from_runtime)?; let selected_pubkey = signing .account .record @@ -9859,9 +9826,11 @@ fn resolve_local_order_decision_signing_identity( .public_key_hex .as_str(); if !selected_pubkey.eq_ignore_ascii_case(seller_pubkey) { - return Err(ActorWriteBindingError::Unconfigured(format!( - "selected local account pubkey `{selected_pubkey}` cannot sign order seller_pubkey `{seller_pubkey}`" - ))); + return Err(ActorWriteBindingError::Account( + accounts::AccountRuntimeFailure::mismatch(format!( + "account mismatch: resolved account pubkey `{selected_pubkey}` cannot sign order seller_pubkey `{seller_pubkey}`" + )), + )); } Ok(signing) } @@ -9876,7 +9845,7 @@ fn resolve_local_order_fulfillment_signing_identity( )); } let signing = accounts::resolve_local_signing_identity(config) - .map_err(|error| ActorWriteBindingError::Unconfigured(error.to_string()))?; + .map_err(ActorWriteBindingError::from_runtime)?; let selected_pubkey = signing .account .record @@ -9884,9 +9853,11 @@ fn resolve_local_order_fulfillment_signing_identity( .public_key_hex .as_str(); if !selected_pubkey.eq_ignore_ascii_case(seller_pubkey) { - return Err(ActorWriteBindingError::Unconfigured(format!( - "selected local account pubkey `{selected_pubkey}` cannot sign order seller_pubkey `{seller_pubkey}`" - ))); + return Err(ActorWriteBindingError::Account( + accounts::AccountRuntimeFailure::mismatch(format!( + "account mismatch: resolved account pubkey `{selected_pubkey}` cannot sign order seller_pubkey `{seller_pubkey}`" + )), + )); } Ok(signing) } @@ -9901,7 +9872,7 @@ fn resolve_local_order_cancellation_signing_identity( )); } let signing = accounts::resolve_local_signing_identity(config) - .map_err(|error| ActorWriteBindingError::Unconfigured(error.to_string()))?; + .map_err(ActorWriteBindingError::from_runtime)?; let selected_pubkey = signing .account .record @@ -9909,9 +9880,11 @@ fn resolve_local_order_cancellation_signing_identity( .public_key_hex .as_str(); if !selected_pubkey.eq_ignore_ascii_case(buyer_pubkey) { - return Err(ActorWriteBindingError::Unconfigured(format!( - "selected local account pubkey `{selected_pubkey}` cannot sign order buyer_pubkey `{buyer_pubkey}`" - ))); + return Err(ActorWriteBindingError::Account( + accounts::AccountRuntimeFailure::mismatch(format!( + "account mismatch: resolved account pubkey `{selected_pubkey}` cannot sign order buyer_pubkey `{buyer_pubkey}`" + )), + )); } Ok(signing) } @@ -9926,7 +9899,7 @@ fn resolve_local_order_receipt_signing_identity( )); } let signing = accounts::resolve_local_signing_identity(config) - .map_err(|error| ActorWriteBindingError::Unconfigured(error.to_string()))?; + .map_err(ActorWriteBindingError::from_runtime)?; let selected_pubkey = signing .account .record @@ -9934,9 +9907,11 @@ fn resolve_local_order_receipt_signing_identity( .public_key_hex .as_str(); if !selected_pubkey.eq_ignore_ascii_case(buyer_pubkey) { - return Err(ActorWriteBindingError::Unconfigured(format!( - "selected local account pubkey `{selected_pubkey}` cannot sign order buyer_pubkey `{buyer_pubkey}`" - ))); + return Err(ActorWriteBindingError::Account( + accounts::AccountRuntimeFailure::mismatch(format!( + "account mismatch: resolved account pubkey `{selected_pubkey}` cannot sign order buyer_pubkey `{buyer_pubkey}`" + )), + )); } Ok(signing) } @@ -9951,7 +9926,7 @@ fn resolve_local_order_payment_signing_identity( )); } let signing = accounts::resolve_local_signing_identity(config) - .map_err(|error| ActorWriteBindingError::Unconfigured(error.to_string()))?; + .map_err(ActorWriteBindingError::from_runtime)?; let selected_pubkey = signing .account .record @@ -9959,9 +9934,11 @@ fn resolve_local_order_payment_signing_identity( .public_key_hex .as_str(); if !selected_pubkey.eq_ignore_ascii_case(buyer_pubkey) { - return Err(ActorWriteBindingError::Unconfigured(format!( - "selected local account pubkey `{selected_pubkey}` cannot sign order buyer_pubkey `{buyer_pubkey}`" - ))); + return Err(ActorWriteBindingError::Account( + accounts::AccountRuntimeFailure::mismatch(format!( + "account mismatch: resolved account pubkey `{selected_pubkey}` cannot sign order buyer_pubkey `{buyer_pubkey}`" + )), + )); } Ok(signing) } @@ -9976,7 +9953,7 @@ fn resolve_local_order_settlement_signing_identity( )); } let signing = accounts::resolve_local_signing_identity(config) - .map_err(|error| ActorWriteBindingError::Unconfigured(error.to_string()))?; + .map_err(ActorWriteBindingError::from_runtime)?; let selected_pubkey = signing .account .record @@ -9984,9 +9961,11 @@ fn resolve_local_order_settlement_signing_identity( .public_key_hex .as_str(); if !selected_pubkey.eq_ignore_ascii_case(seller_pubkey) { - return Err(ActorWriteBindingError::Unconfigured(format!( - "selected local account pubkey `{selected_pubkey}` cannot sign order seller_pubkey `{seller_pubkey}`" - ))); + return Err(ActorWriteBindingError::Account( + accounts::AccountRuntimeFailure::mismatch(format!( + "account mismatch: resolved account pubkey `{selected_pubkey}` cannot sign order seller_pubkey `{seller_pubkey}`" + )), + )); } Ok(signing) } @@ -10003,7 +9982,7 @@ fn resolve_local_order_revision_decision_signing_identity( ))); } let signing = accounts::resolve_local_signing_identity(config) - .map_err(|error| ActorWriteBindingError::Unconfigured(error.to_string()))?; + .map_err(ActorWriteBindingError::from_runtime)?; let selected_pubkey = signing .account .record @@ -10011,9 +9990,11 @@ fn resolve_local_order_revision_decision_signing_identity( .public_key_hex .as_str(); if !selected_pubkey.eq_ignore_ascii_case(buyer_pubkey) { - return Err(ActorWriteBindingError::Unconfigured(format!( - "selected local account pubkey `{selected_pubkey}` cannot sign order buyer_pubkey `{buyer_pubkey}`" - ))); + return Err(ActorWriteBindingError::Account( + accounts::AccountRuntimeFailure::mismatch(format!( + "account mismatch: resolved account pubkey `{selected_pubkey}` cannot sign order buyer_pubkey `{buyer_pubkey}`" + )), + )); } Ok(signing) } @@ -10375,7 +10356,6 @@ mod tests { SignerBackend, SignerConfig, Verbosity, }; use crate::runtime::direct_relay::DirectRelayFetchReceipt; - use crate::runtime::signer::ActorWriteBindingError; use crate::runtime_args::{ OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderDraftAdjustmentArgs, OrderFulfillmentArgs, OrderPaymentArgs, OrderReceiptArgs, OrderRevisionDecisionArg, @@ -14921,7 +14901,7 @@ mod tests { ) .expect_err("non seller account rejected"); - let ActorWriteBindingError::Unconfigured(reason) = error; + let reason = error.reason(); assert!(reason.contains("cannot sign order seller_pubkey")); } diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs @@ -2,6 +2,8 @@ use crate::domain::runtime::{ IdentityPublicView, LocalSignerStatusView, SignerBindingStatusView, SignerStatusView, SignerWriteKindReadinessView, }; +use crate::runtime::RuntimeError; +use crate::runtime::accounts::AccountRuntimeFailure; use crate::runtime::accounts::{SHARED_ACCOUNT_STORE_SOURCE, empty_account_resolution_view}; use crate::runtime::config::{RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend}; use radroots_events::kinds::{ @@ -28,6 +30,23 @@ struct CliWriteKind { #[derive(Debug, Clone)] pub enum ActorWriteBindingError { Unconfigured(String), + Account(AccountRuntimeFailure), +} + +impl ActorWriteBindingError { + pub fn from_runtime(error: RuntimeError) -> Self { + match error { + RuntimeError::Account(failure) => Self::Account(failure), + other => Self::Unconfigured(other.to_string()), + } + } + + pub fn reason(self) -> String { + match self { + Self::Unconfigured(reason) => reason, + Self::Account(failure) => failure.to_string(), + } + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -158,35 +177,33 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { myc: None, } } - Ok(RadrootsNostrAccountStatus::PublicOnly { account }) => SignerStatusView { - mode: config.signer.backend.as_str().to_owned(), - state: "unconfigured".to_owned(), - source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), - signer_account_id: Some(account.account_id.to_string()), - account_resolution: account_resolution.clone(), - reason: Some(format!( - "local account {} is present but not secret-backed", - account.account_id - )), - binding: disabled_binding_status(), - write_kinds: local_write_kind_readiness( - false, - Some(format!( - "local account {} is present but not secret-backed", - account.account_id - )), - ), - local: Some(LocalSignerStatusView { - account_id: account.account_id.to_string(), - public_identity: IdentityPublicView::from_public_identity(&account.public_identity), - availability: local_availability(RadrootsNostrLocalSignerAvailability::PublicOnly) + Ok(RadrootsNostrAccountStatus::PublicOnly { account }) => { + let reason = AccountRuntimeFailure::watch_only(&account.account_id).to_string(); + SignerStatusView { + mode: config.signer.backend.as_str().to_owned(), + state: "unconfigured".to_owned(), + source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), + signer_account_id: Some(account.account_id.to_string()), + account_resolution: account_resolution.clone(), + reason: Some(reason.clone()), + binding: disabled_binding_status(), + write_kinds: local_write_kind_readiness(false, Some(reason)), + local: Some(LocalSignerStatusView { + account_id: account.account_id.to_string(), + public_identity: IdentityPublicView::from_public_identity( + &account.public_identity, + ), + availability: local_availability( + RadrootsNostrLocalSignerAvailability::PublicOnly, + ) .to_owned(), - secret_backed: false, - backend: backend.clone(), - used_fallback, - }), - myc: None, - }, + secret_backed: false, + backend: backend.clone(), + used_fallback, + }), + myc: None, + } + } Ok(RadrootsNostrAccountStatus::NotConfigured) => SignerStatusView { mode: config.signer.backend.as_str().to_owned(), state: "unconfigured".to_owned(), diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -41,6 +41,8 @@ fn local_signer_status_reports_ready_after_account_create() { assert_eq!(created["operation_id"], "account.create"); assert_eq!(created["result"]["state"], "created"); assert_eq!(created["result"]["account"]["signer"], "local"); + assert_eq!(created["result"]["account"]["custody"], "secret_backed"); + assert_eq!(created["result"]["account"]["write_capable"], true); assert_eq!(created["result"]["account"]["is_default"], true); let account_id = created["result"]["account"]["id"] .as_str() @@ -170,6 +172,8 @@ fn account_import_dry_run_validates_profile_without_mutating_store() { public_identity.id.as_str() ); assert_eq!(value["result"]["account"]["signer"], "watch_only"); + assert_eq!(value["result"]["account"]["custody"], "watch_only"); + assert_eq!(value["result"]["account"]["write_capable"], false); assert_eq!(value["result"]["account"]["is_default"], true); let list = sandbox.json_success(&["--format", "json", "account", "list"]); @@ -222,6 +226,8 @@ fn account_attach_secret_dry_run_validates_without_mutating_store() { .as_str() .expect("watch account id"); assert_eq!(imported["result"]["account"]["signer"], "watch_only"); + assert_eq!(imported["result"]["account"]["custody"], "watch_only"); + assert_eq!(imported["result"]["account"]["write_capable"], false); assert_eq!(imported["result"]["account"]["is_default"], false); let value = sandbox.json_success(&[ @@ -241,6 +247,8 @@ fn account_attach_secret_dry_run_validates_without_mutating_store() { assert_eq!(value["result"]["default"], true); assert_eq!(value["result"]["account"]["id"], watch_account_id); assert_eq!(value["result"]["account"]["signer"], "local"); + assert_eq!(value["result"]["account"]["custody"], "secret_backed"); + assert_eq!(value["result"]["account"]["write_capable"], true); assert_eq!(value["result"]["account"]["is_default"], true); let watch_get = sandbox.json_success(&["--format", "json", "account", "get", watch_account_id]); @@ -248,6 +256,14 @@ fn account_attach_secret_dry_run_validates_without_mutating_store() { watch_get["result"]["account_resolution"]["resolved_account"]["signer"], "watch_only" ); + assert_eq!( + watch_get["result"]["account_resolution"]["resolved_account"]["custody"], + "watch_only" + ); + assert_eq!( + watch_get["result"]["account_resolution"]["resolved_account"]["write_capable"], + false + ); let selected = sandbox.json_success(&["--format", "json", "account", "selection", "get"]); assert_eq!( selected["result"]["account_resolution"]["resolved_account"]["id"], @@ -293,6 +309,8 @@ fn account_attach_secret_attaches_matching_secret_and_can_make_default() { assert_eq!(attached["result"]["state"], "secret_attached"); assert_eq!(attached["result"]["account"]["id"], watch_account_id); assert_eq!(attached["result"]["account"]["signer"], "local"); + assert_eq!(attached["result"]["account"]["custody"], "secret_backed"); + assert_eq!(attached["result"]["account"]["write_capable"], true); assert_eq!(attached["result"]["account"]["is_default"], true); let status = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); @@ -342,6 +360,14 @@ fn account_attach_secret_requires_approval_before_writing_secret() { get["result"]["account_resolution"]["resolved_account"]["signer"], "watch_only" ); + assert_eq!( + get["result"]["account_resolution"]["resolved_account"]["custody"], + "watch_only" + ); + assert_eq!( + get["result"]["account_resolution"]["resolved_account"]["write_capable"], + false + ); } #[test] @@ -568,6 +594,8 @@ fn watch_only_import_reports_unconfigured_local_signer() { public_identity.id.as_str() ); assert_eq!(imported["result"]["account"]["signer"], "watch_only"); + assert_eq!(imported["result"]["account"]["custody"], "watch_only"); + assert_eq!(imported["result"]["account"]["write_capable"], false); assert_eq!(imported["result"]["account"]["is_default"], true); let status = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); @@ -587,6 +615,14 @@ fn watch_only_import_reports_unconfigured_local_signer() { "watch_only" ); assert_eq!( + status["result"]["account_resolution"]["resolved_account"]["custody"], + "watch_only" + ); + assert_eq!( + status["result"]["account_resolution"]["resolved_account"]["write_capable"], + false + ); + assert_eq!( status["result"]["local"]["account_id"], public_identity.id.as_str() ); @@ -668,7 +704,7 @@ fn local_listing_publish_fails_without_local_account_authority() { assert_eq!(value["errors"][0]["detail"]["class"], "account"); assert_contains( &value["errors"][0]["message"], - "no local account is selected", + "no resolved account pubkey is available", ); } @@ -1216,7 +1252,8 @@ fn watch_only_listing_publish_fails_as_account_watch_only() { assert_eq!(value["errors"][0]["code"], "account_watch_only"); assert_eq!(value["errors"][0]["exit_code"], 7); assert_eq!(value["errors"][0]["detail"]["class"], "account"); - assert_contains(&value["errors"][0]["message"], "watch_only account"); + assert_contains(&value["errors"][0]["message"], "resolved account"); + assert_contains(&value["errors"][0]["message"], "watch_only"); } #[cfg(unix)] diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -1855,6 +1855,8 @@ fn buyer_target_flow_acceptance_uses_target_operations() { .expect("account id"); assert_eq!(account["operation_id"], "account.create"); assert_eq!(account["result"]["account"]["signer"], "local"); + assert_eq!(account["result"]["account"]["custody"], "secret_backed"); + assert_eq!(account["result"]["account"]["write_capable"], true); assert_no_removed_command_reference(&account, &["account", "create"]); let signer = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); @@ -2429,6 +2431,8 @@ fn seller_target_flow_acceptance_uses_target_operations() { .expect("account id"); assert_eq!(account["operation_id"], "account.create"); assert_eq!(account["result"]["account"]["signer"], "local"); + assert_eq!(account["result"]["account"]["custody"], "secret_backed"); + assert_eq!(account["result"]["account"]["write_capable"], true); assert_no_removed_command_reference(&account, &["account", "create"]); let signer = sandbox.json_success(&["--format", "json", "signer", "status", "get"]);