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:
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,