commit 4f740383dd0d0b20a0f8ced330bc2d099f3e3b12
parent 45023069b422f3b717f3b7b02d4fe9c346dad9b7
Author: triesap <tyson@radroots.org>
Date: Thu, 7 May 2026 01:42:37 +0000
cli: normalize account write gates
Diffstat:
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"]);