commit ba0ce9d3cb1ed6a768540f16016040fd78f4c041
parent 106516a900d4e97c500105ee16036cc0f91efe94
Author: triesap <tyson@radroots.org>
Date: Mon, 27 Apr 2026 06:31:22 +0000
cli: add local signed listing publish
- sign listing publish events with the selected local account
- return signed event metadata without claiming relay delivery
- classify local account publish boundary failures precisely
- cover signed no-account and watch-only publish paths
Diffstat:
6 files changed, 297 insertions(+), 5 deletions(-)
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -1807,10 +1807,14 @@ pub struct ListingMutationJobView {
pub struct ListingMutationEventView {
pub kind: u32,
pub author: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub created_at: Option<u32>,
pub content: String,
pub tags: Vec<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub event_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub signature: Option<String>,
pub event_addr: String,
}
diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs
@@ -519,6 +519,7 @@ fn classify_runtime_failure(
&lowered,
&[
"no account",
+ "no local account",
"account selector",
"account selection",
"did not match any local account",
diff --git a/src/operation_listing.rs b/src/operation_listing.rs
@@ -141,7 +141,8 @@ impl OperationService<ListingPublishRequest> for ListingOperationService<'_> {
}
require_approval(&request)?;
let args = mutation_args(&request)?;
- let view = map_runtime(crate::runtime::listing::publish(self.config, &args))?;
+ let view = crate::runtime::listing::publish(self.config, &args)
+ .map_err(|error| publish_runtime_error(request.operation_id(), error))?;
mutation_result::<ListingPublishResult>(request.operation_id(), &view)
}
}
@@ -272,6 +273,25 @@ fn map_runtime<T>(result: Result<T, RuntimeError>) -> Result<T, OperationAdapter
result.map_err(|error| OperationAdapterError::Runtime(error.to_string()))
}
+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);
+ }
+ if matches!(&error, RuntimeError::Config(_)) {
+ return OperationAdapterError::InvalidInput {
+ operation_id: operation_id.to_owned(),
+ message,
+ };
+ }
+ OperationAdapterError::Runtime(message)
+}
+
fn required_string<P>(
request: &OperationRequest<P>,
key: &str,
diff --git a/src/runtime/accounts.rs b/src/runtime/accounts.rs
@@ -1,6 +1,6 @@
use std::path::Path;
-use radroots_identity::{IdentityError, load_identity_profile};
+use radroots_identity::{IdentityError, RadrootsIdentity, load_identity_profile};
use radroots_nostr_accounts::prelude::{
RadrootsNostrAccountRecord, RadrootsNostrAccountStatus, RadrootsNostrAccountsError,
RadrootsNostrAccountsManager,
@@ -91,6 +91,12 @@ pub struct AccountResolution {
pub default_account: Option<AccountRecordView>,
}
+#[derive(Debug, Clone)]
+pub struct AccountSigningIdentity {
+ pub account: AccountRecordView,
+ pub identity: RadrootsIdentity,
+}
+
pub fn create_or_migrate_default_account(
config: &RuntimeConfig,
) -> Result<AccountCreateResult, RuntimeError> {
@@ -259,6 +265,25 @@ pub fn resolved_account_signing_status(
)
}
+pub fn resolve_local_signing_identity(
+ config: &RuntimeConfig,
+) -> Result<AccountSigningIdentity, RuntimeError> {
+ 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(),
+ ));
+ };
+ 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
+ )));
+ };
+ Ok(AccountSigningIdentity { account, identity })
+}
+
pub fn account_summary_view(account: &AccountRecordView) -> AccountSummaryView {
AccountSummaryView::from_account_record(&account.record, account.signer, account.is_default)
}
diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs
@@ -18,6 +18,7 @@ use radroots_events::listing::{
use radroots_events::trade::RadrootsTradeListingValidationError;
use radroots_events_codec::d_tag::is_d_tag_base64url;
use radroots_events_codec::listing::encode::to_wire_parts_with_kind;
+use radroots_nostr::prelude::radroots_nostr_build_event;
use radroots_replica_db::ReplicaSql;
use radroots_sql_core::SqliteExecutor;
use radroots_trade::listing::publish::validate_listing_for_seller;
@@ -36,7 +37,7 @@ use crate::domain::runtime::{
};
use crate::runtime::RuntimeError;
use crate::runtime::accounts;
-use crate::runtime::config::RuntimeConfig;
+use crate::runtime::config::{RuntimeConfig, SignerBackend};
use crate::runtime::daemon;
use crate::runtime::daemon::DaemonRpcError;
use crate::runtime::farm_config;
@@ -47,6 +48,7 @@ const DRAFT_KIND: &str = "listing_draft_v1";
const LISTING_SOURCE: &str = "local draft · local first";
const LISTING_READ_SOURCE: &str = "local replica · local first";
const LISTING_WRITE_SOURCE: &str = "daemon bridge · durable write plane";
+const LISTING_LOCAL_SIGNED_SOURCE: &str = "local account signer · signed event artifact";
static D_TAG_COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -1122,6 +1124,19 @@ fn mutate(
});
}
+ if matches!(operation, ListingMutationOperation::Publish)
+ && matches!(config.signer.backend, SignerBackend::Local)
+ {
+ return local_signed_view(
+ config,
+ args,
+ operation,
+ &canonical,
+ listing_addr,
+ event_preview,
+ );
+ }
+
let signer_authority =
match resolve_actor_write_authority(config, "seller", canonical.seller_pubkey.as_str()) {
Ok(authority) => authority,
@@ -1562,9 +1577,11 @@ fn build_listing_event_preview(
ListingMutationEventView {
kind: KIND_LISTING,
author: canonical.seller_pubkey.clone(),
+ created_at: None,
content: parts.content,
tags: parts.tags,
event_id: None,
+ signature: None,
event_addr: validated.listing_addr.clone(),
},
validated.listing_addr,
@@ -1687,6 +1704,132 @@ fn daemon_error_view(
}
}
+fn local_signed_view(
+ config: &RuntimeConfig,
+ args: &ListingMutationArgs,
+ operation: ListingMutationOperation,
+ canonical: &CanonicalListingDraft,
+ listing_addr: String,
+ event_preview: ListingMutationEventView,
+) -> Result<ListingMutationView, RuntimeError> {
+ let signed_event = match sign_listing_event(config, canonical) {
+ Ok(event) => event,
+ Err(error) => {
+ return Ok(ListingMutationView {
+ state: "unconfigured".to_owned(),
+ operation: operation.as_str().to_owned(),
+ source: LISTING_LOCAL_SIGNED_SOURCE.to_owned(),
+ file: args.file.display().to_string(),
+ listing_id: canonical.listing_id.clone(),
+ listing_addr,
+ seller_pubkey: canonical.seller_pubkey.clone(),
+ event_kind: KIND_LISTING,
+ dry_run: false,
+ deduplicated: false,
+ job_id: None,
+ job_status: None,
+ signer_mode: Some(config.signer.backend.as_str().to_owned()),
+ event_id: None,
+ event_addr: None,
+ idempotency_key: args.idempotency_key.clone(),
+ signer_session_id: None,
+ requested_signer_session_id: args.signer_session_id.clone(),
+ reason: Some(error.to_string()),
+ job: args.print_job.then(|| ListingMutationJobView {
+ rpc_method: "local.listing.sign".to_owned(),
+ state: "unconfigured".to_owned(),
+ job_id: None,
+ idempotency_key: args.idempotency_key.clone(),
+ requested_signer_session_id: args.signer_session_id.clone(),
+ signer_mode: Some(config.signer.backend.as_str().to_owned()),
+ signer_session_id: None,
+ }),
+ event: args.print_event.then_some(event_preview),
+ actions: vec!["radroots signer status get".to_owned()],
+ });
+ }
+ };
+ let event_view = signed_listing_event_view(&signed_event, listing_addr.as_str());
+ Ok(ListingMutationView {
+ state: "signed".to_owned(),
+ operation: operation.as_str().to_owned(),
+ source: LISTING_LOCAL_SIGNED_SOURCE.to_owned(),
+ file: args.file.display().to_string(),
+ listing_id: canonical.listing_id.clone(),
+ listing_addr: listing_addr.clone(),
+ seller_pubkey: canonical.seller_pubkey.clone(),
+ event_kind: KIND_LISTING,
+ dry_run: false,
+ deduplicated: false,
+ job_id: None,
+ job_status: None,
+ signer_mode: Some(config.signer.backend.as_str().to_owned()),
+ event_id: event_view.event_id.clone(),
+ event_addr: Some(listing_addr),
+ idempotency_key: args.idempotency_key.clone(),
+ signer_session_id: None,
+ requested_signer_session_id: args.signer_session_id.clone(),
+ reason: Some("signed locally; relay delivery was not attempted".to_owned()),
+ job: args.print_job.then(|| ListingMutationJobView {
+ rpc_method: "local.listing.sign".to_owned(),
+ state: "not_submitted".to_owned(),
+ job_id: None,
+ idempotency_key: args.idempotency_key.clone(),
+ requested_signer_session_id: args.signer_session_id.clone(),
+ signer_mode: Some(config.signer.backend.as_str().to_owned()),
+ signer_session_id: None,
+ }),
+ event: Some(event_view),
+ actions: Vec::new(),
+ })
+}
+
+fn sign_listing_event(
+ config: &RuntimeConfig,
+ canonical: &CanonicalListingDraft,
+) -> Result<radroots_nostr::prelude::RadrootsNostrEvent, RuntimeError> {
+ let signing = accounts::resolve_local_signing_identity(config)?;
+ let account_pubkey = signing
+ .account
+ .record
+ .public_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 `{}`",
+ canonical.seller_pubkey
+ )));
+ }
+ let parts = to_wire_parts_with_kind(&canonical.listing, KIND_LISTING)
+ .map_err(|error| RuntimeError::Config(format!("invalid listing contract: {error}")))?;
+ let event = radroots_nostr_build_event(parts.kind, parts.content, parts.tags)
+ .map_err(|error| RuntimeError::Config(format!("build local listing event: {error}")))?
+ .sign_with_keys(signing.identity.keys())
+ .map_err(|error| RuntimeError::Config(format!("sign local listing event: {error}")))?;
+ Ok(event)
+}
+
+fn signed_listing_event_view(
+ event: &radroots_nostr::prelude::RadrootsNostrEvent,
+ listing_addr: &str,
+) -> ListingMutationEventView {
+ ListingMutationEventView {
+ kind: event.kind.as_u16() as u32,
+ author: event.pubkey.to_string(),
+ created_at: Some(u32::try_from(event.created_at.as_secs()).unwrap_or(u32::MAX)),
+ content: event.content.clone(),
+ tags: event
+ .tags
+ .iter()
+ .map(|tag| tag.as_slice().to_vec())
+ .collect(),
+ event_id: Some(event.id.to_string()),
+ signature: Some(event.sig.to_string()),
+ event_addr: listing_addr.to_owned(),
+ }
+}
+
fn binding_error_view(
config: &RuntimeConfig,
args: &ListingMutationArgs,
diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs
@@ -3,6 +3,7 @@ mod support;
use std::fs;
use std::path::{Path, PathBuf};
+use radroots_events::kinds::KIND_LISTING;
use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic};
use serde_json::{Value, json};
use support::RadrootsCliSandbox;
@@ -419,14 +420,106 @@ fn local_listing_publish_fails_without_local_account_authority() {
assert!(!output.status.success());
assert_eq!(value["operation_id"], "listing.publish");
assert_eq!(value["result"], serde_json::Value::Null);
- assert_eq!(value["errors"][0]["code"], "runtime_error");
- assert_eq!(value["errors"][0]["exit_code"], 1);
+ assert_eq!(value["errors"][0]["code"], "account_unresolved");
+ assert_eq!(value["errors"][0]["exit_code"], 5);
+ assert_eq!(value["errors"][0]["detail"]["class"], "account");
assert_contains(
&value["errors"][0]["message"],
"no local account is selected",
);
}
+#[test]
+fn local_listing_publish_signs_with_selected_account_without_remote_fallback() {
+ let sandbox = RadrootsCliSandbox::new();
+ sandbox.json_success(&["--format", "json", "account", "create"]);
+ let listing_file = create_listing_draft(&sandbox, "local-signed");
+ make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw");
+
+ let (output, value) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "--approval-token",
+ "approve",
+ "listing",
+ "publish",
+ listing_file.to_string_lossy().as_ref(),
+ ]);
+
+ assert!(output.status.success());
+ assert_eq!(value["operation_id"], "listing.publish");
+ assert_eq!(value["result"]["state"], "signed");
+ assert_eq!(value["result"]["signer_mode"], "local");
+ assert_eq!(
+ value["result"]["signer_session_id"],
+ serde_json::Value::Null
+ );
+ assert_eq!(value["result"]["job_id"], serde_json::Value::Null);
+ assert_eq!(value["result"]["event"]["kind"], KIND_LISTING);
+ assert_eq!(
+ value["result"]["event"]["author"],
+ value["result"]["seller_pubkey"]
+ );
+ assert_eq!(
+ value["result"]["event"]["event_id"],
+ value["result"]["event_id"]
+ );
+ assert_hex_len(&value["result"]["event_id"], 64);
+ assert_hex_len(&value["result"]["event"]["signature"], 128);
+ assert_contains(
+ &value["result"]["reason"],
+ "relay delivery was not attempted",
+ );
+ assert!(
+ value["result"]["event"]["tags"]
+ .as_array()
+ .expect("event tags")
+ .iter()
+ .any(|tag| tag
+ .as_array()
+ .is_some_and(|items| items.first() == Some(&json!("d"))
+ && items.get(1) == Some(&value["result"]["listing_id"])))
+ );
+}
+
+#[test]
+fn watch_only_listing_publish_fails_as_account_watch_only() {
+ let sandbox = RadrootsCliSandbox::new();
+ let public_identity = identity_public(12);
+ let public_identity_file =
+ write_public_identity_profile(&sandbox, "watch-only-publish", &public_identity);
+ sandbox.json_success(&[
+ "--format",
+ "json",
+ "--approval-token",
+ "approve",
+ "account",
+ "import",
+ "--default",
+ public_identity_file.to_string_lossy().as_ref(),
+ ]);
+ let listing_file = create_listing_draft(&sandbox, "watch-only-publish");
+ make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw");
+
+ let (output, value) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "--approval-token",
+ "approve",
+ "listing",
+ "publish",
+ listing_file.to_string_lossy().as_ref(),
+ ]);
+
+ assert!(!output.status.success());
+ assert_eq!(value["operation_id"], "listing.publish");
+ assert_eq!(value["result"], serde_json::Value::Null);
+ 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");
+}
+
#[cfg(unix)]
#[test]
fn myc_listing_publish_does_not_fallback_to_local_account() {
@@ -573,6 +666,12 @@ fn assert_contains(value: &Value, needle: &str) {
);
}
+fn assert_hex_len(value: &Value, expected_len: usize) {
+ let value = value.as_str().expect("hex string");
+ assert_eq!(value.len(), expected_len);
+ assert!(value.chars().all(|ch| ch.is_ascii_hexdigit()));
+}
+
fn create_listing_draft(sandbox: &RadrootsCliSandbox, key: &str) -> PathBuf {
let listing_file = sandbox.root().join(format!("{key}.toml"));
let listing_file_arg = listing_file.to_string_lossy();