cli

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

commit 63e3e1bedf1482f5b4dd3ed256e803c933b70be8
parent 88cfcaefee7634ff971ef2cfd103917940f15231
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 14:55:03 +0000

cli: migrate signer integrations

- compose local accounts through the final concrete Nostr signer
- drive NIP-46 protocol state through the lower canonical client
- keep relay execution and authentication presentation host-owned
- remove retired SDK signer and duplicate permission abstractions

Diffstat:
MCargo.lock | 1+
MCargo.toml | 5+++--
Msrc/ops/exec/core.rs | 6++----
Msrc/runtime/account.rs | 178++++++++++++++++++++++++++++++++++++++++---------------------------------------
Msrc/runtime/farm.rs | 93+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Msrc/runtime/listing.rs | 75++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Msrc/runtime/mod.rs | 1+
Msrc/runtime/sdk.rs | 514++++---------------------------------------------------------------------------
Msrc/runtime/signer.rs | 187++++++++++++++++++++++---------------------------------------------------------
Asrc/runtime/signing.rs | 619+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/trade.rs | 66+++++++++++++++++++++++++++++++++++-------------------------------
Msrc/view/runtime.rs | 17+++++++++--------
12 files changed, 939 insertions(+), 823 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -2116,6 +2116,7 @@ dependencies = [ "flate2", "getrandom 0.2.17", "nostr", + "nostr-sdk", "radroots_authority", "radroots_core", "radroots_event", diff --git a/Cargo.toml b/Cargo.toml @@ -26,6 +26,8 @@ chacha20poly1305 = "0.10" chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } clap = { version = "4.5", features = ["derive"] } getrandom = "0.2" +nostr = { version = "0.44.2", features = ["nip44"] } +nostr-sdk = "0.44.1" radroots_authority = { version = "=0.1.0-alpha", default-features = false, features = ["std"] } radroots_core = { version = "=0.1.0-alpha", features = ["std", "serde"] } radroots_event = "=0.1.0-alpha" @@ -35,7 +37,7 @@ radroots_runtime_store = "=0.1.0-alpha" radroots_log = "=0.1.0-alpha" radroots_mesh = { version = "=0.1.0-alpha", default-features = false } radroots_nostr_accounts = { version = "=0.1.0-alpha", features = ["os-keyring"] } -radroots_nostr = { version = "=0.1.0-alpha", features = ["events"] } +radroots_nostr = { version = "=0.1.0-alpha", features = ["events", "signing"] } radroots_nostr_connect = "=0.1.0-alpha" radroots_nostr_signer = "=0.1.0-alpha" radroots_protected_store = { version = "=0.1.0-alpha", features = ["std"] } @@ -65,7 +67,6 @@ zeroize = "1.8" [dev-dependencies] assert_cmd = "2.0" flate2 = "1" -nostr = { version = "0.44.2", features = ["nip44"] } radroots_outbox = "=0.1.0-alpha" tar = "0.4" tempfile = "3.17" diff --git a/src/ops/exec/core.rs b/src/ops/exec/core.rs @@ -237,7 +237,7 @@ impl OperationService<AccountRemoveRequest> for CoreOperationService<'_> { let result = remove_account(self.config, selector.as_str()).map_err(|error| { OperationAdapterError::unconfigured(request.operation_id(), error.to_string()) })?; - let removed_account_id = result.removed_account.record.account_id.to_string(); + let removed_account_id = result.removed_account.record.id().to_string(); let farm_orphan_warning = account_remove_farm_orphan_warning(resolved_farm_config.as_ref(), &removed_account_id); let mut result_value = json!({ @@ -549,9 +549,7 @@ fn nostr_publish_readiness( return ( "unconfigured", false, - Some( - AccountRuntimeFailure::watch_only(&resolved_account.record.account_id).to_string(), - ), + Some(AccountRuntimeFailure::watch_only(&resolved_account.record.id()).to_string()), ); } diff --git a/src/runtime/account.rs b/src/runtime/account.rs @@ -1,12 +1,10 @@ use std::{fmt, path::Path, sync::Arc}; use radroots_identity::{ - IdentityError, RadrootsIdentity, RadrootsIdentityPublic, load_identity_profile, -}; -use radroots_nostr_accounts::prelude::{ - RadrootsNostrAccountRecord, RadrootsNostrAccountStatus, RadrootsNostrAccountsError, - RadrootsNostrAccountsManager, + AccountId, PublicIdentity, + account::{Record as AccountRecord, Status as AccountStatus}, }; +use radroots_nostr_accounts::prelude::{RadrootsNostrAccountsError, RadrootsNostrAccountsManager}; use radroots_protected_store::RadrootsProtectedFileSecretVault; use radroots_secret_vault::{ RadrootsHostVaultCapabilities, RadrootsResolvedSecretBackend, RadrootsSecretBackend, @@ -65,7 +63,7 @@ impl AccountRuntimeFailure { Self::Unresolved(AccountRuntimeFailureIssue::with_detail(message, detail)) } - pub fn watch_only(account_id: &radroots_identity::RadrootsIdentityId) -> Self { + pub fn watch_only(account_id: &AccountId) -> Self { Self::WatchOnly(AccountRuntimeFailureIssue::new(format!( "resolved account `{account_id}` is watch_only and cannot sign because it is not secret-backed" ))) @@ -123,7 +121,7 @@ pub struct AccountSnapshot { #[derive(Debug, Clone)] pub struct AccountRecordView { - pub record: RadrootsNostrAccountRecord, + pub record: AccountRecord, pub is_default: bool, pub custody: AccountCustody, pub write_capable: bool, @@ -213,15 +211,15 @@ pub struct AccountResolution { pub default_account: Option<AccountRecordView>, } -#[derive(Debug, Clone)] -pub struct AccountSigningIdentity { +#[derive(Debug)] +pub struct AccountLocalSigner { pub account: AccountRecordView, - pub identity: RadrootsIdentity, + pub signer: radroots_nostr::signing::LocalSigner, } pub fn create_default_account(config: &RuntimeConfig) -> Result<AccountCreateResult, RuntimeError> { let manager = account_manager(config)?; - let created_account_id = manager.generate_identity(None, false)?; + let created_account_id = manager.generate_keys(None, false)?; let snapshot = snapshot(config)?; let account = snapshot_account( @@ -261,7 +259,7 @@ pub fn preview_public_identity_import( if let Some(existing) = snapshot .accounts .iter() - .find(|account| account.record.account_id == public_identity.id) + .find(|account| account.record.id() == AccountId::from(&public_identity)) .cloned() { let mut account = existing; @@ -272,7 +270,7 @@ pub fn preview_public_identity_import( } Ok(AccountRecordView { - record: RadrootsNostrAccountRecord::new(public_identity, None, 0), + record: AccountRecord::new(public_identity, None, 0), is_default: make_default, custody: AccountCustody::WatchOnly, write_capable: false, @@ -288,8 +286,8 @@ pub fn preview_identity_secret_attachment( let manager = account_manager(config)?; let snapshot = snapshot_from_manager(&manager)?; let mut account = resolve_selector_account(&manager, &snapshot, selector)?; - let identity = load_secret_identity_for_attachment(path)?; - validate_identity_secret_matches_account(&account.record, &identity)?; + let secret = load_secret_key_for_attachment(path)?; + validate_secret_matches_account(&account.record, &secret)?; if make_default { account.is_default = true; } @@ -307,14 +305,14 @@ pub fn attach_identity_secret( let manager = account_manager(config)?; let snapshot = snapshot_from_manager(&manager)?; let account = resolve_selector_account(&manager, &snapshot, selector)?; - let identity = load_secret_identity_for_attachment(path)?; - validate_identity_secret_matches_account(&account.record, &identity)?; - let attached = - manager.attach_identity_secret(&account.record.account_id, &identity, make_default)?; + let secret = load_secret_key_for_attachment(path)?; + validate_secret_matches_account(&account.record, &secret)?; + let keys = nostr::Keys::new(secret); + let attached = manager.attach_secret_keys(&account.record.id(), &keys, make_default)?; let snapshot = snapshot_from_manager(&manager)?; snapshot_account( &snapshot, - &attached.account_id, + &attached.id(), "attached account missing after account secret attachment", ) } @@ -366,12 +364,12 @@ pub fn select_account( let snapshot = snapshot_from_manager(&manager)?; let account = resolve_selector_account(&manager, &snapshot, selector)?; - manager.set_default_account(&account.record.account_id)?; + manager.set_default_account(&account.record.id())?; let snapshot = snapshot_from_manager(&manager)?; snapshot .accounts .into_iter() - .find(|candidate| candidate.record.account_id == account.record.account_id) + .find(|candidate| candidate.record.id() == account.record.id()) .ok_or_else(|| { RuntimeError::Accounts( radroots_nostr_accounts::prelude::RadrootsNostrAccountsError::InvalidState( @@ -416,7 +414,7 @@ pub fn remove_account( let snapshot = snapshot_from_manager(&manager)?; let removed_account = resolve_selector_account(&manager, &snapshot, selector)?; let default_cleared = removed_account.is_default; - manager.remove_account(&removed_account.record.account_id)?; + manager.remove_account(&removed_account.record.id())?; let remaining_account_count = snapshot_from_manager(&manager)?.accounts.len(); Ok(AccountRemoveResult { removed_account, @@ -441,49 +439,52 @@ pub fn preview_account_removal( pub fn resolved_account_signing_status( config: &RuntimeConfig, -) -> Result<RadrootsNostrAccountStatus, RuntimeError> { +) -> Result<AccountStatus, RuntimeError> { let manager = account_manager(config)?; let resolution = resolve_account_resolution(config)?; let Some(account) = resolution.resolved_account else { - return Ok(RadrootsNostrAccountStatus::NotConfigured); + return Ok(AccountStatus::NotConfigured); }; - Ok( - match manager.get_signing_identity(&account.record.account_id)? { - Some(_) => RadrootsNostrAccountStatus::Ready { - account: account.record.clone(), - }, - None => RadrootsNostrAccountStatus::PublicOnly { - account: account.record.clone(), - }, + Ok(match manager.get_signing_keys(&account.record.id())? { + Some(_) => AccountStatus::Ready { + account: account.record.clone(), }, - ) + None => AccountStatus::PublicOnly { + account: account.record.clone(), + }, + }) } pub fn resolve_local_signing_identity( config: &RuntimeConfig, -) -> Result<AccountSigningIdentity, RuntimeError> { +) -> Result<AccountLocalSigner, RuntimeError> { let manager = account_manager(config)?; let resolution = resolve_account_resolution(config)?; let Some(account) = resolution.resolved_account else { return Err(AccountRuntimeFailure::unresolved(unresolved_account_reason(config)?).into()); }; - let Some(identity) = manager.get_signing_identity(&account.record.account_id)? else { - return Err(AccountRuntimeFailure::watch_only(&account.record.account_id).into()); + let Some(secret) = manager.export_secret_hex(&account.record.id())? else { + return Err(AccountRuntimeFailure::watch_only(&account.record.id()).into()); }; - Ok(AccountSigningIdentity { account, identity }) + let secret = zeroize::Zeroizing::new(secret); + let secret = radroots_nostr::key::parse_secret_key(secret.trim()) + .map_err(|_| RuntimeError::Config("local account secret is invalid".to_owned()))?; + let signer = radroots_nostr::signing::LocalSigner::new(secret) + .map_err(|error| RuntimeError::Config(error.to_string()))?; + Ok(AccountLocalSigner { account, signer }) } pub fn resolve_local_signing_identity_for_account( config: &RuntimeConfig, account_id: &str, -) -> Result<AccountSigningIdentity, RuntimeError> { +) -> Result<AccountLocalSigner, RuntimeError> { let manager = account_manager(config)?; let snapshot = snapshot_from_manager(&manager)?; let Some(account) = snapshot .accounts .iter() - .find(|account| account.record.account_id.as_str() == account_id) + .find(|account| account.record.id().to_hex() == account_id) .cloned() else { return Err(AccountRuntimeFailure::unresolved(format!( @@ -491,10 +492,15 @@ pub fn resolve_local_signing_identity_for_account( )) .into()); }; - let Some(identity) = manager.get_signing_identity(&account.record.account_id)? else { - return Err(AccountRuntimeFailure::watch_only(&account.record.account_id).into()); + let Some(secret) = manager.export_secret_hex(&account.record.id())? else { + return Err(AccountRuntimeFailure::watch_only(&account.record.id()).into()); }; - Ok(AccountSigningIdentity { account, identity }) + let secret = zeroize::Zeroizing::new(secret); + let secret = radroots_nostr::key::parse_secret_key(secret.trim()) + .map_err(|_| RuntimeError::Config("local account secret is invalid".to_owned()))?; + let signer = radroots_nostr::signing::LocalSigner::new(secret) + .map_err(|error| RuntimeError::Config(error.to_string()))?; + Ok(AccountLocalSigner { account, signer }) } pub fn account_summary_view(account: &AccountRecordView) -> AccountSummaryView { @@ -599,7 +605,7 @@ fn snapshot_from_manager( for record in manager.list_accounts()? { let is_default = default_account_id .as_deref() - .is_some_and(|default| default == record.account_id.as_str()); + .is_some_and(|default| default == record.id().to_hex()); let runtime = account_runtime_facts(manager, &record)?; accounts.push(AccountRecordView { record, @@ -614,13 +620,13 @@ fn snapshot_from_manager( fn snapshot_account( snapshot: &AccountSnapshot, - account_id: &radroots_identity::RadrootsIdentityId, + account_id: &AccountId, missing_message: &str, ) -> Result<AccountRecordView, RuntimeError> { snapshot .accounts .iter() - .find(|account| account.record.account_id == *account_id) + .find(|account| account.record.id() == *account_id) .cloned() .ok_or_else(|| { RuntimeError::Accounts( @@ -642,7 +648,7 @@ fn resolve_selector_account( snapshot .accounts .iter() - .find(|account| account.record.account_id == record.account_id) + .find(|account| account.record.id() == record.id()) .cloned() .ok_or_else(|| { RuntimeError::Accounts(RadrootsNostrAccountsError::InvalidState( @@ -673,66 +679,64 @@ fn selector_runtime_error(selector: &str, error: RadrootsNostrAccountsError) -> fn account_runtime_facts( manager: &RadrootsNostrAccountsManager, - record: &RadrootsNostrAccountRecord, + record: &AccountRecord, ) -> Result<AccountRuntimeFacts, RuntimeError> { - Ok( - if manager.get_signing_identity(&record.account_id)?.is_some() { - AccountRuntimeFacts { - custody: AccountCustody::SecretBacked, - write_capable: true, - } - } else { - AccountRuntimeFacts { - custody: AccountCustody::WatchOnly, - write_capable: false, - } - }, - ) -} - -fn format_identity_error(error: IdentityError) -> String { - match error { - IdentityError::NotFound(path) => format!("path not found: {}", path.display()), - other => other.to_string(), - } + Ok(if manager.get_signing_keys(&record.id())?.is_some() { + AccountRuntimeFacts { + custody: AccountCustody::SecretBacked, + write_capable: true, + } + } else { + AccountRuntimeFacts { + custody: AccountCustody::WatchOnly, + write_capable: false, + } + }) } -fn load_public_identity_for_import(path: &Path) -> Result<RadrootsIdentityPublic, RuntimeError> { - load_identity_profile(path).map_err(|error| { +fn load_public_identity_for_import(path: &Path) -> Result<PublicIdentity, RuntimeError> { + let bytes = std::fs::read(path).map_err(|error| { RuntimeError::Config(format!( - "failed to import account from {}: {}", - path.display(), - format_identity_error(error) + "failed to read account import {}: {error}", + path.display() + )) + })?; + serde_json::from_slice(&bytes).map_err(|error| { + RuntimeError::Config(format!( + "failed to import canonical public identity from {}: {error}", + path.display() )) }) } -fn load_secret_identity_for_attachment(path: &Path) -> Result<RadrootsIdentity, RuntimeError> { - RadrootsIdentity::load_from_path_auto(path).map_err(|error| { +fn load_secret_key_for_attachment(path: &Path) -> Result<nostr::SecretKey, RuntimeError> { + let secret = std::fs::read_to_string(path).map_err(|error| { RuntimeError::Config(format!( - "failed to import account secret from {}: {}", + "failed to read account secret from {}: {error}", path.display(), - format_identity_error(error) + )) + })?; + nostr::SecretKey::parse(secret.trim()).map_err(|_| { + RuntimeError::Config(format!( + "failed to import account secret from {}: invalid hex or nsec key", + path.display() )) }) } -fn validate_identity_secret_matches_account( - record: &RadrootsNostrAccountRecord, - identity: &RadrootsIdentity, +fn validate_secret_matches_account( + record: &AccountRecord, + secret: &nostr::SecretKey, ) -> Result<(), RuntimeError> { - let secret_public_key_hex = identity.public_key_hex(); - if record - .public_identity - .public_key_hex - .eq_ignore_ascii_case(secret_public_key_hex.as_str()) - { + let secret_public_key_hex = nostr::Keys::new(secret.clone()).public_key().to_hex(); + let public_key_hex = record.public_identity().public_key().to_hex(); + if public_key_hex.eq_ignore_ascii_case(secret_public_key_hex.as_str()) { return Ok(()); } 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 + record.id(), public_key_hex, secret_public_key_hex )) .into()) } diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs @@ -87,8 +87,9 @@ pub fn init_preflight( Some( selected_account .record - .public_identity - .public_key_hex + .public_identity() + .public_key() + .to_hex() .as_str(), ), )), @@ -143,10 +144,14 @@ fn rebind_inner( let from_account = configured_account(config, &resolved.document.selection.account)?; let from_seller_pubkey = from_account .as_ref() - .map(|account| account.record.public_identity.public_key_hex.clone()); + .map(|account| account.record.public_identity().public_key().to_hex()); let target_account = account::resolve_account_selector(config, args.selector.as_str()) .map_err(|error| farm_rebind_selector_error(args.selector.as_str(), error))?; - let to_seller_pubkey = target_account.record.public_identity.public_key_hex.clone(); + let to_seller_pubkey = target_account + .record + .public_identity() + .public_key() + .to_hex(); let seller_pubkey_changed = from_seller_pubkey .as_deref() .is_none_or(|pubkey| !pubkey.eq_ignore_ascii_case(to_seller_pubkey.as_str())); @@ -156,7 +161,7 @@ fn rebind_inner( "preserved" }; let mut document = resolved.document.clone(); - document.selection.account = target_account.record.account_id.to_string(); + document.selection.account = target_account.record.id().to_string(); if seller_pubkey_changed { document.publication = FarmPublicationStatus::default(); } @@ -185,7 +190,7 @@ fn rebind_inner( seller_actor_source: FARM_SELLER_ACTOR_SOURCE.to_owned(), from_seller_account_id: Some(resolved.document.selection.account.clone()), from_seller_pubkey, - to_seller_account_id: Some(target_account.record.account_id.to_string()), + to_seller_account_id: Some(target_account.record.id().to_string()), to_seller_pubkey: Some(to_seller_pubkey.clone()), seller_pubkey_changed: Some(seller_pubkey_changed), publication_state_action: Some(publication_state_action.to_owned()), @@ -246,9 +251,10 @@ pub fn set(config: &RuntimeConfig, args: &FarmUpdateArgs) -> Result<FarmSetView, apply_field_update(&mut resolved.document, args.field, field_value.as_str())?; let written_path = farm_config::write(&config.paths, resolved.scope, &resolved.document)?; let configured_account = configured_account(config, &resolved.document.selection.account)?; - let account_pubkey = configured_account + let account_public_key_hex = configured_account .as_ref() - .map(|account| account.record.public_identity.public_key_hex.as_str()); + .map(|account| account.record.public_identity().public_key().to_hex()); + let account_pubkey = account_public_key_hex.as_deref(); append_farm_local_work( config, resolved.scope, @@ -303,9 +309,10 @@ pub fn set_preflight( let field_value = required_text(raw_value.as_str(), "farm set value")?; apply_field_update(&mut resolved.document, args.field, field_value.as_str())?; let configured_account = configured_account(config, &resolved.document.selection.account)?; - let account_pubkey = configured_account + let account_public_key_hex = configured_account .as_ref() - .map(|account| account.record.public_identity.public_key_hex.as_str()); + .map(|account| account.record.public_identity().public_key().to_hex()); + let account_pubkey = account_public_key_hex.as_deref(); let reason = if configured_account.is_none() { Some(format!( "dry run requested; farm draft was not written; {}", @@ -439,9 +446,10 @@ pub fn status( } else { actions.extend(missing_field_actions(draft_missing.as_slice())); } - let account_pubkey = account + let account_public_key_hex = account .as_ref() - .map(|account| account.record.public_identity.public_key_hex.as_str()); + .map(|account| account.record.public_identity().public_key().to_hex()); + let account_pubkey = account_public_key_hex.as_deref(); Ok(FarmStatusView { state: state.to_owned(), @@ -555,8 +563,13 @@ fn transport_farm_publish_readiness( if matches!(config.signer.backend, SignerBackend::Myc) { if let Err(error) = validate_configured_signer_for_actor( config, - Some(account.record.account_id.as_str()), - account.record.public_identity.public_key_hex.as_str(), + Some(account.record.id().to_hex().as_str()), + account + .record + .public_identity() + .public_key() + .to_hex() + .as_str(), "farm seller", ) { return FarmPublishReadiness { @@ -581,7 +594,7 @@ fn transport_farm_publish_readiness( state: "unconfigured", executable: false, reason: Some( - account::AccountRuntimeFailure::watch_only(&account.record.account_id).to_string(), + account::AccountRuntimeFailure::watch_only(&account.record.id()).to_string(), ), missing: vec!["Write-capable farm-bound seller account".to_owned()], actions: vec!["radroots account create".to_owned()], @@ -653,11 +666,11 @@ pub fn publish( config.output.dry_run, true, resolved.document.selection.account.clone(), - account.record.public_identity.public_key_hex.clone(), + account.record.public_identity().public_key().to_hex(), resolved.document.selection.farm_d_tag.clone(), )); } - let account_pubkey = account.record.public_identity.public_key_hex.clone(); + let account_pubkey = account.record.public_identity().public_key().to_hex(); let previews = build_publish_previews(&resolved.document, account_pubkey.as_str())?; let profile_idempotency_key = component_idempotency_key(args, "profile")?; let farm_idempotency_key = component_idempotency_key(args, "farm")?; @@ -1090,7 +1103,7 @@ fn sdk_prepared_publish_view( state: "not_submitted".to_owned(), reason: Some("dry run requested; SDK enqueue and transport push skipped".to_owned()), signer_mode: Some(config.signer.backend.as_str().to_owned()), - event_id: Some(plan.draft().expected_event_id().as_str().to_owned()), + event_id: Some(plan.draft().expected_event_id().to_string()), event_addr: Some(plan.coordinate().as_str().to_owned()), event: args.print_event.then_some(sdk_plan_event_view(&plan)), ..preview_component( @@ -1112,7 +1125,7 @@ fn sdk_plan_event_view(plan: &FarmPlan) -> FarmPublishEventView { author: plan.draft().expected_pubkey().to_hex(), content: plan.draft().content().to_owned(), tags: plan.draft().tags_as_vec(), - event_id: Some(plan.draft().expected_event_id().as_str().to_owned()), + event_id: Some(plan.draft().expected_event_id().to_string()), event_addr: Some(plan.coordinate().as_str().to_owned()), } } @@ -1166,8 +1179,8 @@ fn private_location_target( let Some(account) = configured_account(config, &resolved.document.selection.account)? else { return Ok(None); }; - let seller_pubkey = account.record.public_identity.public_key_hex.clone(); - let seller_account_id = account.record.account_id.to_string(); + let seller_pubkey = account.record.public_identity().public_key().to_hex(); + let seller_account_id = account.record.id().to_string(); let farm_d_tag = farm_d_tag .map(str::to_owned) .unwrap_or_else(|| resolved.document.selection.farm_d_tag.clone()); @@ -1233,19 +1246,20 @@ fn init_document( ) -> Result<FarmConfigDocument, RuntimeError> { let existing_document = existing.map(|resolved| &resolved.document); if let Some(document) = existing_document - && document.selection.account != account.record.account_id.to_string() + && document.selection.account != account.record.id().to_string() { let message = format!( "account mismatch: farm config is bound to seller account `{}`; select account `{}` before updating this farm config", - document.selection.account, account.record.account_id + document.selection.account, + account.record.id() ); return Err(account::AccountRuntimeFailure::mismatch_with_detail( message, json!({ "seller_actor_source": FARM_SELLER_ACTOR_SOURCE, "farm_bound_seller_account_id": document.selection.account, - "attempted_seller_account_id": account.record.account_id.to_string(), - "actions": farm_rebind_recovery_actions(account.record.account_id.as_str()), + "attempted_seller_account_id": account.record.id().to_string(), + "actions": farm_rebind_recovery_actions(account.record.id().to_hex().as_str()), }), ) .into()); @@ -1304,7 +1318,7 @@ fn init_document( version: SUPPORTED_FARM_CONFIG_VERSION, selection: FarmConfigSelection { scope, - account: account.record.account_id.to_string(), + account: account.record.id().to_string(), farm_d_tag: farm_d_tag.clone(), }, profile: FarmProfileDraft { @@ -1364,7 +1378,14 @@ fn save_draft_view( scope, written_path.display().to_string(), document, - Some(account.record.public_identity.public_key_hex.as_str()), + Some( + account + .record + .public_identity() + .public_key() + .to_hex() + .as_str(), + ), )?; Ok(FarmSetupView { state: state.to_owned(), @@ -1373,7 +1394,14 @@ fn save_draft_view( scope, written_path.display().to_string(), document, - Some(account.record.public_identity.public_key_hex.as_str()), + Some( + account + .record + .public_identity() + .public_key() + .to_hex() + .as_str(), + ), )), reason, actions, @@ -1625,7 +1653,7 @@ fn publication_for_document( existing_document .filter(|document| { document.farm.d_tag == farm_d_tag - && document.selection.account == account.record.account_id.as_str() + && document.selection.account == account.record.id().to_hex() }) .map(|document| document.publication.clone()) .unwrap_or_default() @@ -1639,7 +1667,7 @@ fn configured_account( Ok(snapshot .accounts .into_iter() - .find(|account| account.record.account_id.as_str() == account_id)) + .find(|account| account.record.id().to_hex() == account_id)) } fn summary_view( @@ -1750,10 +1778,9 @@ fn optional_arg_or_existing(arg: Option<&String>, existing: Option<&String>) -> fn draft_name_from_account(account: &AccountRecordView) -> Option<String> { account .record - .label - .as_deref() + .label() .and_then(non_empty) - .or_else(|| non_empty(account.record.account_id.as_str())) + .or_else(|| non_empty(account.record.id().to_hex().as_str())) } fn existing_name(existing_document: Option<&FarmConfigDocument>) -> Option<String> { diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -923,8 +923,12 @@ fn rebind_inner( let from_seller_pubkey = non_empty(draft.seller_actor.pubkey.clone()); let from_seller_actor_source = non_empty(draft.seller_actor.source.clone()); let from_farm_d_tag = non_empty(draft.listing.farm_d_tag.clone()); - let target_account_id = target_account.record.account_id.to_string(); - let target_pubkey = target_account.record.public_identity.public_key_hex.clone(); + let target_account_id = target_account.record.id().to_string(); + let target_pubkey = target_account + .record + .public_identity() + .public_key() + .to_hex(); let target_farm_d_tag = resolve_rebind_farm_d_tag( config, args, @@ -1819,7 +1823,7 @@ fn sdk_prepared_publish_view( job_id: None, job_status: None, signer_mode: Some(config.signer.backend.as_str().to_owned()), - event_id: Some(plan.draft().expected_event_id().as_str().to_owned()), + event_id: Some(plan.draft().expected_event_id().to_string()), event_addr: Some(listing_addr), idempotency_key: args.idempotency_key.clone(), local_replica: None, @@ -1837,7 +1841,7 @@ fn sdk_plan_event_view(plan: &ListingPlan) -> ListingMutationEventView { created_at: Some(plan.draft().created_at_u64()), content: plan.draft().content().to_owned(), tags: plan.draft().tags_as_vec(), - event_id: Some(plan.draft().expected_event_id().as_str().to_owned()), + event_id: Some(plan.draft().expected_event_id().to_string()), signature: None, event_addr: plan.address().as_str().to_owned(), } @@ -2083,7 +2087,14 @@ fn canonicalize_draft( contents, "primary_bin.quantity_unit", )?; - let quantity = Quantity::new(quantity_amount, quantity_unit) + let quantity = Quantity::try_new(quantity_amount, quantity_unit) + .map_err(|error| { + issue_for_field( + contents, + "primary_bin.quantity_amount", + format!("invalid primary_bin quantity: {error}"), + ) + })? .with_optional_label(non_empty(draft.primary_bin.label.clone())) .to_canonical() .map_err(|error| { @@ -2114,18 +2125,22 @@ fn canonicalize_draft( contents, "primary_bin.price_per_unit", )?; - let price = QuantityPrice::new( - Money::new(price_amount, price_currency), - Quantity::new(price_per_amount, price_per_unit), - ) - .try_to_canonical_unit_price() - .map_err(|error| { - issue_for_field( - contents, - "primary_bin.price_per_unit", - format!("invalid primary_bin price definition: {error:?}"), - ) + let price_money = Money::try_new(price_amount, price_currency).map_err(|error| { + issue_for_field(contents, "primary_bin.price_amount", error.to_string()) + })?; + let price_quantity = Quantity::try_new(price_per_amount, price_per_unit).map_err(|error| { + issue_for_field(contents, "primary_bin.price_per_amount", error.to_string()) })?; + let price = QuantityPrice::try_new(price_money, price_quantity) + .map_err(|error| issue_for_field(contents, "primary_bin.price_amount", error.to_string()))? + .try_to_canonical_unit_price() + .map_err(|error| { + issue_for_field( + contents, + "primary_bin.price_per_unit", + format!("invalid primary_bin price definition: {error:?}"), + ) + })?; let inventory_available = parse_decimal_field( draft.inventory.available.as_str(), @@ -2355,7 +2370,9 @@ fn build_listing_discounts( field_prefix.as_str(), )? }; - DiscountValue::MoneyPerBin(Money::new(amount, currency)) + DiscountValue::MoneyPerBin(Money::try_new(amount, currency).map_err(|error| { + issue_for_field(contents, field_prefix.as_str(), error.to_string()) + })?) } other => { return Err(issue_for_field( @@ -2397,7 +2414,7 @@ fn listing_bound_account_issue( ), ))); }; - let account_pubkey = account.record.public_identity.public_key_hex; + let account_pubkey = account.record.public_identity().public_key().to_hex(); if !account_pubkey.eq_ignore_ascii_case(canonical.seller_pubkey.as_str()) { return Ok(Some(issue_for_field( contents, @@ -2432,7 +2449,7 @@ fn ensure_listing_bound_account( ) .into()); }; - let account_pubkey = account.record.public_identity.public_key_hex; + let account_pubkey = account.record.public_identity().public_key().to_hex(); if !account_pubkey.eq_ignore_ascii_case(canonical.seller_pubkey.as_str()) { return Err(account::AccountRuntimeFailure::mismatch_with_detail( format!( @@ -2468,18 +2485,18 @@ fn validate_invocation_account_matches_bound( return Ok(()); }; let attempted = account::resolve_account_selector(config, selector)?; - if attempted.record.account_id.to_string() == canonical.seller_account_id { + if attempted.record.id().to_string() == canonical.seller_account_id { return Ok(()); } Err(account::AccountRuntimeFailure::mismatch_with_detail( format!( "account mismatch: listing draft is bound to seller account `{}`; invocation selected `{}`", - canonical.seller_account_id, attempted.record.account_id + canonical.seller_account_id, attempted.record.id() ), json!({ "seller_actor_source": canonical.seller_actor_source, "listing_seller_account_id": canonical.seller_account_id, - "attempted_seller_account_id": attempted.record.account_id.to_string(), + "attempted_seller_account_id": attempted.record.id().to_string(), "listing_file": file.display().to_string(), "actions": listing_bound_account_recovery_actions(file), }), @@ -2543,7 +2560,7 @@ fn validate_configured_listing_signer( fn validate_operational_listing_draft( canonical: &CanonicalListingDraft, ) -> Result<(), OperationalListingValidationError> { - let seller_pubkey = PublicKey::parse(canonical.seller_pubkey.as_str()) + let seller_pubkey = PublicKey::from_hex(canonical.seller_pubkey.as_str()) .map_err(|_| OperationalListingValidationError::InvalidSeller)?; validate_operational_listing_model(canonical.listing.clone(), &seller_pubkey).map(|_| ()) } @@ -2628,12 +2645,12 @@ fn authoring_defaults(config: &RuntimeConfig) -> Result<ListingAuthoringDefaults .to_owned(), ), farm_name: None, - seller_account_id: selected_account.record.account_id.to_string(), + seller_account_id: selected_account.record.id().to_string(), seller_pubkey: selected_account .record - .public_identity - .public_key_hex - .clone(), + .public_identity() + .public_key() + .to_hex(), seller_actor_source: LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT.to_owned(), selected_farm_d_tag: None, delivery_method: None, @@ -2668,7 +2685,7 @@ fn authoring_defaults(config: &RuntimeConfig) -> Result<ListingAuthoringDefaults .or_else(|| non_empty(resolved.document.profile.name.clone())) .or_else(|| non_empty(resolved.document.farm.name.clone())); defaults.seller_account_id = resolved.document.selection.account.clone(); - defaults.seller_pubkey = account.record.public_identity.public_key_hex.clone(); + defaults.seller_pubkey = account.record.public_identity().public_key().to_hex(); defaults.seller_actor_source = LISTING_SELLER_ACTOR_SOURCE_FARM_CONFIG.to_owned(); defaults.selected_farm_d_tag = Some(resolved.document.selection.farm_d_tag.clone()); let draft_missing = farm_config::missing_fields(&resolved.document); @@ -2739,7 +2756,7 @@ fn configured_account( Ok(snapshot .accounts .into_iter() - .find(|account| account.record.account_id.as_str() == account_id)) + .find(|account| account.record.id().to_hex() == account_id)) } fn parse_decimal_field( diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs @@ -12,6 +12,7 @@ pub mod provider; pub mod runtime_store; pub mod sdk; pub mod signer; +pub mod signing; pub mod store; pub mod sync; pub mod trade; diff --git a/src/runtime/sdk.rs b/src/runtime/sdk.rs @@ -1,28 +1,14 @@ use std::fs; use std::future::Future; use std::path::PathBuf; -use std::sync::Arc; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use radroots_authority::RadrootsLocalEventSigner; -use radroots_identity::RadrootsIdentity; -use radroots_nostr::prelude::{ - RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrFilter, RadrootsNostrKeys, - RadrootsNostrKind, RadrootsNostrRelayPoolNotification, RadrootsNostrTimestamp, - radroots_nostr_filter_tag, -}; -use radroots_nostr_connect::prelude::{ - RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectBunkerUri, - RadrootsNostrConnectClientTarget, RadrootsNostrConnectError, RadrootsNostrConnectUri, -}; +use std::time::{SystemTime, UNIX_EPOCH}; + use radroots_sdk::{ Client, ClientBuilder, Error as SdkError, MeshScopeId, MultiTargetProfile, NostrProfile, NostrRelayUrlPolicy, PushOutboxTargetOutcomeKind, PushOutboxTransportOutcomeKind, - RadrootsClient, RadrootsClientBuilder, RadrootsSdkLocalKeySigner, - RadrootsSdkMycNip46RequestPolicy, RadrootsSdkMycNip46Signer, RadrootsSdkNip46Transport, - RadrootsSdkNip46TransportFuture, RadrootsSdkSignerProvider, RadrootsSdkStorageConfig, - RadrootsdExecutionProfile, ReticulumAgentEndpoint, ReticulumBehavior as SdkReticulumBehavior, - ReticulumProfile, TargetPolicy, TransportProfile, + RadrootsClient, RadrootsClientBuilder, RadrootsSdkStorageConfig, RadrootsdExecutionProfile, + ReticulumAgentEndpoint, ReticulumBehavior as SdkReticulumBehavior, ReticulumProfile, + TargetPolicy, TransportProfile, }; use radroots_transport_nostr::{ RadrootsNostrClientFetchAdapter, RadrootsRelayFetchRequest, RadrootsRelayFetchedEventsReceipt, @@ -30,20 +16,17 @@ use radroots_transport_nostr::{ fetch_relay_events_blocking, }; use tokio::runtime::{Builder as TokioRuntimeBuilder, Runtime}; -use tokio::sync::{Mutex, broadcast}; -use tokio::time::{Instant, timeout}; -use url::Url; use crate::runtime::RuntimeError; use crate::runtime::account; use crate::runtime::config::{ - CapabilityBindingTargetKind, ReticulumBehavior, RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY, - SignerBackend, TransportProfileKind, nostr_relay_url_policy_for_url, + ReticulumBehavior, RuntimeConfig, TransportProfileKind, nostr_relay_url_policy_for_url, }; +use crate::runtime::signing; const SDK_STORAGE_DIR_NAME: &str = "sdk"; const CLI_RELAY_FETCH_TIMEOUT_MS: u64 = 10_000; -pub(crate) const MYC_NIP46_SESSION_SECRET_SERVICE: &str = "org.radroots.cli.myc-nip46-session"; +pub(crate) use signing::{MYC_NIP46_SESSION_SECRET_SERVICE, myc_managed_account_ref_matches}; #[derive(Debug, thiserror::Error)] pub enum CliSdkAdapterError { @@ -175,10 +158,13 @@ impl CliSdkSession { actor_label: &str, ) -> Result<Self, CliSdkAdapterError> { let sdk_config = CliSdkConfig::from_runtime_config(config)?; - let signer_input = - configured_signer_input(config, actor_account_id, actor_pubkey, actor_label)?; let runtime = sdk_runtime()?; - let signer_provider = runtime.block_on(signer_provider(config, signer_input))?; + let signer_provider = runtime.block_on(signing::provider_for_actor( + config, + actor_account_id, + actor_pubkey, + actor_label, + ))?; let sdk = runtime.block_on( sdk_config .builder() @@ -199,10 +185,13 @@ impl CliSdkSession { actor_label: &str, ) -> Result<Self, CliSdkAdapterError> { let sdk_config = CliSdkConfig::from_runtime_config(config)?; - let signer_input = - configured_signer_input(config, actor_account_id, actor_pubkey, actor_label)?; let runtime = sdk_runtime()?; - let signer_provider = runtime.block_on(signer_provider(config, signer_input))?; + let signer_provider = runtime.block_on(signing::provider_for_actor( + config, + actor_account_id, + actor_pubkey, + actor_label, + ))?; let sdk = runtime.block_on( memory_builder(&sdk_config) .signer_provider(signer_provider) @@ -237,402 +226,7 @@ pub fn validate_configured_signer_for_actor( actor_pubkey: &str, actor_label: &str, ) -> Result<(), RuntimeError> { - configured_signer_input(config, actor_account_id, actor_pubkey, actor_label).map(|_| ()) -} - -pub struct CliSdkLocalSigner { - account_id: String, - public_key_hex: String, - signer: RadrootsLocalEventSigner, -} - -impl CliSdkLocalSigner { - pub fn from_runtime_config(config: &RuntimeConfig) -> Result<Self, RuntimeError> { - let signing = account::resolve_local_signing_identity(config)?; - let account_id = signing.account.record.account_id.to_string(); - let public_key_hex = signing - .account - .record - .public_identity - .public_key_hex - .clone(); - let keys = signing.identity.into_keys(); - let signer = RadrootsLocalEventSigner::new(keys) - .map_err(|error| RuntimeError::Config(error.to_string()))?; - Ok(Self { - account_id, - public_key_hex, - signer, - }) - } - - pub fn account_id(&self) -> &str { - self.account_id.as_str() - } - - pub fn public_key_hex(&self) -> &str { - self.public_key_hex.as_str() - } - - pub fn signer(&self) -> &RadrootsLocalEventSigner { - &self.signer - } -} - -enum CliSdkSignerInput { - LocalSigner(RadrootsLocalEventSigner), - MycNip46 { - client_keys: RadrootsNostrKeys, - target: RadrootsNostrConnectClientTarget, - actor_pubkey: String, - }, -} - -fn configured_signer_input( - config: &RuntimeConfig, - actor_account_id: Option<&str>, - actor_pubkey: &str, - actor_label: &str, -) -> Result<CliSdkSignerInput, RuntimeError> { - match config.signer.backend { - SignerBackend::Local => { - let signer = - local_key_signer_input(config, actor_account_id, actor_pubkey, actor_label)?; - Ok(CliSdkSignerInput::LocalSigner(signer)) - } - SignerBackend::Myc => myc_nip46_signer_input(config, actor_account_id, actor_pubkey), - } -} - -fn local_key_signer_input( - config: &RuntimeConfig, - actor_account_id: Option<&str>, - actor_pubkey: &str, - actor_label: &str, -) -> Result<RadrootsLocalEventSigner, RuntimeError> { - let signing = match actor_account_id { - Some(account_id) => { - account::resolve_local_signing_identity_for_account(config, account_id)? - } - None => account::resolve_local_signing_identity(config)?, - }; - let signer_pubkey = signing - .account - .record - .public_identity - .public_key_hex - .as_str(); - if !signer_pubkey.eq_ignore_ascii_case(actor_pubkey) { - return Err(account::AccountRuntimeFailure::mismatch(format!( - "{actor_label} public key `{actor_pubkey}` does not match local signer account `{}` public key `{signer_pubkey}`", - signing.account.record.account_id - )) - .into()); - } - RadrootsLocalEventSigner::new(signing.identity.into_keys()) - .map_err(|error| RuntimeError::Config(error.to_string())) -} - -fn myc_nip46_signer_input( - config: &RuntimeConfig, - actor_account_id: Option<&str>, - actor_pubkey: &str, -) -> Result<CliSdkSignerInput, RuntimeError> { - let binding = config - .capability_binding(SIGNER_REMOTE_NIP46_CAPABILITY) - .ok_or_else(|| RuntimeError::Config("signer.remote_nip46 binding is missing".to_owned()))?; - if binding.target_kind != CapabilityBindingTargetKind::ExplicitEndpoint { - return Err(RuntimeError::Config(format!( - "signer.remote_nip46 binding target_kind `{}` is not supported for CLI Myc signing; use `explicit_endpoint`", - binding.target_kind.as_str() - ))); - } - if let Some(managed_account_ref) = binding.managed_account_ref.as_deref() - && !myc_managed_account_ref_matches(managed_account_ref, actor_account_id, actor_pubkey) - { - return Err(RuntimeError::Config(format!( - "signer.remote_nip46 managed_account_ref `{managed_account_ref}` does not match actor account or pubkey" - ))); - } - let signer_session_ref = binding.signer_session_ref.as_deref().ok_or_else(|| { - RuntimeError::Config("signer.remote_nip46 signer_session_ref is missing".to_owned()) - })?; - let secret = - account::load_secret_backend_secret(config, signer_session_ref, MYC_NIP46_SESSION_SECRET_SERVICE)? - .ok_or_else(|| { - RuntimeError::Config(format!( - "signer.remote_nip46 signer_session_ref `{signer_session_ref}` was not found in the account secret backend" - )) - })?; - let client_keys = RadrootsIdentity::from_secret_key_str(secret.trim()) - .map_err(|error| { - RuntimeError::Config(format!( - "signer.remote_nip46 signer_session_ref `{signer_session_ref}` contains invalid client secret key material: {error}" - )) - })? - .into_keys(); - let bunker = parse_myc_nip46_target(binding.target.as_str())?; - let target = - RadrootsNostrConnectClientTarget::new(bunker.remote_signer_public_key, bunker.relays); - Ok(CliSdkSignerInput::MycNip46 { - client_keys, - target, - actor_pubkey: actor_pubkey.to_owned(), - }) -} - -pub(crate) fn myc_managed_account_ref_matches( - managed_account_ref: &str, - actor_account_id: Option<&str>, - actor_pubkey: &str, -) -> bool { - actor_account_id.is_some_and(|account_id| managed_account_ref == account_id) - || managed_account_ref == actor_pubkey -} - -async fn signer_provider( - config: &RuntimeConfig, - signer_input: CliSdkSignerInput, -) -> Result<RadrootsSdkSignerProvider, RuntimeError> { - match signer_input { - CliSdkSignerInput::LocalSigner(signer) => { - let signer = RadrootsSdkLocalKeySigner::from_event_signer(signer) - .map_err(|error| RuntimeError::Config(error.to_string()))?; - Ok(RadrootsSdkSignerProvider::LocalKey(signer)) - } - CliSdkSignerInput::MycNip46 { - client_keys, - target, - actor_pubkey, - } => { - let request_policy = myc_nip46_request_policy(config)?; - let request_timeout = request_policy.request_timeout(); - let transport = Arc::new( - CliSdkNip46RelayTransport::connect(&client_keys, &target, request_timeout).await?, - ); - let signer = RadrootsSdkMycNip46Signer::new_with_request_policy( - client_keys, - target, - actor_pubkey, - transport, - request_policy, - ) - .map_err(|error| RuntimeError::Config(error.to_string()))?; - Ok(RadrootsSdkSignerProvider::MycNip46(Box::new(signer))) - } - } -} - -fn myc_nip46_request_policy( - config: &RuntimeConfig, -) -> Result<RadrootsSdkMycNip46RequestPolicy, RuntimeError> { - RadrootsSdkMycNip46RequestPolicy::new(Duration::from_millis(config.myc.status_timeout_ms)) - .map_err(|error| RuntimeError::Config(error.to_string())) -} - -fn parse_myc_nip46_target(value: &str) -> Result<RadrootsNostrConnectBunkerUri, RuntimeError> { - let trimmed = value.trim(); - if trimmed.starts_with("nostrconnect://") { - return Err(RuntimeError::Config( - "signer.remote_nip46 target must be a bunker URI or discovery URL; raw nostrconnect client URIs are signer-side only" - .to_owned(), - )); - } - let bunker_uri = if trimmed.starts_with("bunker://") { - trimmed.to_owned() - } else { - let url = Url::parse(trimmed).map_err(|error| { - RuntimeError::Config(format!("signer.remote_nip46 target is invalid: {error}")) - })?; - url.query_pairs() - .find(|(key, _)| key == "uri") - .map(|(_, uri)| uri.into_owned()) - .ok_or_else(|| { - RuntimeError::Config( - "signer.remote_nip46 discovery target is missing `uri` query parameter" - .to_owned(), - ) - })? - }; - match RadrootsNostrConnectUri::parse(bunker_uri.as_str()).map_err(|error| { - RuntimeError::Config(format!("signer.remote_nip46 target is invalid: {error}")) - })? { - RadrootsNostrConnectUri::Bunker(bunker) => Ok(bunker), - RadrootsNostrConnectUri::Client(_) => Err(RuntimeError::Config( - "signer.remote_nip46 target must resolve to a bunker URI; raw nostrconnect client URIs are signer-side only" - .to_owned(), - )), - } -} - -struct CliSdkNip46RelayTransport { - client: RadrootsNostrClient, - notifications: Mutex<broadcast::Receiver<RadrootsNostrRelayPoolNotification>>, - request_timeout: Duration, - deadline: Mutex<Option<Instant>>, -} - -impl CliSdkNip46RelayTransport { - async fn connect( - client_keys: &RadrootsNostrKeys, - target: &RadrootsNostrConnectClientTarget, - request_timeout: Duration, - ) -> Result<Self, RuntimeError> { - if request_timeout.is_zero() { - return Err(RuntimeError::Config( - "RADROOTS_CLI_MYC_STATUS_TIMEOUT_MS must be greater than zero".to_owned(), - )); - } - let client = RadrootsNostrClient::new_signerless(); - for relay in &target.relays { - client.add_relay(relay.as_str()).await.map_err(|error| { - RuntimeError::Network(format!( - "failed to add signer.remote_nip46 relay `{relay}`: {error}" - )) - })?; - } - let connect_output = client.try_connect(request_timeout).await; - if connect_output.success.is_empty() { - let failures = connect_output - .failed - .iter() - .map(|(relay, error)| format!("{relay}: {error}")) - .collect::<Vec<_>>() - .join("; "); - return Err(RuntimeError::Network(if failures.is_empty() { - "failed to connect to signer.remote_nip46 relays".to_owned() - } else { - format!("failed to connect to signer.remote_nip46 relays: {failures}") - })); - } - let filter = radroots_nostr_filter_tag( - RadrootsNostrFilter::new() - .kind(RadrootsNostrKind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND)) - .since(RadrootsNostrTimestamp::now()), - "p", - vec![client_keys.public_key().to_hex()], - ) - .map_err(|error| { - RuntimeError::Config(format!( - "failed to build signer.remote_nip46 filter: {error}" - )) - })?; - let notifications = client.clone().into_inner().notifications(); - let subscribe_output = client.subscribe(filter, None).await.map_err(|error| { - RuntimeError::Network(format!( - "failed to subscribe to signer.remote_nip46 response relays: {error}" - )) - })?; - validate_myc_response_subscription_acceptance( - subscribe_output.success.len(), - subscribe_output - .failed - .iter() - .map(|(relay, error)| (relay.to_string(), error.to_owned())), - )?; - Ok(Self { - client, - notifications: Mutex::new(notifications), - request_timeout, - deadline: Mutex::new(None), - }) - } -} - -fn validate_myc_response_subscription_acceptance<I>( - success_count: usize, - failed: I, -) -> Result<(), RuntimeError> -where - I: IntoIterator<Item = (String, String)>, -{ - if success_count > 0 { - return Ok(()); - } - let failures = failed - .into_iter() - .map(|(relay, error)| format!("{relay}: {error}")) - .collect::<Vec<_>>() - .join("; "); - Err(RuntimeError::Network(if failures.is_empty() { - "signer.remote_nip46 response subscription was not accepted by any relay".to_owned() - } else { - format!( - "signer.remote_nip46 response subscription was not accepted by any relay: {failures}" - ) - })) -} - -impl RadrootsSdkNip46Transport for CliSdkNip46RelayTransport { - fn publish_request_event<'a>( - &'a self, - event: RadrootsNostrEvent, - ) -> RadrootsSdkNip46TransportFuture<'a, ()> { - Box::pin(async move { - *self.deadline.lock().await = Some(Instant::now() + self.request_timeout); - let output = self.client.send_event(&event).await.map_err(|error| { - RadrootsNostrConnectError::Transport { - reason: error.to_string(), - } - })?; - if output.success.is_empty() { - let failures = output - .failed - .iter() - .map(|(relay, error)| format!("{relay}: {error}")) - .collect::<Vec<_>>() - .join("; "); - return Err(RadrootsNostrConnectError::Transport { - reason: if failures.is_empty() { - "signer.remote_nip46 request event was not accepted by any relay".to_owned() - } else { - format!( - "signer.remote_nip46 request event was not accepted by any relay: {failures}" - ) - }, - }); - } - Ok(()) - }) - } - - fn next_response_event<'a>( - &'a self, - ) -> RadrootsSdkNip46TransportFuture<'a, RadrootsNostrEvent> { - Box::pin(async move { - loop { - let Some(deadline) = *self.deadline.lock().await else { - return Err(RadrootsNostrConnectError::Transport { - reason: "signer.remote_nip46 request deadline is not initialized" - .to_owned(), - }); - }; - let now = Instant::now(); - if now >= deadline { - return Err(RadrootsNostrConnectError::RequestTimedOut); - } - let remaining = deadline - now; - let mut notifications = self.notifications.lock().await; - let received = timeout(remaining, notifications.recv()).await; - drop(notifications); - let notification = match received { - Ok(Ok(notification)) => notification, - Ok(Err(broadcast::error::RecvError::Lagged(_))) => continue, - Ok(Err(broadcast::error::RecvError::Closed)) => { - return Err(RadrootsNostrConnectError::Transport { - reason: "signer.remote_nip46 relay notification stream closed" - .to_owned(), - }); - } - Err(_) => return Err(RadrootsNostrConnectError::RequestTimedOut), - }; - let RadrootsNostrRelayPoolNotification::Event { event, .. } = notification else { - continue; - }; - return Ok((*event).clone()); - } - }) - } + signing::validate_for_actor(config, actor_account_id, actor_pubkey, actor_label) } pub fn sdk_storage_root(config: &RuntimeConfig) -> PathBuf { @@ -1392,28 +986,6 @@ mod tests { } #[test] - fn materializes_local_account_signer_for_sdk_workflows() { - let root = tempdir().expect("tempdir"); - let config = sample_config(root.path(), Vec::new()); - let account = account::create_default_account(&config).expect("create account"); - - let signer = CliSdkLocalSigner::from_runtime_config(&config).expect("sdk signer"); - - assert_eq!( - signer.account_id(), - account.account.record.account_id.as_str() - ); - assert_eq!( - signer.public_key_hex(), - account.account.record.public_identity.public_key_hex - ); - assert_eq!( - signer.signer().pubkey().as_str(), - account.account.record.public_identity.public_key_hex - ); - } - - #[test] fn sdk_session_builds_once_and_runs_async_storage_smoke() { let root = tempdir().expect("tempdir"); let config = sample_config(root.path(), Vec::new()); @@ -1431,50 +1003,6 @@ mod tests { } #[test] - fn myc_request_policy_uses_cli_timeout_config() { - let root = tempdir().expect("tempdir"); - let mut config = sample_config(root.path(), Vec::new()); - config.myc.status_timeout_ms = 12_345; - - let policy = myc_nip46_request_policy(&config).expect("request policy"); - - assert_eq!(policy.request_timeout(), Duration::from_millis(12_345)); - } - - #[test] - fn myc_request_policy_rejects_zero_cli_timeout() { - let root = tempdir().expect("tempdir"); - let mut config = sample_config(root.path(), Vec::new()); - config.myc.status_timeout_ms = 0; - - let error = myc_nip46_request_policy(&config).expect_err("zero timeout"); - - assert!(error.to_string().contains("must be greater than zero")); - } - - #[test] - fn myc_response_subscription_requires_relay_acceptance() { - let error = validate_myc_response_subscription_acceptance( - 0, - [( - "ws://127.0.0.1:8080".to_owned(), - "subscription rejected".to_owned(), - )], - ) - .expect_err("response subscription acceptance"); - - assert!( - error - .to_string() - .contains("response subscription was not accepted by any relay") - ); - assert!(error.to_string().contains("subscription rejected")); - - validate_myc_response_subscription_acceptance(1, std::iter::empty()) - .expect("accepted response subscription"); - } - - #[test] fn sdk_sources_do_not_import_cli_types() { let sdk_src = Path::new(env!("CARGO_MANIFEST_DIR")).join("../sdk/crates/sdk/src"); let mut files = Vec::new(); diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs @@ -13,14 +13,7 @@ use radroots_event::envelope::kind::{ KIND_CLASSIFIED_LISTING, KIND_FARM, KIND_TRADE_CANCELLATION, KIND_TRADE_DECISION, KIND_TRADE_PROPOSAL, KIND_TRADE_REVISION_DECISION, KIND_TRADE_REVISION_PROPOSAL, }; -use radroots_nostr_accounts::prelude::RadrootsNostrAccountStatus; -use radroots_nostr_connect::prelude::RadrootsNostrConnectPermissions; -use radroots_nostr_signer::prelude::{ - RadrootsNostrLocalSignerAvailability, RadrootsNostrLocalSignerCapability, - RadrootsNostrSignerCapability, -}; -use radroots_sdk::radroots_sdk_myc_nip46_product_permission_strings; -use std::str::FromStr; +use radroots_identity::account::Status as AccountStatus; use url::Url; const SIGNER_BINDING_PROVIDER_RUNTIME_ID: &str = "myc"; @@ -69,7 +62,7 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { resolution .resolved_account .as_ref() - .map(|account| account.record.account_id.to_string()), + .map(|account| account.record.id().to_string()), ), Err(error) => { let reason = error.to_string(); @@ -125,66 +118,50 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { .unwrap_or_else(|| "unknown".to_owned()); match crate::runtime::account::resolved_account_signing_status(config) { - Ok(RadrootsNostrAccountStatus::Ready { account }) => { - let capability = RadrootsNostrSignerCapability::LocalAccount(Box::new( - RadrootsNostrLocalSignerCapability::new( - account.account_id.clone(), - account.public_identity.clone(), - RadrootsNostrLocalSignerAvailability::SecretBacked, + Ok(AccountStatus::Ready { account }) => SignerStatusView { + mode: config.signer.backend.as_str().to_owned(), + state: "ready".to_owned(), + source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), + signer_account_id: Some(account.id().to_string()), + account_resolution: account_resolution.clone(), + reason: None, + binding: disabled_binding_status(), + write_kinds: local_write_kind_readiness(true, None), + local: Some(LocalSignerStatusView { + account_id: account.id().to_string(), + public_identity: IdentityPublicView::from_public_identity( + account.public_identity(), ), - )); - let local = capability - .local_account() - .expect("local signer capability") - .clone(); - SignerStatusView { - mode: config.signer.backend.as_str().to_owned(), - state: "ready".to_owned(), - source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), - signer_account_id: Some(local.account_id.to_string()), - account_resolution: account_resolution.clone(), - reason: None, - binding: disabled_binding_status(), - write_kinds: local_write_kind_readiness(true, None), - local: Some(LocalSignerStatusView { - account_id: local.account_id.to_string(), - public_identity: IdentityPublicView::from_public_identity( - &local.public_identity, - ), - availability: local_availability(local.availability).to_owned(), - secret_backed: local.is_secret_backed(), - backend: backend.clone(), - }), - myc: None, - } - } - Ok(RadrootsNostrAccountStatus::PublicOnly { account }) => { - let reason = AccountRuntimeFailure::watch_only(&account.account_id).to_string(); + availability: "secret_backed".to_owned(), + secret_backed: true, + backend: backend.clone(), + }), + myc: None, + }, + Ok(AccountStatus::PublicOnly { account }) => { + let reason = AccountRuntimeFailure::watch_only(&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()), + signer_account_id: Some(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(), + account_id: account.id().to_string(), public_identity: IdentityPublicView::from_public_identity( - &account.public_identity, + account.public_identity(), ), - availability: local_availability( - RadrootsNostrLocalSignerAvailability::PublicOnly, - ) - .to_owned(), + availability: "public_only".to_owned(), secret_backed: false, backend: backend.clone(), }), myc: None, } } - Ok(RadrootsNostrAccountStatus::NotConfigured) => SignerStatusView { + Ok(AccountStatus::NotConfigured) => SignerStatusView { mode: config.signer.backend.as_str().to_owned(), state: "unconfigured".to_owned(), source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), @@ -199,6 +176,21 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { local: None, myc: None, }, + Ok(_) => SignerStatusView { + mode: config.signer.backend.as_str().to_owned(), + state: "error".to_owned(), + source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), + signer_account_id: resolved_account_id, + account_resolution, + reason: Some("account status is not supported by this CLI version".to_owned()), + binding: disabled_binding_status(), + write_kinds: local_write_kind_readiness( + false, + Some("account status is not supported by this CLI version".to_owned()), + ), + local: None, + myc: None, + }, Err(error) => { let reason = error.to_string(); SignerStatusView { @@ -224,11 +216,11 @@ fn resolve_myc_signer_status(config: &RuntimeConfig) -> SignerStatusView { let actor_account_id = resolution .resolved_account .as_ref() - .map(|account| account.record.account_id.to_string()); + .map(|account| account.record.id().to_string()); let actor_pubkey = resolution .resolved_account .as_ref() - .map(|account| account.record.public_identity.public_key_hex.clone()); + .map(|account| account.record.public_identity().public_key().to_hex()); ( crate::runtime::account::account_resolution_view(&resolution), actor_account_id, @@ -351,13 +343,6 @@ fn local_write_kind_readiness( .collect() } -fn local_availability(value: RadrootsNostrLocalSignerAvailability) -> &'static str { - match value { - RadrootsNostrLocalSignerAvailability::PublicOnly => "public_only", - RadrootsNostrLocalSignerAvailability::SecretBacked => "secret_backed", - } -} - #[derive(Debug, Clone)] struct MycBindingReadiness { binding: SignerBindingStatusView, @@ -503,11 +488,11 @@ fn validate_myc_target(value: &str) -> Result<(), String> { "signer.remote_nip46 discovery target is missing `uri` query parameter".to_owned() })? }; - match radroots_nostr_connect::prelude::RadrootsNostrConnectUri::parse(bunker_uri.as_str()) + match radroots_nostr_connect::uri::Uri::parse(bunker_uri.as_str()) .map_err(|error| format!("signer.remote_nip46 target is invalid: {error}"))? { - radroots_nostr_connect::prelude::RadrootsNostrConnectUri::Bunker(_) => Ok(()), - radroots_nostr_connect::prelude::RadrootsNostrConnectUri::Client(_) => Err( + radroots_nostr_connect::uri::Uri::Bunker(_) => Ok(()), + radroots_nostr_connect::uri::Uri::Client(_) => Err( "signer.remote_nip46 target must resolve to a bunker URI; raw nostrconnect client URIs are signer-side only" .to_owned(), ), @@ -518,58 +503,16 @@ fn myc_write_kind_readiness( ready: bool, reason: Option<String>, ) -> Vec<SignerWriteKindReadinessView> { - myc_write_kind_readiness_for_permissions(ready, reason, sdk_myc_nip46_product_permissions()) -} - -fn sdk_myc_nip46_product_permissions() -> Result<RadrootsNostrConnectPermissions, String> { - RadrootsNostrConnectPermissions::from_str( - radroots_sdk_myc_nip46_product_permission_strings() - .join(",") - .as_str(), - ) - .map_err(|error| format!("SDK Myc signer permissions are invalid: {error}")) -} - -fn myc_write_kind_readiness_for_permissions( - ready: bool, - reason: Option<String>, - permissions: Result<RadrootsNostrConnectPermissions, String>, -) -> Vec<SignerWriteKindReadinessView> { - let permissions = match permissions { - Ok(permissions) => permissions, - Err(error) => { - return cli_write_kinds() - .iter() - .map(|kind| SignerWriteKindReadinessView { - command: kind.command.to_owned(), - event_kind: kind.event_kind, - permission: sign_event_permission_for_kind(kind.event_kind), - ready: false, - reason: Some(error.clone()), - }) - .collect(); - } - }; cli_write_kinds() .iter() .map(|kind| { let permission = sign_event_permission_for_kind(kind.event_kind); - let permission_ready = ready && permissions.allows_sign_event_kind(kind.event_kind); SignerWriteKindReadinessView { command: kind.command.to_owned(), event_kind: kind.event_kind, permission, - ready: permission_ready, - reason: if permission_ready { - None - } else { - reason.clone().or_else(|| { - Some( - "SDK Myc signer permission is not configured for this event kind" - .to_owned(), - ) - }) - }, + ready, + reason: if ready { None } else { reason.clone() }, } }) .collect() @@ -585,10 +528,7 @@ mod tests { KIND_CLASSIFIED_LISTING, KIND_FARM, KIND_TRADE_CANCELLATION, KIND_TRADE_DECISION, KIND_TRADE_PROPOSAL, KIND_TRADE_REVISION_DECISION, KIND_TRADE_REVISION_PROPOSAL, cli_write_kinds, myc_managed_account_ref_matches, myc_write_kind_readiness, - myc_write_kind_readiness_for_permissions, sign_event_permission_for_kind, - }; - use radroots_nostr_connect::prelude::{ - RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, + sign_event_permission_for_kind, }; #[test] @@ -659,31 +599,6 @@ mod tests { } #[test] - fn myc_write_readiness_uses_typed_kind_permissions() { - let readiness = myc_write_kind_readiness_for_permissions( - true, - None, - Ok(RadrootsNostrConnectPermissions::from(vec![ - RadrootsNostrConnectPermission::with_parameter( - RadrootsNostrConnectMethod::SignEvent, - format!("kind:{KIND_CLASSIFIED_LISTING}"), - ), - ])), - ); - let listing = readiness - .iter() - .find(|kind| kind.command == "listing.publish") - .expect("listing readiness"); - let farm = readiness - .iter() - .find(|kind| kind.command == "farm.publish") - .expect("farm readiness"); - - assert!(listing.ready); - assert!(!farm.ready); - } - - #[test] fn myc_managed_account_ref_matches_actor_account_id_or_pubkey() { let actor_account_id = Some("acct_farmer_market"); let actor_pubkey = "02d67b520cb0b835a5ca6ddf78bf3bbfe636d31a523050efc01bf8cb0c680da09e"; diff --git a/src/runtime/signing.rs b/src/runtime/signing.rs @@ -0,0 +1,619 @@ +//! CLI-owned composition of canonical local and NIP-46 signer adapters. + +use std::{ + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use nostr_sdk::prelude::{ + Client as RelayClient, Filter, JsonUtil, Kind, RelayPoolNotification, Timestamp, +}; +use radroots_event::{SignedEvent, wire::Nip01EventWire}; +use radroots_nostr_connect::{ + Client as NostrConnectClient, Error as NostrConnectError, Request, Response, + client::{ + CancellationToken, ClientEvent, Completion, Progress, Receive, Target, Transport, + TransportFuture, + }, + message::{RequestId, UnsignedEvent}, + uri::{BunkerUri, Uri}, +}; +use radroots_sdk::signing::Provider; +use radroots_signing::{ + Error as SigningError, SignReceipt, SignRequest, Signer, SignerStatus, + capability::{CancellationSupport, SignerCapability, SignerKind}, + error::Kind as SigningErrorKind, + signer::BoxFuture, + status::{AuthChallenge, SignProgress, SignProgressStage, SignerAvailability}, +}; +use tokio::{sync::broadcast, time::Instant}; +use url::Url; +use zeroize::Zeroizing; + +use crate::runtime::{ + RuntimeError, account, + config::{ + CapabilityBindingTargetKind, RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend, + }, +}; + +pub(crate) const MYC_NIP46_SESSION_SECRET_SERVICE: &str = "org.radroots.cli.myc-nip46-session"; + +pub(crate) fn validate_for_actor( + config: &RuntimeConfig, + actor_account_id: Option<&str>, + actor_pubkey: &str, + actor_label: &str, +) -> Result<(), RuntimeError> { + match config.signer.backend { + SignerBackend::Local => { + let signing = local_signer(config, actor_account_id)?; + let signer_pubkey = signing.signer.public_key().to_hex(); + if !signer_pubkey.eq_ignore_ascii_case(actor_pubkey) { + return Err(account::AccountRuntimeFailure::mismatch(format!( + "{actor_label} public key `{actor_pubkey}` does not match local signer account `{}` public key `{signer_pubkey}`", + signing.account.record.id() + )) + .into()); + } + Ok(()) + } + SignerBackend::Myc => remote_input(config, actor_account_id, actor_pubkey).map(|_| ()), + } +} + +pub(crate) async fn provider_for_actor( + config: &RuntimeConfig, + actor_account_id: Option<&str>, + actor_pubkey: &str, + actor_label: &str, +) -> Result<Provider, RuntimeError> { + match config.signer.backend { + SignerBackend::Local => { + let signing = local_signer(config, actor_account_id)?; + let signer_pubkey = signing.signer.public_key().to_hex(); + if !signer_pubkey.eq_ignore_ascii_case(actor_pubkey) { + return Err(account::AccountRuntimeFailure::mismatch(format!( + "{actor_label} public key `{actor_pubkey}` does not match local signer account `{}` public key `{signer_pubkey}`", + signing.account.record.id() + )) + .into()); + } + Ok(Provider::local(signing.signer)) + } + SignerBackend::Myc => { + let input = remote_input(config, actor_account_id, actor_pubkey)?; + let signer = Nip46Signer::connect(input).await?; + Ok(Provider::nip46(Arc::new(signer))) + } + } +} + +fn local_signer( + config: &RuntimeConfig, + actor_account_id: Option<&str>, +) -> Result<account::AccountLocalSigner, RuntimeError> { + match actor_account_id { + Some(account_id) => account::resolve_local_signing_identity_for_account(config, account_id), + None => account::resolve_local_signing_identity(config), + } +} + +struct RemoteInput { + session_secret: Zeroizing<String>, + bunker: BunkerUri, + request_timeout: Duration, +} + +fn remote_input( + config: &RuntimeConfig, + actor_account_id: Option<&str>, + actor_pubkey: &str, +) -> Result<RemoteInput, RuntimeError> { + let binding = config + .capability_binding(SIGNER_REMOTE_NIP46_CAPABILITY) + .ok_or_else(|| RuntimeError::Config("signer.remote_nip46 binding is missing".to_owned()))?; + if binding.target_kind != CapabilityBindingTargetKind::ExplicitEndpoint { + return Err(RuntimeError::Config(format!( + "signer.remote_nip46 binding target_kind `{}` is not supported for CLI Myc signing; use `explicit_endpoint`", + binding.target_kind.as_str() + ))); + } + if let Some(managed_account_ref) = binding.managed_account_ref.as_deref() + && !myc_managed_account_ref_matches(managed_account_ref, actor_account_id, actor_pubkey) + { + return Err(RuntimeError::Config(format!( + "signer.remote_nip46 managed_account_ref `{managed_account_ref}` does not match actor account or pubkey" + ))); + } + let signer_session_ref = binding.signer_session_ref.as_deref().ok_or_else(|| { + RuntimeError::Config("signer.remote_nip46 signer_session_ref is missing".to_owned()) + })?; + let secret = account::load_secret_backend_secret( + config, + signer_session_ref, + MYC_NIP46_SESSION_SECRET_SERVICE, + )? + .ok_or_else(|| { + RuntimeError::Config(format!( + "signer.remote_nip46 signer_session_ref `{signer_session_ref}` was not found in the account secret backend" + )) + })?; + if config.myc.status_timeout_ms == 0 { + return Err(RuntimeError::Config( + "RADROOTS_CLI_MYC_STATUS_TIMEOUT_MS must be greater than zero".to_owned(), + )); + } + let bunker = parse_target(binding.target.as_str())?; + if bunker.remote_signer_public_key().to_hex() != actor_pubkey { + return Err(account::AccountRuntimeFailure::mismatch(format!( + "remote signer public key `{}` does not match actor public key `{actor_pubkey}`", + bunker.remote_signer_public_key().to_hex() + )) + .into()); + } + Ok(RemoteInput { + session_secret: Zeroizing::new(secret), + bunker, + request_timeout: Duration::from_millis(config.myc.status_timeout_ms), + }) +} + +pub(crate) fn myc_managed_account_ref_matches( + managed_account_ref: &str, + actor_account_id: Option<&str>, + actor_pubkey: &str, +) -> bool { + actor_account_id.is_some_and(|account_id| managed_account_ref == account_id) + || managed_account_ref == actor_pubkey +} + +fn parse_target(value: &str) -> Result<BunkerUri, RuntimeError> { + let trimmed = value.trim(); + if trimmed.starts_with("nostrconnect://") { + return Err(RuntimeError::Config( + "signer.remote_nip46 target must be a bunker URI or discovery URL; raw nostrconnect client URIs are signer-side only" + .to_owned(), + )); + } + let bunker_uri = if trimmed.starts_with("bunker://") { + trimmed.to_owned() + } else { + Url::parse(trimmed) + .map_err(|error| { + RuntimeError::Config(format!("signer.remote_nip46 target is invalid: {error}")) + })? + .query_pairs() + .find(|(key, _)| key == "uri") + .map(|(_, uri)| uri.into_owned()) + .ok_or_else(|| { + RuntimeError::Config( + "signer.remote_nip46 discovery target is missing `uri` query parameter" + .to_owned(), + ) + })? + }; + match Uri::parse(bunker_uri.as_str()) + .map_err(|error| RuntimeError::Config(format!("signer.remote_nip46 target is invalid: {error}")))? + { + Uri::Bunker(bunker) => Ok(bunker), + Uri::Client(_) => Err(RuntimeError::Config( + "signer.remote_nip46 target must resolve to a bunker URI; raw nostrconnect client URIs are signer-side only" + .to_owned(), + )), + } +} + +struct Nip46Signer { + client: NostrConnectClient, + target: Target, + request_timeout: Duration, +} + +impl Nip46Signer { + async fn connect(input: RemoteInput) -> Result<Self, RuntimeError> { + let target = Target::try_new( + input.bunker.remote_signer_public_key(), + input.bunker.relays().to_vec(), + ) + .map_err(|error| RuntimeError::Config(error.to_string()))?; + let client = NostrConnectClient::from_secret(input.session_secret.trim(), target.clone()) + .map_err(|_| { + RuntimeError::Config("NIP-46 client session secret is invalid".to_owned()) + })?; + Ok(Self { + client, + target, + request_timeout: input.request_timeout, + }) + } +} + +impl Signer for Nip46Signer { + fn status(&self) -> BoxFuture<'_, Result<SignerStatus, SigningError>> { + Box::pin(async { + Ok(SignerStatus::new( + SignerAvailability::Ready, + vec![SignerCapability::new( + SignerKind::Remote, + CancellationSupport::BeforeAndAfterPublication, + true, + true, + )], + None, + )) + }) + } + + fn sign(&self, request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, SigningError>> { + Box::pin(async move { + let now = + unix_time().map_err(|_| SigningError::new(SigningErrorKind::InternalError))?; + let remaining = request.policy().deadline_unix().saturating_sub(now); + if remaining == 0 { + return Err(SigningError::new(SigningErrorKind::DeadlineExceeded)); + } + request.report_progress(&SignProgress::stage(SignProgressStage::Validating)?); + let unsigned_json = serde_json::json!({ + "pubkey": request.draft().expected_pubkey().to_hex(), + "created_at": request.draft().created_at_u64(), + "kind": request.draft().kind_u32(), + "tags": request.draft().tags_as_vec(), + "content": request.draft().content(), + }) + .to_string(); + let unsigned = UnsignedEvent::from_json(unsigned_json.as_str()) + .map_err(|error| signing_source(SigningErrorKind::InvalidArgument, error))?; + let mut transport = RelayTransport::connect( + &self.client, + &self.target, + self.request_timeout.min(Duration::from_secs(remaining)), + ) + .await + .map_err(|error| signing_source(SigningErrorKind::SignerUnavailable, error))?; + let request_id = random_request_id() + .map_err(|error| signing_source(SigningErrorKind::InternalError, error))?; + request.report_progress(&SignProgress::stage(SignProgressStage::RequestPublished)?); + let cancellation = CancellationToken::new(); + let completion = self + .client + .execute( + request_id, + Request::SignEvent(unsigned), + &mut transport, + &cancellation, + |progress| report_remote_progress(&request, progress), + ) + .await + .map_err(normalize_nip46_error)?; + finish_remote_signing(&request, completion) + }) + } +} + +fn report_remote_progress( + request: &SignRequest, + progress: Progress, +) -> Result<(), NostrConnectError> { + match progress { + Progress::AuthChallenge { url } => { + let now = unix_time().map_err(|error| NostrConnectError::Transport { + reason: error.to_string(), + })?; + let challenge = AuthChallenge::new(url, now, Some(request.policy().deadline_unix())) + .map_err(|_| NostrConnectError::InvalidClientState { + reason: "remote authentication challenge is invalid", + })?; + request.report_progress(&SignProgress::authentication(challenge)); + } + } + Ok(()) +} + +fn finish_remote_signing( + request: &SignRequest, + completion: Completion, +) -> Result<SignReceipt, SigningError> { + let Completion::Response(response) = completion else { + return Err(SigningError::new(SigningErrorKind::SignerCancelled)); + }; + let Response::SignedEvent(event) = *response else { + return Err(SigningError::new(SigningErrorKind::SignerOutputInvalid)); + }; + request.report_progress(&SignProgress::stage(SignProgressStage::VerifyingOutput)?); + let raw_json = event.as_json(); + let wire = Nip01EventWire::parse_json(raw_json.as_str()) + .map_err(|error| signing_source(SigningErrorKind::SignerOutputInvalid, error))?; + let signed = SignedEvent::from_wire_verified_id(wire, raw_json) + .map_err(|error| signing_source(SigningErrorKind::SignerOutputInvalid, error))?; + let completed_at = + unix_time().map_err(|error| signing_source(SigningErrorKind::InternalError, error))?; + let receipt = SignReceipt::from_signed_event(request, signed, completed_at)?; + request.report_progress(&SignProgress::stage(SignProgressStage::Complete)?); + Ok(receipt) +} + +fn normalize_nip46_error(error: NostrConnectError) -> SigningError { + let kind = match error { + NostrConnectError::RequestTimedOut => SigningErrorKind::SignerTimeout, + NostrConnectError::WrongRequestId + | NostrConnectError::WrongResponseSigner + | NostrConnectError::ReplayedResponse + | NostrConnectError::InvalidResponseEnvelope { .. } + | NostrConnectError::InvalidResponsePayload { .. } + | NostrConnectError::InvalidClientEvent => SigningErrorKind::SignerOutputInvalid, + NostrConnectError::Transport { .. } => SigningErrorKind::SignerUnavailable, + _ => SigningErrorKind::InternalError, + }; + signing_source(kind, error) +} + +fn signing_source<E>(kind: SigningErrorKind, source: E) -> SigningError +where + E: std::error::Error + Send + Sync + 'static, +{ + SigningError::with_source(kind, source) +} + +fn random_request_id() -> Result<RequestId, getrandom::Error> { + let mut bytes = [0_u8; 16]; + getrandom::getrandom(&mut bytes)?; + let value = bytes + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::<String>(); + Ok(RequestId::parse(value).expect("hex request ID is bounded and valid")) +} + +fn unix_time() -> Result<u64, std::time::SystemTimeError> { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|value| value.as_secs()) +} + +struct RelayTransport { + client: RelayClient, + notifications: broadcast::Receiver<RelayPoolNotification>, + request_timeout: Duration, + deadline: Option<Instant>, +} + +impl RelayTransport { + async fn connect( + protocol_client: &NostrConnectClient, + target: &Target, + request_timeout: Duration, + ) -> Result<Self, NostrConnectError> { + let client = RelayClient::default(); + client.automatic_authentication(false); + for relay in target.relays() { + client + .add_relay(relay.to_string()) + .await + .map_err(transport_error)?; + } + let connected = client.try_connect(request_timeout).await; + if connected.success.is_empty() { + return Err(NostrConnectError::Transport { + reason: "no NIP-46 relay accepted the connection".to_owned(), + }); + } + let client_public_key = protocol_client.public_key().map_err(|error| error)?; + let client_public_key = nostr_sdk::prelude::PublicKey::from_slice( + client_public_key.as_bytes(), + ) + .map_err(|_| NostrConnectError::InvalidClientState { + reason: "client public key is invalid", + })?; + let filter = Filter::new() + .kind(Kind::Custom(radroots_nostr_connect::message::RPC_KIND)) + .pubkey(client_public_key) + .since(Timestamp::now()); + let notifications = client.notifications(); + let subscribed = client + .subscribe(filter, None) + .await + .map_err(transport_error)?; + if subscribed.success.is_empty() { + return Err(NostrConnectError::Transport { + reason: "no NIP-46 relay accepted the response subscription".to_owned(), + }); + } + Ok(Self { + client, + notifications, + request_timeout, + deadline: None, + }) + } +} + +impl Transport for RelayTransport { + fn publish<'a>(&'a mut self, event: ClientEvent) -> TransportFuture<'a, ()> { + Box::pin(async move { + self.deadline = Some(Instant::now() + self.request_timeout); + let event = nostr_sdk::prelude::Event::from_json(event.as_json()) + .map_err(|_| NostrConnectError::InvalidClientEvent)?; + let output = self + .client + .send_event(&event) + .await + .map_err(transport_error)?; + if output.success.is_empty() { + return Err(NostrConnectError::Transport { + reason: "no NIP-46 relay accepted the request".to_owned(), + }); + } + Ok(()) + }) + } + + fn receive<'a>( + &'a mut self, + cancellation: &'a CancellationToken, + ) -> TransportFuture<'a, Receive> { + Box::pin(async move { + loop { + if cancellation.is_cancelled() { + return Ok(Receive::Cancelled); + } + let Some(deadline) = self.deadline else { + return Err(NostrConnectError::InvalidClientState { + reason: "request deadline is not initialized", + }); + }; + let now = Instant::now(); + if now >= deadline { + return Ok(Receive::TimedOut); + } + let poll = (deadline - now).min(Duration::from_millis(50)); + match tokio::time::timeout(poll, self.notifications.recv()).await { + Err(_) => continue, + Ok(Err(broadcast::error::RecvError::Lagged(_))) => continue, + Ok(Err(broadcast::error::RecvError::Closed)) => { + return Err(NostrConnectError::Transport { + reason: "NIP-46 relay notification stream closed".to_owned(), + }); + } + Ok(Ok(RelayPoolNotification::Event { event, .. })) => { + return ClientEvent::from_json(event.as_json().as_str()) + .map(Receive::event); + } + Ok(Ok(_)) => continue, + } + } + }) + } +} + +fn transport_error(error: impl std::fmt::Display) -> NostrConnectError { + NostrConnectError::Transport { + reason: error.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + + use radroots_event::{EventDraft, contract::AuthorRole, envelope::kind::KIND_GEOCHAT}; + use radroots_protocol::runtime::v1::OperationId; + use radroots_signing::{ + Actor, + actor::ActorSource, + request::{CancellationPolicy, ProgressObserver, SignPolicy}, + }; + + struct RecordingObserver(Mutex<Vec<SignProgressStage>>); + + impl ProgressObserver for RecordingObserver { + fn on_progress(&self, progress: &SignProgress) { + self.0 + .lock() + .expect("progress lock") + .push(progress.stage_value()); + } + } + + fn local_request() -> (radroots_nostr::signing::LocalSigner, SignRequest) { + let signer = radroots_nostr::signing::LocalSigner::generate().expect("local signer"); + let public_key = signer.public_key(); + let actor = Actor::new( + public_key, + ActorSource::ExplicitPublicKey, + [AuthorRole::Any], + ) + .expect("actor"); + let now = unix_time().expect("time"); + let draft = EventDraft::new( + "radroots.social.geochat.v1", + KIND_GEOCHAT, + now, + Vec::new(), + "CLI NIP-46 fixture", + public_key.to_hex(), + ) + .expect("draft"); + let request = SignRequest::new( + OperationId::SyncPush, + actor, + draft, + SignPolicy::new(now + 120, CancellationPolicy::PreservePublishedRequest) + .expect("policy"), + ) + .expect("request"); + (signer, request) + } + + #[tokio::test] + async fn remote_happy_response_becomes_an_exact_verified_receipt() { + let (local, request) = local_request(); + let signed = local.sign(request.clone()).await.expect("local fixture"); + let wire = radroots_nostr_connect::message::SignedEvent::from_json( + signed.signed_event().raw_json(), + ) + .expect("NIP-46 signed event"); + + let receipt = + finish_remote_signing(&request, Completion::response(Response::SignedEvent(wire))) + .expect("remote receipt"); + + assert_eq!(receipt.operation_id(), OperationId::SyncPush); + assert_eq!( + receipt.signed_event().id_str(), + request.draft().expected_event_id_hex() + ); + } + + #[test] + fn remote_auth_challenge_is_presented_through_the_canonical_observer() { + let (_, request) = local_request(); + let observer = Arc::new(RecordingObserver(Mutex::new(Vec::new()))); + let request = request.with_progress_observer(observer.clone()); + + report_remote_progress( + &request, + Progress::AuthChallenge { + url: "https://signer.example/approve".to_owned(), + }, + ) + .expect("auth progress"); + + assert_eq!( + observer.0.lock().expect("progress lock").as_slice(), + &[SignProgressStage::AwaitingAuthentication] + ); + } + + #[test] + fn timeout_and_wrong_response_errors_map_to_stable_signing_kinds() { + assert_eq!( + normalize_nip46_error(NostrConnectError::RequestTimedOut).kind(), + SigningErrorKind::SignerTimeout + ); + assert_eq!( + normalize_nip46_error(NostrConnectError::WrongResponseSigner).kind(), + SigningErrorKind::SignerOutputInvalid + ); + } + + #[test] + fn exact_account_or_pubkey_binding_is_required() { + assert!(myc_managed_account_ref_matches( + "account-a", + Some("account-a"), + "aa" + )); + assert!(myc_managed_account_ref_matches( + "aa", + Some("account-a"), + "aa" + )); + assert!(!myc_managed_account_ref_matches( + "account-b", + Some("account-a"), + "aa" + )); + } +} diff --git a/src/runtime/trade.rs b/src/runtime/trade.rs @@ -240,10 +240,10 @@ fn trade_prepared_view( TradePreparedView { state: "prepared".to_owned(), operation: operation.to_owned(), - trade_id: plan.workflow().trade_id().as_str().to_owned(), - mutation_id: plan.workflow().mutation_id().as_str().to_owned(), + trade_id: plan.workflow().trade_id().to_string(), + mutation_id: plan.workflow().mutation_id().to_string(), mutation_kind: format!("{:?}", plan.workflow().kind()), - event_id: plan.draft().expected_event_id().as_str().to_owned(), + event_id: plan.draft().expected_event_id().to_string(), event_kind: plan.draft().kind_u32(), author: plan.draft().expected_pubkey().to_hex(), required_actions: plan @@ -384,10 +384,8 @@ fn scaffold_proposal_draft_inner( let listing_state = resolve_active_listing_state(config, product.listing_addr.as_str(), &parsed_listing)?; let farm_id = resolve_farm_id(config, parsed_listing.seller_pubkey.as_str())?; - let buyer_pubkey = pubkey( - buyer.record.public_identity.public_key_hex.as_str(), - "buyer_pubkey", - )?; + let buyer_public_key_hex = buyer.record.public_identity().public_key().to_hex(); + let buyer_pubkey = pubkey(buyer_public_key_hex.as_str(), "buyer_pubkey")?; let seller_pubkey = pubkey(parsed_listing.seller_pubkey.as_str(), "seller_pubkey")?; let candidate = candidate_terms( &product, @@ -407,17 +405,14 @@ fn scaffold_proposal_draft_inner( seller_pubkey: seller_pubkey.clone(), farm_id: farm_id.clone(), parent_mutation_ids: Vec::new(), - author_pubkey: pubkey( - buyer.record.public_identity.public_key_hex.as_str(), - "author_pubkey", - )?, + author_pubkey: pubkey(buyer_public_key_hex.as_str(), "author_pubkey")?, counterparty_pubkey: seller_pubkey, authored_at_unix_s: now_unix(), body: TradeMutationBodyV1::Proposal { candidate }, }; let canonical = canonical_trade_mutation_content(envelope) .map_err(|error| RuntimeError::Config(format!("build trade proposal envelope: {error}")))?; - let file = candidate_draft_file(config, canonical.envelope.trade_id.as_str()); + let file = candidate_draft_file(config, canonical.envelope.trade_id.to_string().as_str()); if !dry_run { if let Some(parent) = file.parent() { fs::create_dir_all(parent)?; @@ -441,7 +436,7 @@ fn scaffold_proposal_draft_inner( listing_addr: product.listing_addr, listing_event_id: listing_state.last_event_id, listing_snapshot_sha256: listing_state.content_hash, - buyer_pubkey: buyer.record.public_identity.public_key_hex, + buyer_pubkey: buyer_public_key_hex, seller_pubkey: parsed_listing.seller_pubkey, farm_id: farm_id.to_string(), ready_for_submit: true, @@ -473,25 +468,25 @@ fn actor_for_envelope( let account = account::resolve_account(config)?.ok_or_else(|| { RuntimeError::Config(format!("{operation} requires a selected signer account")) })?; - let author_pubkey = envelope.author_pubkey.as_str(); - let account_pubkey = account.record.public_identity.public_key_hex.as_str(); - if !account_pubkey.eq_ignore_ascii_case(author_pubkey) { + let author_pubkey = envelope.author_pubkey.to_hex(); + let account_pubkey = account.record.public_identity().public_key().to_hex(); + if !account_pubkey.eq_ignore_ascii_case(author_pubkey.as_str()) { return Err(RuntimeError::Config(format!( "{operation} envelope author `{author_pubkey}` does not match selected account `{}` public key `{account_pubkey}`", - account.record.account_id + account.record.id() )) .into()); } let role = if envelope .buyer_pubkey - .as_str() - .eq_ignore_ascii_case(author_pubkey) + .to_hex() + .eq_ignore_ascii_case(author_pubkey.as_str()) { AuthorRole::Buyer } else if envelope .seller_pubkey - .as_str() - .eq_ignore_ascii_case(author_pubkey) + .to_hex() + .eq_ignore_ascii_case(author_pubkey.as_str()) { AuthorRole::Seller } else { @@ -500,13 +495,16 @@ fn actor_for_envelope( )) .into()); }; - let actor = - Actor::from_public_key_hex(author_pubkey, ActorSource::ExplicitPublicKey, [role]) - .map_err(|error| RuntimeError::Config(format!("invalid trade SDK actor: {error}")))?; + let actor = Actor::from_public_key_hex( + author_pubkey.as_str(), + ActorSource::ExplicitPublicKey, + [role], + ) + .map_err(|error| RuntimeError::Config(format!("invalid trade SDK actor: {error}")))?; validate_configured_signer_for_actor( config, - Some(account.record.account_id.as_str()), - author_pubkey, + Some(account.record.id().to_hex().as_str()), + author_pubkey.as_str(), operation, )?; Ok(actor) @@ -668,7 +666,8 @@ fn candidate_terms( RuntimeError::Config(format!("listing price_currency is invalid: {error}")) })?; let quantity_amount = exact_positive_decimal(product.qty_amt_exact.as_str(), "qty_amt_exact")? - * Decimal::from(bin_count); + .checked_mul(Decimal::from(bin_count)) + .map_err(|error| RuntimeError::Config(format!("trade quantity overflow: {error}")))?; let quantity_unit = product .qty_unit .parse::<Unit>() @@ -685,8 +684,13 @@ fn candidate_terms( "listing quantity and price units are incompatible: {error}" )) })?; - let unit_price_amount = (price_amount / price_quantity_amount) * quantity_unit_in_price_units; - let subtotal = unit_price_amount * quantity_amount; + let unit_price_amount = price_amount + .checked_div(price_quantity_amount) + .and_then(|value| value.checked_mul(quantity_unit_in_price_units)) + .map_err(|error| RuntimeError::Config(format!("trade unit price is invalid: {error}")))?; + let subtotal = unit_price_amount + .checked_mul(quantity_amount) + .map_err(|error| RuntimeError::Config(format!("trade subtotal overflow: {error}")))?; let quantity_scale = u8::try_from(quantity_amount.scale()) .map_err(|_| RuntimeError::Config("trade quantity scale exceeds u8".to_owned()))?; let currency_exponent = currency.minor_unit_exponent(); @@ -927,7 +931,7 @@ fn exact_positive_decimal(value: &str, field: &str) -> Result<Decimal, RuntimeEr fn decimal_mantissa_at_scale(mut value: Decimal, scale: u32) -> String { value.rescale(scale); - value.0.mantissa().to_string() + value.to_string().replace('.', "") } fn candidate_draft_file(config: &RuntimeConfig, trade_id: &str) -> PathBuf { @@ -971,7 +975,7 @@ fn inventory_bin_id(value: &str, field: &str) -> Result<InventoryBinId, RuntimeE } fn pubkey(value: &str, field: &str) -> Result<PublicKey, RuntimeError> { - PublicKey::parse(value) + PublicKey::from_hex(value) .map_err(|error| RuntimeError::Config(format!("{field} is invalid: {error}"))) } diff --git a/src/view/runtime.rs b/src/view/runtime.rs @@ -3,7 +3,7 @@ use std::process::ExitCode; use radroots_event::farm::Farm; use radroots_event::id::ClassifiedListingAddress; use radroots_event::listing::operational::OperationalListingPublicLocation; -use radroots_nostr_accounts::prelude::RadrootsNostrAccountRecord; +use radroots_identity::account::Record as AccountRecord; use serde::Serialize; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -427,11 +427,12 @@ pub struct IdentityPublicView { } impl IdentityPublicView { - pub fn from_public_identity(identity: &radroots_identity::RadrootsIdentityPublic) -> Self { + pub fn from_public_identity(identity: &radroots_identity::PublicIdentity) -> Self { Self { - id: identity.id.to_string(), - public_key_hex: identity.public_key_hex.clone(), - public_key_npub: identity.public_key_npub.clone(), + id: identity.id().to_string(), + public_key_hex: identity.public_key().to_hex(), + public_key_npub: radroots_nostr::key::public_key_to_npub(identity.public_key()) + .unwrap_or_else(|_| identity.public_key().to_hex()), } } } @@ -449,15 +450,15 @@ pub struct AccountSummaryView { impl AccountSummaryView { pub fn from_account_runtime( - record: &RadrootsNostrAccountRecord, + record: &AccountRecord, signer: &str, custody: &str, write_capable: bool, is_default: bool, ) -> Self { Self { - id: record.account_id.to_string(), - display_name: record.label.clone(), + id: record.id().to_string(), + display_name: record.label().map(ToOwned::to_owned), signer: signer.to_owned(), custody: custody.to_owned(), write_capable,