commit d734e0fb82ddaa87d37d8ba1f1106dfbd761b81d
parent ce5be2dc7e548e6a44eda80acc19ee1adac29002
Author: triesap <tyson@radroots.org>
Date: Wed, 24 Jun 2026 00:57:25 +0000
cli: enable sdk-backed myc signing
- Add CLI signer provider wiring for local key and Myc NIP-46 SDK signing modes.
- Route farm and listing writes through configured SDK signer providers without local fallback.
- Replace deferred Myc readiness with binding and session validation for config, health, and signer status surfaces.
- Cover direct relay and radrootsd proxy Myc listing publishes with deterministic integration tests.
- Validate with cargo fmt --all --check, cargo check --all-targets, and cargo test --all-targets.
Diffstat:
16 files changed, 1444 insertions(+), 326 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -3617,6 +3617,7 @@ dependencies = [
"clap",
"flate2",
"getrandom 0.2.17",
+ "nostr",
"radroots_authority",
"radroots_core",
"radroots_events",
@@ -3626,6 +3627,7 @@ dependencies = [
"radroots_log",
"radroots_nostr",
"radroots_nostr_accounts",
+ "radroots_nostr_connect",
"radroots_nostr_signer",
"radroots_protected_store",
"radroots_replica_db",
@@ -3920,6 +3922,8 @@ dependencies = [
"radroots_events_codec",
"radroots_identity",
"radroots_nostr",
+ "radroots_nostr_connect",
+ "radroots_nostr_signer",
"radroots_outbox",
"radroots_publish_proxy_protocol",
"radroots_relay_transport",
diff --git a/Cargo.toml b/Cargo.toml
@@ -35,7 +35,9 @@ radroots_local_events = { path = "../lib/crates/local_events" }
radroots_log = { path = "../lib/crates/log" }
radroots_nostr_accounts = { path = "../lib/crates/nostr_accounts", features = ["os-keyring"] }
radroots_nostr = { path = "../lib/crates/nostr", features = ["client", "events"] }
+radroots_nostr_connect = { path = "../lib/crates/nostr_connect" }
radroots_nostr_signer = { path = "../lib/crates/nostr_signer" }
+radroots_protected_store = { path = "../lib/crates/protected_store", features = ["std"] }
radroots_replica_db = { path = "../lib/crates/replica_db" }
radroots_replica_db_schema = { path = "../lib/crates/replica_db_schema" }
radroots_replica_sync = { path = "../lib/crates/replica_sync" }
@@ -49,7 +51,7 @@ radroots_trade = { path = "../lib/crates/trade" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2.0"
-tokio = { version = "1", features = ["rt-multi-thread", "time"] }
+tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
toml = "0.8"
url = "2.5"
zeroize = "1.8"
@@ -57,7 +59,7 @@ zeroize = "1.8"
[dev-dependencies]
assert_cmd = "2.0"
flate2 = "1"
-radroots_protected_store = { path = "../lib/crates/protected_store", features = ["std"] }
+nostr = { version = "0.44.2", features = ["nip44"] }
tar = "0.4"
tempfile = "3.17"
tungstenite = "0.26.2"
diff --git a/src/ops/error.rs b/src/ops/error.rs
@@ -281,6 +281,12 @@ impl OperationAdapterError {
RuntimeFailureAvailability::Unconfigured,
)
}
+ RuntimeError::Config(_) if looks_like_signer_failure(&lowered) => {
+ Self::SignerUnconfigured {
+ operation_id: operation_id.to_owned(),
+ message,
+ }
+ }
RuntimeError::Config(_) if looks_like_validation_failure(&lowered) => {
Self::ValidationFailed {
operation_id: operation_id.to_owned(),
diff --git a/src/ops/exec/core.rs b/src/ops/exec/core.rs
@@ -30,6 +30,7 @@ use crate::runtime::account::{
use crate::runtime::config::{PublishTransport, RuntimeConfig, SignerBackend};
use crate::runtime::logging::LoggingState;
use crate::runtime::sdk::CliSdkAdapterError;
+use crate::runtime::signer::resolve_signer_status;
use crate::view::runtime::{
CommandDisposition, LocalBackupView, LocalRestoreView, PublishProviderRuntimeView,
PublishRelayRuntimeView, PublishRuntimeView,
@@ -874,14 +875,12 @@ fn direct_nostr_relay_publish_readiness(
}
if matches!(config.signer.backend, SignerBackend::Myc) {
- return (
- "unavailable",
- false,
- Some(
- "direct_nostr_relay publish transport requires signer mode `local` for signed writes; signer mode `myc` is deferred"
- .to_owned(),
- ),
- );
+ let signer = resolve_signer_status(config);
+ return if signer.state == "ready" {
+ ("ready", true, None)
+ } else {
+ ("unconfigured", false, signer.reason)
+ };
}
let Some(resolved_account) = account.resolved_account.as_ref() else {
@@ -920,14 +919,12 @@ fn radrootsd_publish_readiness(config: &RuntimeConfig) -> (&'static str, bool, O
}
if matches!(config.signer.backend, SignerBackend::Myc) {
- return (
- "unavailable",
- false,
- Some(
- "radrootsd_proxy publish transport requires signer mode `local` for signed writes; signer mode `myc` is deferred"
- .to_owned(),
- ),
- );
+ let signer = resolve_signer_status(config);
+ return if signer.state == "ready" {
+ ("ready", true, None)
+ } else {
+ ("unconfigured", false, signer.reason)
+ };
}
("ready", true, None)
@@ -952,12 +949,16 @@ fn signer_health_view(config: &RuntimeConfig, account: &AccountResolution) -> Va
},
})
}
- SignerBackend::Myc => json!({
- "state": "unavailable",
- "backend": config.signer.backend.as_str(),
- "write_capable_account": false,
- "reason": "signer mode `myc` is deferred for CLI writes",
- }),
+ SignerBackend::Myc => {
+ let signer = resolve_signer_status(config);
+ json!({
+ "state": signer.state,
+ "backend": config.signer.backend.as_str(),
+ "write_capable_account": signer.reason.is_none(),
+ "reason": signer.reason,
+ "binding_state": signer.binding.state,
+ })
+ }
}
}
diff --git a/src/ops/exec/listing.rs b/src/ops/exec/listing.rs
@@ -335,7 +335,7 @@ where
fn listing_relay_unavailable(view: &ListingMutationView) -> bool {
matches!(
view.source.as_str(),
- "direct Nostr relay publish · local key" | "SDK listing publish · local key"
+ "direct Nostr relay publish · local key" | "SDK listing publish · configured signer"
) && (view.reason.as_deref().is_some_and(|reason| {
reason.contains("configured relay")
|| reason.contains("direct relay connection failed")
diff --git a/src/registry/mod.rs b/src/registry/mod.rs
@@ -211,12 +211,7 @@ pub fn network_requirement(operation_id: &str) -> NetworkRequirement {
pub fn requires_local_signer_mode(operation_id: &str) -> bool {
matches!(
operation_id,
- "signer.status.get"
- | "farm.publish"
- | "sync.push"
- | "listing.publish"
- | "listing.update"
- | "listing.archive"
+ "sync.push"
| "order.submit"
| "order.accept"
| "order.decline"
@@ -577,12 +572,7 @@ mod tests {
.map(|operation| operation.operation_id)
.collect::<BTreeSet<_>>();
let expected = [
- "signer.status.get",
"sync.push",
- "farm.publish",
- "listing.publish",
- "listing.update",
- "listing.archive",
"order.submit",
"order.accept",
"order.decline",
diff --git a/src/runtime/account.rs b/src/runtime/account.rs
@@ -1,4 +1,4 @@
-use std::{fmt, path::Path};
+use std::{fmt, path::Path, sync::Arc};
use radroots_identity::{
IdentityError, RadrootsIdentity, RadrootsIdentityPublic, load_identity_profile,
@@ -7,8 +7,9 @@ use radroots_nostr_accounts::prelude::{
RadrootsNostrAccountRecord, RadrootsNostrAccountStatus, RadrootsNostrAccountsError,
RadrootsNostrAccountsManager,
};
+use radroots_protected_store::RadrootsProtectedFileSecretVault;
use radroots_secret_vault::{
- RadrootsHostVaultCapabilities, RadrootsResolvedSecretBackend,
+ RadrootsHostVaultCapabilities, RadrootsResolvedSecretBackend, RadrootsSecretBackend,
RadrootsSecretBackendAvailability, RadrootsSecretBackendSelection, RadrootsSecretVault,
RadrootsSecretVaultError, RadrootsSecretVaultOsKeyring,
};
@@ -594,6 +595,26 @@ pub fn secret_backend_status(config: &RuntimeConfig) -> AccountSecretBackendStat
}
}
+pub fn load_secret_backend_secret(
+ config: &RuntimeConfig,
+ slot: &str,
+ service_name: &str,
+) -> Result<Option<String>, RuntimeError> {
+ if slot.trim().is_empty() {
+ return Err(RuntimeError::Config(
+ "secret backend slot must not be empty".to_owned(),
+ ));
+ }
+ let resolved = resolve_secret_backend(config).map_err(secret_backend_resolution_error)?;
+ let vault = secret_vault_for_backend(config, resolved.backend, service_name)?;
+ vault.load_secret(slot).map_err(|error| {
+ RuntimeError::Config(format!(
+ "failed to load secret `{slot}` from account secret backend `{}`: {error}",
+ resolved.backend.kind()
+ ))
+ })
+}
+
fn snapshot_from_manager(
manager: &RadrootsNostrAccountsManager,
) -> Result<AccountSnapshot, RuntimeError> {
@@ -773,6 +794,36 @@ fn resolve_secret_backend(
})
}
+fn secret_backend_resolution_error(error: SecretBackendResolutionError) -> RuntimeError {
+ match error {
+ SecretBackendResolutionError::Unavailable(reason)
+ | SecretBackendResolutionError::Invalid(reason) => RuntimeError::Config(reason),
+ }
+}
+
+fn secret_vault_for_backend(
+ config: &RuntimeConfig,
+ backend: RadrootsSecretBackend,
+ service_name: &str,
+) -> Result<Arc<dyn RadrootsSecretVault>, RuntimeError> {
+ match backend {
+ RadrootsSecretBackend::HostVault(_) => {
+ Ok(Arc::new(RadrootsSecretVaultOsKeyring::new(service_name)))
+ }
+ RadrootsSecretBackend::EncryptedFile => Ok(Arc::new(
+ RadrootsProtectedFileSecretVault::new(config.account.secrets_dir.as_path()),
+ )),
+ RadrootsSecretBackend::ExternalCommand => Err(RuntimeError::Config(
+ "external_command account secret backend is not supported for CLI signer sessions"
+ .to_owned(),
+ )),
+ RadrootsSecretBackend::Memory => Err(RuntimeError::Config(
+ "memory account secret backend is not supported for persisted CLI signer sessions"
+ .to_owned(),
+ )),
+ }
+}
+
fn account_secret_backend_selection(config: &RuntimeConfig) -> RadrootsSecretBackendSelection {
RadrootsSecretBackendSelection {
primary: config.account.secret_backend,
diff --git a/src/runtime/config.rs b/src/runtime/config.rs
@@ -317,7 +317,6 @@ pub enum CapabilityBindingTargetKind {
ExplicitEndpoint,
}
-#[cfg(test)]
impl CapabilityBindingTargetKind {
pub fn as_str(self) -> &'static str {
match self {
@@ -333,7 +332,6 @@ pub enum CapabilityBindingSource {
WorkspaceConfig,
}
-#[cfg(test)]
impl CapabilityBindingSource {
pub fn as_str(self) -> &'static str {
match self {
diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs
@@ -1,7 +1,7 @@
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
-use radroots_authority::{RadrootsActorContext, RadrootsLocalEventSigner};
+use radroots_authority::RadrootsActorContext;
use radroots_events::contract::RadrootsActorRole;
use radroots_events::farm::{RadrootsFarm, RadrootsFarmLocation};
use radroots_events::kinds::{KIND_FARM, KIND_PROFILE};
@@ -9,7 +9,6 @@ use radroots_events::listing::RadrootsListingLocation;
use radroots_events::profile::{RadrootsProfile, RadrootsProfileType};
use radroots_events_codec::d_tag::is_d_tag_base64url;
use radroots_events_codec::profile::encode::to_wire_parts_with_profile_type;
-use radroots_nostr::prelude::RadrootsNostrKeys;
use radroots_sdk::{
FarmEnqueuePublishRequest, FarmEnqueueReceipt, FarmPreparePublishRequest, FarmPublishPlan,
PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt, PushOutboxRelayOutcomeKind,
@@ -31,8 +30,9 @@ use crate::runtime::farm_config::{
use crate::runtime::local_events::append_local_work;
use crate::runtime::sdk::{
CliSdkAdapterError, CliSdkSession, sdk_relay_target_policy, sdk_relay_url_policy,
+ validate_configured_signer_for_actor,
};
-use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority};
+use crate::runtime::signer::ActorWriteBindingError;
use crate::view::runtime::{
FarmConfigDocumentView, FarmConfigSummaryView, FarmGetView, FarmListingDefaultsView,
FarmPublicationView, FarmPublishComponentView, FarmPublishEventView, FarmPublishView,
@@ -42,7 +42,7 @@ use crate::view::runtime::{
const FARM_CONFIG_SOURCE: &str = "farm config · local first";
const FARM_SELLER_ACTOR_SOURCE: &str = "farm_config";
-const SDK_FARM_WRITE_SOURCE: &str = "SDK farm publish · local key";
+const SDK_FARM_WRITE_SOURCE: &str = "SDK farm publish · configured signer";
const SDK_PROFILE_NOT_SUBMITTED_METHOD: &str = "sdk.farm.profile.not_submitted";
const SDK_FARM_PUBLISH_METHOD: &str = "sdk.farm.publish.v1";
const SDK_PROFILE_NOT_SUBMITTED_REASON: &str =
@@ -518,15 +518,26 @@ fn relay_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(),
+ "farm seller",
+ ) {
+ return FarmPublishReadiness {
+ state: "unconfigured",
+ executable: false,
+ reason: Some(error.to_string()),
+ missing: vec!["Remote signer binding".to_owned()],
+ actions: vec!["radroots signer status get".to_owned()],
+ };
+ }
return FarmPublishReadiness {
- state: "unavailable",
- executable: false,
- reason: Some(
- "direct_nostr_relay farm publish requires signer mode `local`; signer mode `myc` is deferred"
- .to_owned(),
- ),
- missing: vec!["Local signer mode".to_owned()],
- actions: vec!["radroots signer status get".to_owned()],
+ state: "ready",
+ executable: true,
+ reason: None,
+ missing: Vec::new(),
+ actions: Vec::new(),
};
}
@@ -644,12 +655,14 @@ fn publish_via_sdk(
) -> Result<FarmPublishView, CliSdkAdapterError> {
let input = sdk_farm_publish_input(&resolved, account_pubkey.as_str())?;
if config.output.dry_run {
- if let Err(error) = resolve_farm_signing_identity(
+ if let Err(error) = validate_configured_signer_for_actor(
config,
- resolved.document.selection.account.as_str(),
+ Some(resolved.document.selection.account.as_str()),
account_pubkey.as_str(),
+ "farm seller",
) {
- return match error {
+ let binding_error = ActorWriteBindingError::from_runtime(error);
+ return match binding_error {
ActorWriteBindingError::Account(failure) => Err(RuntimeError::from(failure).into()),
error => Ok(binding_error_publish_view(
config,
@@ -681,18 +694,18 @@ fn publish_via_sdk(
));
}
- let session = CliSdkSession::connect(config)?;
- let signer = sdk_farm_signer(
+ let session = CliSdkSession::connect_for_actor(
config,
- resolved.document.selection.account.as_str(),
+ Some(resolved.document.selection.account.as_str()),
account_pubkey.as_str(),
+ "farm seller",
)?;
let mut request =
FarmEnqueuePublishRequest::new(input.actor, input.farm, sdk_relay_target_policy(config));
if let Some(idempotency_key) = farm_idempotency_key.as_deref() {
request = request.try_with_idempotency_key(idempotency_key)?;
}
- let enqueue = session.block_on(session.sdk().farms().enqueue_publish(request, &signer))?;
+ let enqueue = session.block_on(session.sdk().farms().enqueue_publish(request))?;
let push = session.block_on(
session.sdk().sync().push_outbox(
PushOutboxRequest::new()
@@ -777,39 +790,6 @@ fn missing_publish_view(
}
}
-fn resolve_farm_signing_identity(
- config: &RuntimeConfig,
- account_id: &str,
- account_pubkey: &str,
-) -> Result<account::AccountSigningIdentity, ActorWriteBindingError> {
- if !matches!(
- config.signer.backend,
- crate::runtime::config::SignerBackend::Local
- ) {
- return resolve_actor_write_authority(config, "farm", account_pubkey).and_then(|_| {
- Err(ActorWriteBindingError::Unconfigured(
- "farm publish requires signer mode `local`".to_owned(),
- ))
- });
- }
- let signing = account::resolve_local_signing_identity_for_account(config, account_id)
- .map_err(ActorWriteBindingError::from_runtime)?;
- let selected_pubkey = signing
- .account
- .record
- .public_identity
- .public_key_hex
- .as_str();
- if !selected_pubkey.eq_ignore_ascii_case(account_pubkey) {
- return Err(ActorWriteBindingError::Account(
- account::AccountRuntimeFailure::mismatch(format!(
- "account mismatch: resolved account pubkey `{selected_pubkey}` cannot sign farm-bound seller pubkey `{account_pubkey}`"
- )),
- ));
- }
- Ok(signing)
-}
-
fn base_publish_view(
state: &str,
config: &RuntimeConfig,
@@ -977,20 +957,6 @@ fn sdk_farm_publish_input(
})
}
-fn sdk_farm_signer(
- config: &RuntimeConfig,
- account_id: &str,
- account_pubkey: &str,
-) -> Result<RadrootsLocalEventSigner, RuntimeError> {
- let signing = match resolve_farm_signing_identity(config, account_id, account_pubkey) {
- Ok(signing) => signing,
- Err(ActorWriteBindingError::Account(failure)) => return Err(failure.into()),
- Err(error) => return Err(RuntimeError::Config(error.reason())),
- };
- let keys: RadrootsNostrKeys = signing.identity.into_keys();
- RadrootsLocalEventSigner::new(keys).map_err(|error| RuntimeError::Config(error.to_string()))
-}
-
fn sdk_prepared_publish_view(
config: &RuntimeConfig,
args: &FarmPublishArgs,
diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs
@@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
-use radroots_authority::{RadrootsActorContext, RadrootsLocalEventSigner};
+use radroots_authority::RadrootsActorContext;
use radroots_core::{
RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountScope,
RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreMoney,
@@ -24,7 +24,6 @@ use radroots_events::trade_validation::RadrootsTradeValidationListingError;
use radroots_events_codec::d_tag::is_d_tag_base64url;
use radroots_events_codec::listing::encode::to_wire_parts_with_kind;
use radroots_local_events::{LocalEventRecord, LocalRecordFamily, SourceRuntime};
-use radroots_nostr::prelude::RadrootsNostrKeys;
use radroots_replica_db::ReplicaSql;
use radroots_sdk::{
ListingEnqueuePublishRequest, ListingEnqueueReceipt, ListingPreparePublishRequest,
@@ -42,7 +41,7 @@ use crate::cli::global::{
};
use crate::runtime::RuntimeError;
use crate::runtime::account;
-use crate::runtime::config::{RuntimeConfig, SignerBackend};
+use crate::runtime::config::RuntimeConfig;
use crate::runtime::farm_config;
use crate::runtime::local_events::{
append_local_work, get_shared_record, list_shared_records_before, list_shared_records_latest,
@@ -50,6 +49,7 @@ use crate::runtime::local_events::{
};
use crate::runtime::sdk::{
CliSdkAdapterError, CliSdkSession, sdk_relay_target_policy, sdk_relay_url_policy,
+ validate_configured_signer_for_actor,
};
use crate::runtime::sync::{
RelayIngestScope, freshness_for_scope_from_executor, market_refresh, missing_freshness,
@@ -66,7 +66,7 @@ const DRAFT_KIND: &str = "listing_draft_v1";
const LISTING_SOURCE: &str = "local draft · local first";
const LISTING_READ_SOURCE: &str = "local replica · local first";
const LISTING_APP_RECORD_SOURCE: &str = "shared local events · app";
-const SDK_LISTING_WRITE_SOURCE: &str = "SDK listing publish · local key";
+const SDK_LISTING_WRITE_SOURCE: &str = "SDK listing publish · configured signer";
const LISTING_DRAFTS_DIR: &str = "listings/drafts";
const LISTING_SELLER_ACTOR_SOURCE_FARM_CONFIG: &str = "farm_config";
const LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT: &str = "resolved_account";
@@ -1741,7 +1741,7 @@ pub fn publish_via_sdk(
) -> Result<ListingMutationView, CliSdkAdapterError> {
let input = sdk_listing_publish_input(config, args)?;
if config.output.dry_run {
- validate_local_listing_signer(config, &input.canonical)?;
+ validate_configured_listing_signer(config, &input.canonical)?;
let session = CliSdkSession::connect_memory(config)?;
let plan = session.sdk().listings().prepare_publish(
ListingPreparePublishRequest::from_document(
@@ -1758,8 +1758,12 @@ pub fn publish_via_sdk(
));
}
- let session = CliSdkSession::connect(config)?;
- let signer = sdk_listing_signer(config, &input.canonical)?;
+ let session = CliSdkSession::connect_for_actor(
+ config,
+ Some(input.canonical.seller_account_id.as_str()),
+ input.canonical.seller_pubkey.as_str(),
+ "listing seller",
+ )?;
let mut request = ListingEnqueuePublishRequest::from_document(
input.actor,
input.document,
@@ -1768,8 +1772,7 @@ pub fn publish_via_sdk(
if let Some(idempotency_key) = args.idempotency_key.as_deref() {
request = request.try_with_idempotency_key(idempotency_key)?;
}
- let enqueue_receipt =
- session.block_on(session.sdk().listings().enqueue_publish(request, &signer))?;
+ let enqueue_receipt = session.block_on(session.sdk().listings().enqueue_publish(request))?;
let push_receipt = if args.offline {
None
} else {
@@ -1842,15 +1845,6 @@ fn sdk_listing_publish_input(
})
}
-fn sdk_listing_signer(
- config: &RuntimeConfig,
- canonical: &CanonicalListingDraft,
-) -> Result<RadrootsLocalEventSigner, RuntimeError> {
- let signing = resolve_listing_signing_identity(config, canonical)?;
- let keys: RadrootsNostrKeys = signing.identity.into_keys();
- RadrootsLocalEventSigner::new(keys).map_err(|error| RuntimeError::Config(error.to_string()))
-}
-
fn sdk_prepared_publish_view(
config: &RuntimeConfig,
args: &ListingMutationArgs,
@@ -2152,8 +2146,8 @@ fn mutate(
});
}
- if config.output.dry_run && matches!(config.signer.backend, SignerBackend::Local) {
- validate_local_listing_signer(config, &canonical)?;
+ if config.output.dry_run {
+ validate_configured_listing_signer(config, &canonical)?;
}
mutate_via_sdk_from_canonical(config, args, operation, canonical)
@@ -2183,8 +2177,12 @@ fn mutate_via_sdk_from_canonical(
));
}
- let session = CliSdkSession::connect(config)?;
- let signer = sdk_listing_signer(config, &canonical)?;
+ let session = CliSdkSession::connect_for_actor(
+ config,
+ Some(canonical.seller_account_id.as_str()),
+ canonical.seller_pubkey.as_str(),
+ "listing seller",
+ )?;
let mut request = ListingEnqueuePublishRequest::from_document(
actor,
document,
@@ -2193,8 +2191,7 @@ fn mutate_via_sdk_from_canonical(
if let Some(idempotency_key) = args.idempotency_key.as_deref() {
request = request.try_with_idempotency_key(idempotency_key)?;
}
- let enqueue_receipt =
- session.block_on(session.sdk().listings().enqueue_publish(request, &signer))?;
+ let enqueue_receipt = session.block_on(session.sdk().listings().enqueue_publish(request))?;
let push_receipt = if args.offline {
None
} else {
@@ -2794,87 +2791,16 @@ fn invalid_validation_view(
}
}
-fn validate_local_listing_signer(
+fn validate_configured_listing_signer(
config: &RuntimeConfig,
canonical: &CanonicalListingDraft,
) -> Result<(), RuntimeError> {
- resolve_listing_signing_identity(config, canonical).map(|_| ())
-}
-
-fn resolve_listing_signing_identity(
- config: &RuntimeConfig,
- canonical: &CanonicalListingDraft,
-) -> Result<account::AccountSigningIdentity, RuntimeError> {
- let signing = account::resolve_local_signing_identity_for_account(
+ validate_configured_signer_for_actor(
config,
- canonical.seller_account_id.as_str(),
+ Some(canonical.seller_account_id.as_str()),
+ canonical.seller_pubkey.as_str(),
+ "listing seller",
)
- .map_err(|error| listing_bound_signing_error(error, canonical))?;
- let account_pubkey = signing
- .account
- .record
- .public_identity
- .public_key_hex
- .as_str();
- if !account_pubkey.eq_ignore_ascii_case(canonical.seller_pubkey.as_str()) {
- return Err(account::AccountRuntimeFailure::mismatch_with_detail(
- format!(
- "account mismatch: listing-bound seller account `{}` pubkey `{account_pubkey}` cannot sign listing seller_pubkey `{}`",
- canonical.seller_account_id, canonical.seller_pubkey
- ),
- json!({
- "seller_actor_source": canonical.seller_actor_source,
- "listing_seller_account_id": canonical.seller_account_id,
- "listing_seller_pubkey": canonical.seller_pubkey,
- "account_pubkey": account_pubkey,
- "actions": [
- "radroots account import <path>",
- "radroots account attach-secret <account-id> <path>",
- ],
- }),
- )
- .into());
- }
- Ok(signing)
-}
-
-fn listing_bound_signing_error(
- error: RuntimeError,
- canonical: &CanonicalListingDraft,
-) -> RuntimeError {
- match error {
- RuntimeError::Account(account::AccountRuntimeFailure::Unresolved(issue)) => {
- account::AccountRuntimeFailure::unresolved_with_detail(
- issue.message().to_owned(),
- json!({
- "seller_actor_source": canonical.seller_actor_source,
- "listing_seller_account_id": canonical.seller_account_id,
- "listing_seller_pubkey": canonical.seller_pubkey,
- "actions": [
- "radroots account import <path>",
- format!("radroots listing rebind <file> {}", canonical.seller_account_id),
- ],
- }),
- )
- .into()
- }
- RuntimeError::Account(account::AccountRuntimeFailure::WatchOnly(issue)) => {
- account::AccountRuntimeFailure::watch_only_with_detail(
- &canonical.seller_account_id,
- json!({
- "seller_actor_source": canonical.seller_actor_source,
- "listing_seller_account_id": canonical.seller_account_id,
- "listing_seller_pubkey": canonical.seller_pubkey,
- "reason": issue.message(),
- "actions": [
- format!("radroots account attach-secret {} <path>", canonical.seller_account_id),
- ],
- }),
- )
- .into()
- }
- other => other,
- }
}
fn issue_from_trade_validation(
diff --git a/src/runtime/order.rs b/src/runtime/order.rs
@@ -5422,7 +5422,7 @@ fn enqueue_order_revision_proposal_via_sdk(
session
.sdk()
.orders()
- .enqueue_revision_proposal(request, &signer),
+ .enqueue_revision_proposal_with_explicit_signer(request, &signer),
)?;
let push = push_one_sdk_outbox_event(&session, policy)?;
Ok(sdk_enqueued_order_revision_view(
@@ -5467,7 +5467,7 @@ fn enqueue_order_revision_decision_via_sdk(
session
.sdk()
.orders()
- .enqueue_revision_decision(request, &signer),
+ .enqueue_revision_decision_with_explicit_signer(request, &signer),
)?;
let push = push_one_sdk_outbox_event(&session, policy)?;
Ok(sdk_enqueued_order_revision_decision_view(
@@ -5526,7 +5526,7 @@ fn enqueue_order_cancellation_via_sdk(
session
.sdk()
.orders()
- .enqueue_cancellation(request, &signer),
+ .enqueue_cancellation_with_explicit_signer(request, &signer),
)?;
let push = push_one_sdk_outbox_event(&session, policy)?;
Ok(sdk_enqueued_order_cancellation_view(
@@ -6122,7 +6122,12 @@ fn enqueue_order_decision_via_sdk(
let keys: RadrootsNostrKeys = signing.identity.into_keys();
let signer = RadrootsLocalEventSigner::new(keys)
.map_err(|error| RuntimeError::Config(error.to_string()))?;
- let enqueue = session.block_on(session.sdk().orders().enqueue_decision(request, &signer))?;
+ let enqueue = session.block_on(
+ session
+ .sdk()
+ .orders()
+ .enqueue_decision_with_explicit_signer(request, &signer),
+ )?;
let push = session.block_on(
session.sdk().sync().push_outbox(
PushOutboxRequest::new()
@@ -9465,7 +9470,12 @@ fn submit_via_sdk(
let keys: RadrootsNostrKeys = signing.identity.into_keys();
let signer = RadrootsLocalEventSigner::new(keys)
.map_err(|error| RuntimeError::Config(error.to_string()))?;
- let enqueue = session.block_on(session.sdk().orders().enqueue_submit(request, &signer))?;
+ let enqueue = session.block_on(
+ session
+ .sdk()
+ .orders()
+ .enqueue_submit_with_explicit_signer(request, &signer),
+ )?;
let push = session.block_on(
session.sdk().sync().push_outbox(
PushOutboxRequest::new()
diff --git a/src/runtime/sdk.rs b/src/runtime/sdk.rs
@@ -3,23 +3,42 @@
use std::fs;
use std::future::Future;
use std::path::PathBuf;
+use std::sync::Arc;
+use std::time::Duration;
use radroots_authority::RadrootsLocalEventSigner;
-use radroots_nostr::prelude::RadrootsNostrKeys;
+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 radroots_sdk::{
- RadrootsSdk, RadrootsSdkBuilder, RadrootsSdkError, RadrootsSdkStorageConfig,
- SdkPublishTransport, SdkRelayUrlPolicy,
+ RadrootsSdk, RadrootsSdkBuilder, RadrootsSdkError, RadrootsSdkLocalKeySigner,
+ RadrootsSdkMycNip46Signer, RadrootsSdkNip46Transport, RadrootsSdkNip46TransportFuture,
+ RadrootsSdkSignerProvider, RadrootsSdkStorageConfig, SdkPublishTransport, SdkRelayUrlPolicy,
adapters::radrootsd::{RadrootsdAuth, RadrootsdProxyConfig as SdkRadrootsdProxyConfig},
};
use radroots_secret_vault::{RadrootsSecretVault, RadrootsSecretVaultOsKeyring};
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::{PublishTransport, RuntimeConfig};
+use crate::runtime::config::{
+ CapabilityBindingTargetKind, PublishTransport, RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY,
+ SignerBackend,
+};
const SDK_STORAGE_DIR_NAME: &str = "sdk";
const RADROOTSD_PROXY_SECRET_SERVICE: &str = "org.radroots.cli.radrootsd-proxy";
+pub(crate) const MYC_NIP46_SESSION_SECRET_SERVICE: &str = "org.radroots.cli.myc-nip46-session";
#[derive(Debug, thiserror::Error)]
pub enum CliSdkAdapterError {
@@ -89,6 +108,53 @@ impl CliSdkSession {
})
}
+ pub fn connect_for_actor(
+ config: &RuntimeConfig,
+ actor_account_id: Option<&str>,
+ actor_pubkey: &str,
+ 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 sdk = runtime.block_on(
+ sdk_config
+ .builder()
+ .signer_provider(signer_provider)
+ .build(),
+ )?;
+ Ok(Self {
+ runtime,
+ sdk,
+ config: sdk_config,
+ })
+ }
+
+ pub fn connect_memory_for_actor(
+ config: &RuntimeConfig,
+ actor_account_id: Option<&str>,
+ actor_pubkey: &str,
+ 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 sdk = runtime.block_on(
+ memory_builder(&sdk_config)
+ .signer_provider(signer_provider)
+ .build(),
+ )?;
+ Ok(Self {
+ runtime,
+ sdk,
+ config: sdk_config,
+ })
+ }
+
pub fn sdk(&self) -> &RadrootsSdk {
&self.sdk
}
@@ -105,6 +171,15 @@ impl CliSdkSession {
}
}
+pub fn validate_configured_signer_for_actor(
+ config: &RuntimeConfig,
+ actor_account_id: Option<&str>,
+ 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,
@@ -144,6 +219,314 @@ impl CliSdkLocalSigner {
}
}
+enum CliSdkSignerInput {
+ LocalKey(RadrootsNostrKeys),
+ 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 keys = local_key_signer_input(config, actor_account_id, actor_pubkey, actor_label)?;
+ Ok(CliSdkSignerInput::LocalKey(keys))
+ }
+ 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<RadrootsNostrKeys, 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());
+ }
+ Ok(signing.identity.into_keys())
+}
+
+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() {
+ let matched_account = actor_account_id
+ .is_some_and(|account_id| managed_account_ref == account_id)
+ || managed_account_ref == actor_pubkey;
+ if !matched_account {
+ 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(),
+ })
+}
+
+async fn signer_provider(
+ config: &RuntimeConfig,
+ signer_input: CliSdkSignerInput,
+) -> Result<RadrootsSdkSignerProvider, RuntimeError> {
+ match signer_input {
+ CliSdkSignerInput::LocalKey(keys) => {
+ let signer = RadrootsSdkLocalKeySigner::new(keys)
+ .map_err(|error| RuntimeError::Config(error.to_string()))?;
+ Ok(RadrootsSdkSignerProvider::LocalKey(signer))
+ }
+ CliSdkSignerInput::MycNip46 {
+ client_keys,
+ target,
+ actor_pubkey,
+ } => {
+ let transport = Arc::new(
+ CliSdkNip46RelayTransport::connect(
+ &client_keys,
+ &target,
+ Duration::from_millis(config.myc.status_timeout_ms),
+ )
+ .await?,
+ );
+ let signer =
+ RadrootsSdkMycNip46Signer::new(client_keys, target, actor_pubkey, transport)
+ .map_err(|error| RuntimeError::Config(error.to_string()))?;
+ Ok(RadrootsSdkSignerProvider::MycNip46(signer))
+ }
+ }
+}
+
+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.notifications();
+ client.subscribe(filter, None).await.map_err(|error| {
+ RuntimeError::Network(format!(
+ "failed to subscribe to signer.remote_nip46 response relays: {error}"
+ ))
+ })?;
+ Ok(Self {
+ client,
+ notifications: Mutex::new(notifications),
+ request_timeout,
+ deadline: Mutex::new(None),
+ })
+ }
+}
+
+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());
+ }
+ })
+ }
+}
+
pub fn sdk_storage_root(config: &RuntimeConfig) -> PathBuf {
config.local.root.join(SDK_STORAGE_DIR_NAME)
}
@@ -343,6 +726,13 @@ mod tests {
},
DirectRrRsDependency {
section: "dependencies",
+ name: "radroots_nostr_connect",
+ owner: "sdk-myc-nip46-transport",
+ reason: "CLI Myc signer target parsing and NIP-46 relay transport bridge for SDK signing",
+ lifecycle: "retain while CLI owns signer backend wiring",
+ },
+ DirectRrRsDependency {
+ section: "dependencies",
name: "radroots_nostr_accounts",
owner: "cli-account-store",
reason: "CLI account selection, import, local signer status, and account persistence",
@@ -399,6 +789,13 @@ mod tests {
},
DirectRrRsDependency {
section: "dependencies",
+ name: "radroots_protected_store",
+ owner: "cli-account-store",
+ reason: "protected file secret vault selection for local account and Myc session material",
+ lifecycle: "retain while CLI owns account and signer session custody UX",
+ },
+ DirectRrRsDependency {
+ section: "dependencies",
name: "radroots_sp1_host_trade",
owner: "validation-receipts",
reason: "validation receipt SP1 proof inspection and verification",
@@ -418,13 +815,6 @@ mod tests {
reason: "listing draft validation, order economics, order reducer helpers, and validation receipt parsing",
lifecycle: "retain until remaining trade validation and draft behavior migrates",
},
- DirectRrRsDependency {
- section: "dev-dependencies",
- name: "radroots_protected_store",
- owner: "account-tests",
- reason: "unit coverage for protected file secret vault behavior",
- lifecycle: "test-only",
- },
];
const LEGACY_DIRECT_RELAY_CONSUMERS: &[LegacyDirectRelayConsumer] = &[
@@ -473,7 +863,7 @@ mod tests {
end: "#[derive(Debug, Clone)]\nstruct SdkFarmPublishInput",
required_tokens: &[
"prepare_publish(FarmPreparePublishRequest::new",
- "enqueue_publish(request, &signer)",
+ "enqueue_publish(request)",
"session.sdk().sync().push_outbox",
],
},
@@ -518,7 +908,7 @@ mod tests {
required_tokens: &[
"prepare_submit(OrderSubmitPrepareRequest::new",
"OrderSubmitEnqueueRequest::new",
- "enqueue_submit(request, &signer)",
+ "enqueue_submit_with_explicit_signer(request, &signer)",
"push_outbox(",
],
},
@@ -530,7 +920,7 @@ mod tests {
required_tokens: &[
"OrderDecisionEnqueueRequest::new",
"ingest_request_evidence(OrderRequestEvidenceIngestRequest::new",
- "enqueue_decision(request, &signer)",
+ "enqueue_decision_with_explicit_signer(request, &signer)",
"push_outbox(",
],
},
@@ -544,9 +934,9 @@ mod tests {
"prepare_revision_decision(OrderRevisionDecisionPrepareRequest::new",
"prepare_cancellation(OrderCancellationPrepareRequest::new",
"ingest_order_evidence_events(&session, evidence_events)?",
- "enqueue_revision_proposal(request, &signer)",
- "enqueue_revision_decision(request, &signer)",
- "enqueue_cancellation(request, &signer)",
+ "enqueue_revision_proposal_with_explicit_signer(request, &signer)",
+ "enqueue_revision_decision_with_explicit_signer(request, &signer)",
+ "enqueue_cancellation_with_explicit_signer(request, &signer)",
"push_one_sdk_outbox_event(&session, policy)?",
],
},
diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs
@@ -1,10 +1,13 @@
use crate::runtime::RuntimeError;
use crate::runtime::account::AccountRuntimeFailure;
use crate::runtime::account::{SHARED_ACCOUNT_STORE_SOURCE, empty_account_resolution_view};
-use crate::runtime::config::{RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend};
+use crate::runtime::config::{
+ CapabilityBindingTargetKind, RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend,
+};
+use crate::runtime::sdk::MYC_NIP46_SESSION_SECRET_SERVICE;
use crate::view::runtime::{
- IdentityPublicView, LocalSignerStatusView, SignerBindingStatusView, SignerStatusView,
- SignerWriteKindReadinessView,
+ IdentityPublicView, LocalSignerStatusView, MycStatusView, SignerBindingStatusView,
+ SignerStatusView, SignerWriteKindReadinessView,
};
use radroots_events::kinds::{
KIND_FARM, KIND_LISTING, KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST,
@@ -15,11 +18,11 @@ use radroots_nostr_signer::prelude::{
RadrootsNostrLocalSignerAvailability, RadrootsNostrLocalSignerCapability,
RadrootsNostrSignerCapability,
};
-use serde::{Deserialize, Serialize};
+use radroots_sdk::radroots_sdk_myc_nip46_product_permission_strings;
+use url::Url;
const SIGNER_BINDING_PROVIDER_RUNTIME_ID: &str = "myc";
const SIGNER_BINDING_MODEL: &str = "session_authorized_remote_signer";
-const MYC_DEFERRED_REASON: &str = "signer mode `myc` is deferred; use signer mode `local`";
#[derive(Debug, Clone, Copy)]
struct CliWriteKind {
@@ -49,14 +52,6 @@ impl ActorWriteBindingError {
}
}
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct ActorWriteSignerAuthority {
- pub provider_runtime_id: String,
- pub account_identity_id: String,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub provider_session_ref: Option<String>,
-}
-
pub fn resolve_signer_status(config: &RuntimeConfig) -> SignerStatusView {
match config.signer.backend {
SignerBackend::Local => resolve_local_signer_status(config),
@@ -64,20 +59,6 @@ pub fn resolve_signer_status(config: &RuntimeConfig) -> SignerStatusView {
}
}
-pub fn resolve_actor_write_authority(
- config: &RuntimeConfig,
- _actor_role: &str,
- _actor_pubkey: &str,
-) -> Result<Option<ActorWriteSignerAuthority>, ActorWriteBindingError> {
- if !matches!(config.signer.backend, SignerBackend::Myc) {
- return Ok(None);
- }
-
- Err(ActorWriteBindingError::Unconfigured(
- MYC_DEFERRED_REASON.to_owned(),
- ))
-}
-
fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView {
let (account_resolution, resolved_account_id) =
match crate::runtime::account::resolve_account_resolution(config) {
@@ -242,17 +223,40 @@ fn resolve_myc_signer_status(config: &RuntimeConfig) -> SignerStatusView {
Ok(resolution) => crate::runtime::account::account_resolution_view(&resolution),
Err(_) => empty_account_resolution_view(),
};
+ let readiness = myc_binding_readiness(config, None);
SignerStatusView {
mode: config.signer.backend.as_str().to_owned(),
- state: "unconfigured".to_owned(),
- source: "target cli signer mode contract".to_owned(),
+ state: if readiness.ready {
+ "ready"
+ } else {
+ "unconfigured"
+ }
+ .to_owned(),
+ source: readiness.source.clone(),
signer_account_id: None,
account_resolution,
- reason: Some(MYC_DEFERRED_REASON.to_owned()),
- binding: deferred_myc_binding_status(),
- write_kinds: deferred_write_kind_readiness(),
+ reason: readiness.reason.clone(),
+ binding: readiness.binding,
+ write_kinds: myc_write_kind_readiness(readiness.ready, readiness.reason.clone()),
local: None,
- myc: None,
+ myc: Some(MycStatusView {
+ executable: config.myc.executable.display().to_string(),
+ state: if readiness.ready {
+ "ready"
+ } else {
+ "unconfigured"
+ }
+ .to_owned(),
+ source: readiness.source,
+ service_status: None,
+ ready: readiness.ready,
+ reason: readiness.reason,
+ reasons: readiness.reasons,
+ remote_session_count: usize::from(readiness.signer_session_ref.is_some()),
+ local_signer: None,
+ remote_sessions: Vec::new(),
+ custody: None,
+ }),
}
}
@@ -275,23 +279,6 @@ fn disabled_binding_status() -> SignerBindingStatusView {
}
}
-fn deferred_myc_binding_status() -> SignerBindingStatusView {
- SignerBindingStatusView {
- capability_id: SIGNER_REMOTE_NIP46_CAPABILITY.to_owned(),
- provider_runtime_id: SIGNER_BINDING_PROVIDER_RUNTIME_ID.to_owned(),
- binding_model: SIGNER_BINDING_MODEL.to_owned(),
- state: "deferred".to_owned(),
- source: "target cli signer mode contract".to_owned(),
- target_kind: None,
- target: None,
- managed_account_ref: None,
- signer_session_ref: None,
- resolved_session_ref: None,
- matched_session_count: None,
- reason: Some(MYC_DEFERRED_REASON.to_owned()),
- }
-}
-
fn cli_write_kinds() -> [CliWriteKind; 12] {
[
CliWriteKind {
@@ -361,19 +348,6 @@ fn local_write_kind_readiness(
.collect()
}
-fn deferred_write_kind_readiness() -> Vec<SignerWriteKindReadinessView> {
- cli_write_kinds()
- .iter()
- .map(|kind| SignerWriteKindReadinessView {
- command: kind.command.to_owned(),
- event_kind: kind.event_kind,
- permission: "signer_mode_local_required".to_owned(),
- ready: false,
- reason: Some(MYC_DEFERRED_REASON.to_owned()),
- })
- .collect()
-}
-
fn local_availability(value: RadrootsNostrLocalSignerAvailability) -> &'static str {
match value {
RadrootsNostrLocalSignerAvailability::PublicOnly => "public_only",
@@ -381,6 +355,187 @@ fn local_availability(value: RadrootsNostrLocalSignerAvailability) -> &'static s
}
}
+#[derive(Debug, Clone)]
+struct MycBindingReadiness {
+ binding: SignerBindingStatusView,
+ ready: bool,
+ source: String,
+ reason: Option<String>,
+ reasons: Vec<String>,
+ signer_session_ref: Option<String>,
+}
+
+fn myc_binding_readiness(
+ config: &RuntimeConfig,
+ actor_pubkey: Option<&str>,
+) -> MycBindingReadiness {
+ let Some(binding) = config.capability_binding(SIGNER_REMOTE_NIP46_CAPABILITY) else {
+ let reason = "signer.remote_nip46 binding is missing".to_owned();
+ return MycBindingReadiness {
+ binding: missing_myc_binding_status(reason.clone()),
+ ready: false,
+ source: "no explicit capability binding".to_owned(),
+ reason: Some(reason.clone()),
+ reasons: vec![reason],
+ signer_session_ref: None,
+ };
+ };
+
+ let mut reasons = Vec::new();
+ if binding.target_kind != CapabilityBindingTargetKind::ExplicitEndpoint {
+ reasons.push(format!(
+ "signer.remote_nip46 binding target_kind `{}` is not supported for CLI Myc signing; use `explicit_endpoint`",
+ binding.target_kind.as_str()
+ ));
+ }
+ if let Err(reason) = validate_myc_target(binding.target.as_str()) {
+ reasons.push(reason);
+ }
+ if let (Some(managed_account_ref), Some(actor_pubkey)) =
+ (binding.managed_account_ref.as_deref(), actor_pubkey)
+ {
+ if managed_account_ref != actor_pubkey {
+ reasons.push(format!(
+ "signer.remote_nip46 managed_account_ref `{managed_account_ref}` does not match actor pubkey `{actor_pubkey}`"
+ ));
+ }
+ }
+ let signer_session_ref = binding.signer_session_ref.clone();
+ if let Some(session_ref) = signer_session_ref.as_deref() {
+ match crate::runtime::account::load_secret_backend_secret(
+ config,
+ session_ref,
+ MYC_NIP46_SESSION_SECRET_SERVICE,
+ ) {
+ Ok(Some(secret)) if secret.trim().is_empty() => {
+ reasons.push(format!(
+ "signer.remote_nip46 signer_session_ref `{session_ref}` resolved to an empty client secret"
+ ));
+ }
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ reasons.push(format!(
+ "signer.remote_nip46 signer_session_ref `{session_ref}` was not found in the account secret backend"
+ ));
+ }
+ Err(error) => reasons.push(error.to_string()),
+ }
+ } else {
+ reasons.push("signer.remote_nip46 signer_session_ref is missing".to_owned());
+ }
+
+ let ready = reasons.is_empty();
+ let reason = reasons.first().cloned();
+ let source = binding.source.as_str().to_owned();
+ MycBindingReadiness {
+ binding: SignerBindingStatusView {
+ capability_id: binding.capability_id.clone(),
+ provider_runtime_id: binding.provider_runtime_id.clone(),
+ binding_model: binding.binding_model.clone(),
+ state: if ready { "ready" } else { "unconfigured" }.to_owned(),
+ source: source.clone(),
+ target_kind: Some(binding.target_kind.as_str().to_owned()),
+ target: Some(binding.target.clone()),
+ managed_account_ref: binding.managed_account_ref.clone(),
+ signer_session_ref: binding.signer_session_ref.clone(),
+ resolved_session_ref: binding.signer_session_ref.clone().filter(|_| ready),
+ matched_session_count: Some(usize::from(ready)),
+ reason: reason.clone(),
+ },
+ ready,
+ source,
+ reason,
+ reasons,
+ signer_session_ref,
+ }
+}
+
+fn missing_myc_binding_status(reason: String) -> SignerBindingStatusView {
+ SignerBindingStatusView {
+ capability_id: SIGNER_REMOTE_NIP46_CAPABILITY.to_owned(),
+ provider_runtime_id: SIGNER_BINDING_PROVIDER_RUNTIME_ID.to_owned(),
+ binding_model: SIGNER_BINDING_MODEL.to_owned(),
+ state: "unconfigured".to_owned(),
+ source: "no explicit capability binding".to_owned(),
+ target_kind: None,
+ target: None,
+ managed_account_ref: None,
+ signer_session_ref: None,
+ resolved_session_ref: None,
+ matched_session_count: Some(0),
+ reason: Some(reason),
+ }
+}
+
+fn validate_myc_target(value: &str) -> Result<(), String> {
+ let trimmed = value.trim();
+ if trimmed.starts_with("nostrconnect://") {
+ return Err(
+ "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| format!("signer.remote_nip46 target is invalid: {error}"))?;
+ url.query_pairs()
+ .find(|(key, _)| key == "uri")
+ .map(|(_, uri)| uri.into_owned())
+ .ok_or_else(|| {
+ "signer.remote_nip46 discovery target is missing `uri` query parameter".to_owned()
+ })?
+ };
+ match radroots_nostr_connect::prelude::RadrootsNostrConnectUri::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(
+ "signer.remote_nip46 target must resolve to a bunker URI; raw nostrconnect client URIs are signer-side only"
+ .to_owned(),
+ ),
+ }
+}
+
+fn myc_write_kind_readiness(
+ ready: bool,
+ reason: Option<String>,
+) -> Vec<SignerWriteKindReadinessView> {
+ let permissions = radroots_sdk_myc_nip46_product_permission_strings();
+ cli_write_kinds()
+ .iter()
+ .map(|kind| {
+ let event_kind = kind.event_kind.to_string();
+ let permission = permissions
+ .iter()
+ .find(|permission| permission.contains(event_kind.as_str()))
+ .cloned()
+ .unwrap_or_else(|| format!("sign_event:{event_kind}"));
+ let permission_ready = ready
+ && permissions
+ .iter()
+ .any(|permission| permission.contains(event_kind.as_str()));
+ 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(),
+ )
+ })
+ },
+ }
+ })
+ .collect()
+}
+
#[cfg(test)]
mod tests {
use super::{
diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs
@@ -640,20 +640,25 @@ fn watch_only_import_reports_unconfigured_local_signer() {
}
#[test]
-fn myc_signer_status_returns_deferred_signer_error() {
+fn myc_signer_status_reports_missing_binding() {
let sandbox = RadrootsCliSandbox::new();
let missing_myc = sandbox.root().join("bin/missing-myc");
configure_myc_mode(&sandbox, &missing_myc);
let (output, value) = sandbox.json_output(&["--format", "json", "signer", "status", "get"]);
- assert!(!output.status.success());
+ assert!(output.status.success());
assert_eq!(value["operation_id"], "signer.status.get");
- assert_eq!(value["result"], serde_json::Value::Null);
- assert_eq!(value["errors"][0]["code"], "signer_mode_deferred");
- assert_eq!(value["errors"][0]["exit_code"], 7);
- assert_eq!(value["errors"][0]["detail"]["class"], "signer");
- assert_contains(&value["errors"][0]["message"], "signer mode `myc`");
+ assert!(value["errors"].as_array().expect("errors").is_empty());
+ assert_eq!(value["result"]["mode"], "myc");
+ assert_eq!(value["result"]["state"], "unconfigured");
+ assert_eq!(value["result"]["binding"]["state"], "unconfigured");
+ assert_eq!(value["result"]["myc"]["state"], "unconfigured");
+ assert_eq!(value["result"]["myc"]["ready"], false);
+ assert_contains(
+ &value["result"]["reason"],
+ "signer.remote_nip46 binding is missing",
+ );
assert_no_removed_command_reference(&value, &["signer", "status", "get"]);
}
@@ -674,9 +679,11 @@ fn myc_signer_status_does_not_invoke_configured_executable() {
let (output, value) = sandbox.json_output(&["--format", "json", "signer", "status", "get"]);
- assert!(!output.status.success());
+ assert!(output.status.success());
assert_eq!(value["operation_id"], "signer.status.get");
- assert_eq!(value["errors"][0]["code"], "signer_mode_deferred");
+ assert!(value["errors"].as_array().expect("errors").is_empty());
+ assert_eq!(value["result"]["state"], "unconfigured");
+ assert_eq!(value["result"]["myc"]["ready"], false);
assert!(!invoked.exists(), "target CLI must not execute MYC");
}
@@ -1233,7 +1240,7 @@ fn local_farm_publish_reports_sdk_push_failure_without_profile_publish() {
"SDK relay publish did not reach accepted quorum",
);
let detail = &value["errors"][0]["detail"];
- assert_eq!(detail["source"], "SDK farm publish · local key");
+ assert_eq!(detail["source"], "SDK farm publish · configured signer");
assert_eq!(detail["state"], "unavailable");
assert_eq!(detail["profile"]["state"], "not_submitted");
assert_eq!(detail["farm"]["state"], "unavailable");
@@ -1311,7 +1318,7 @@ fn local_farm_publish_does_not_persist_publication_until_sdk_push_publishes() {
assert_eq!(value["errors"][0]["code"], "network_unavailable");
assert_eq!(value["errors"][0]["detail"]["class"], "network");
let detail = &value["errors"][0]["detail"];
- assert_eq!(detail["source"], "SDK farm publish · local key");
+ assert_eq!(detail["source"], "SDK farm publish · configured signer");
assert_eq!(detail["profile"]["state"], "not_submitted");
assert_eq!(detail["farm"]["state"], "unavailable");
assert_eq!(detail["profile"]["event_id"], serde_json::Value::Null);
@@ -1913,7 +1920,7 @@ fn local_seller_publish_commands_attempt_configured_relay() {
);
assert_eq!(
farm_value["errors"][0]["detail"]["source"],
- "SDK farm publish · local key"
+ "SDK farm publish · configured signer"
);
assert_eq!(
farm_value["errors"][0]["detail"]["farm"]["target_relays"][0],
@@ -2399,10 +2406,13 @@ fn myc_listing_publish_does_not_fallback_to_local_account() {
assert!(!output.status.success());
assert_eq!(value["operation_id"], "listing.publish");
assert_eq!(value["result"], serde_json::Value::Null);
- assert_eq!(value["errors"][0]["code"], "signer_mode_deferred");
+ assert_eq!(value["errors"][0]["code"], "signer_unconfigured");
assert_eq!(value["errors"][0]["exit_code"], 7);
assert_eq!(value["errors"][0]["detail"]["class"], "signer");
- assert_contains(&value["errors"][0]["message"], "signer mode `myc`");
+ assert_contains(
+ &value["errors"][0]["message"],
+ "signer.remote_nip46 binding is missing",
+ );
assert!(!invoked.exists(), "target CLI must not execute MYC");
}
diff --git a/tests/support/mod.rs b/tests/support/mod.rs
@@ -15,7 +15,9 @@ use radroots_local_events::{
LocalRecordStatus, PublishOutboxStatus, RelayDeliveryEvidence, SourceRuntime,
canonical_relay_set_fingerprint,
};
+use radroots_protected_store::RadrootsProtectedFileSecretVault;
use radroots_replica_sync::{RadrootsReplicaIngestOutcome, radroots_replica_ingest_event};
+use radroots_secret_vault::RadrootsSecretVault;
use radroots_sql_core::{SqlExecutor, SqliteExecutor};
use serde_json::{Value, json};
use tempfile::TempDir;
@@ -480,6 +482,14 @@ pub fn identity_secret(seed: u8) -> RadrootsIdentity {
RadrootsIdentity::from_secret_key_bytes(&secret).expect("fixture identity")
}
+pub fn store_test_session_secret(sandbox: &RadrootsCliSandbox, slot: &str, secret: &str) {
+ let vault =
+ RadrootsProtectedFileSecretVault::new(sandbox.root().join("secrets/shared/accounts"));
+ vault
+ .store_secret(slot, secret)
+ .expect("store test session secret");
+}
+
pub fn make_listing_publishable(path: &Path, farm_d_tag: &str) {
let raw = fs::read_to_string(path).expect("listing draft");
let mut seller_pubkey_present = false;
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -4,22 +4,33 @@ use std::fs;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
+use std::sync::{
+ Arc, Mutex,
+ atomic::{AtomicBool, Ordering},
+};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
+use nostr::nips::nip44::{self, Version};
+use nostr::{EventBuilder, Keys, Kind, PublicKey, SecretKey, Tag};
use radroots_events::RadrootsNostrEventPtr;
use radroots_events::ids::{
RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId, RadrootsPublicKey,
};
-use radroots_events::kinds::KIND_ORDER_REQUEST;
+use radroots_events::kinds::{KIND_LISTING, KIND_ORDER_REQUEST};
use radroots_events::order::{RadrootsOrderEconomics, RadrootsOrderItem, RadrootsOrderRequest};
use radroots_events_codec::order::order_request_event_build;
+use radroots_identity::RadrootsIdentity;
use radroots_local_events::{
BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, LocalEventRecordInput, LocalEventsStore,
LocalRecordFamily, LocalRecordStatus, PublishOutboxStatus, RelayDeliveryEvidence,
SourceRuntime, canonical_relay_set_fingerprint,
};
use radroots_nostr::prelude::{RadrootsNostrEvent, radroots_nostr_build_event};
+use radroots_nostr_connect::prelude::{
+ RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectRequest,
+ RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse,
+};
use radroots_replica_db::{farm, migrations};
use radroots_replica_db_schema::farm::IFarmFields;
use radroots_replica_sync::radroots_replica_pending_publish_batch;
@@ -32,9 +43,10 @@ use support::{
assert_no_daemon_runtime_reference, assert_no_removed_command_reference, create_listing_draft,
duplicate_orderable_listing_row, identity_public, identity_secret, json_from_stdout,
make_listing_publishable, ndjson_from_stdout, radroots, remove_orderable_listing,
- replace_latest_listing_event_id, seed_orderable_listing, toml_string,
- update_orderable_listing_available_amount, update_orderable_listing_primary_bin_id,
- write_public_identity_profile, write_secret_identity_profile,
+ replace_latest_listing_event_id, seed_orderable_listing, store_test_session_secret,
+ toml_string, update_orderable_listing_available_amount,
+ update_orderable_listing_primary_bin_id, write_public_identity_profile,
+ write_secret_identity_profile,
};
const LISTING_ADDR: &str =
@@ -130,6 +142,345 @@ impl RadrootsdProxyJsonRpcServer {
}
}
+#[derive(Clone, Copy)]
+enum Nip46RelayFinish {
+ SignResponse,
+ ProductPublish,
+}
+
+struct Nip46RelayReport {
+ connection_count: usize,
+ req_count: usize,
+ sign_request_count: usize,
+ published_events: Vec<RadrootsNostrEvent>,
+}
+
+struct Nip46RelayState {
+ connection_count: Mutex<usize>,
+ req_count: Mutex<usize>,
+ sign_request_count: Mutex<usize>,
+ published_events: Mutex<Vec<RadrootsNostrEvent>>,
+ pending_responses: Mutex<Vec<RadrootsNostrEvent>>,
+ done: AtomicBool,
+ finish: Nip46RelayFinish,
+}
+
+struct Nip46RelayServer {
+ endpoint: String,
+ state: Arc<Nip46RelayState>,
+ handle: JoinHandle<()>,
+}
+
+impl Nip46RelayServer {
+ fn new(remote_keys: Keys, user_keys: Keys, finish: Nip46RelayFinish) -> Self {
+ let listener = TcpListener::bind("127.0.0.1:0").expect("bind nip46 relay");
+ listener.set_nonblocking(true).expect("nip46 nonblocking");
+ let endpoint = format!("ws://{}", listener.local_addr().expect("nip46 addr"));
+ let state = Arc::new(Nip46RelayState {
+ connection_count: Mutex::new(0),
+ req_count: Mutex::new(0),
+ sign_request_count: Mutex::new(0),
+ published_events: Mutex::new(Vec::new()),
+ pending_responses: Mutex::new(Vec::new()),
+ done: AtomicBool::new(false),
+ finish,
+ });
+ let thread_state = Arc::clone(&state);
+ let remote_keys = Arc::new(remote_keys);
+ let user_keys = Arc::new(user_keys);
+ let handle = thread::spawn(move || {
+ let deadline = Instant::now() + Duration::from_secs(10);
+ while Instant::now() < deadline && !thread_state.done.load(Ordering::SeqCst) {
+ match listener.accept() {
+ Ok((stream, _)) => {
+ *thread_state
+ .connection_count
+ .lock()
+ .expect("connection count lock") += 1;
+ let connection_state = Arc::clone(&thread_state);
+ let remote_keys = Arc::clone(&remote_keys);
+ let user_keys = Arc::clone(&user_keys);
+ thread::spawn(move || {
+ handle_nip46_relay_connection(
+ stream,
+ (*remote_keys).clone(),
+ (*user_keys).clone(),
+ connection_state,
+ );
+ });
+ }
+ Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
+ thread::sleep(Duration::from_millis(10));
+ }
+ Err(error) => panic!("accept nip46 relay connection: {error}"),
+ }
+ }
+ assert!(
+ thread_state.done.load(Ordering::SeqCst),
+ "timed out waiting for NIP-46 relay proof; connections {}; req {}; sign requests {}; published events {}",
+ *thread_state
+ .connection_count
+ .lock()
+ .expect("connection count lock"),
+ *thread_state.req_count.lock().expect("req count lock"),
+ *thread_state
+ .sign_request_count
+ .lock()
+ .expect("sign count lock"),
+ thread_state
+ .published_events
+ .lock()
+ .expect("published events lock")
+ .len(),
+ );
+ });
+ Self {
+ endpoint,
+ state,
+ handle,
+ }
+ }
+
+ fn endpoint(&self) -> &str {
+ self.endpoint.as_str()
+ }
+
+ fn join(self) -> Nip46RelayReport {
+ self.handle.join().expect("nip46 relay server join");
+ Nip46RelayReport {
+ connection_count: *self
+ .state
+ .connection_count
+ .lock()
+ .expect("connection count lock"),
+ req_count: *self.state.req_count.lock().expect("req count lock"),
+ sign_request_count: *self
+ .state
+ .sign_request_count
+ .lock()
+ .expect("sign count lock"),
+ published_events: self
+ .state
+ .published_events
+ .lock()
+ .expect("published events lock")
+ .clone(),
+ }
+ }
+}
+
+fn handle_nip46_relay_connection(
+ stream: TcpStream,
+ remote_keys: Keys,
+ user_keys: Keys,
+ state: Arc<Nip46RelayState>,
+) {
+ stream
+ .set_nonblocking(false)
+ .expect("nip46 blocking stream");
+ let mut websocket = tungstenite::accept(stream).expect("accept nip46 websocket");
+ let mut subscriptions = Vec::<String>::new();
+ loop {
+ let message = match websocket.read() {
+ Ok(message) => message,
+ Err(_) => return,
+ };
+ if !message.is_text() {
+ continue;
+ }
+ let value: Value =
+ serde_json::from_str(message.to_text().expect("nip46 text")).expect("nip46 json");
+ match value.get(0).and_then(Value::as_str) {
+ Some("REQ") => {
+ *state.req_count.lock().expect("req count lock") += 1;
+ let subscription_id = value
+ .get(1)
+ .and_then(Value::as_str)
+ .expect("nip46 subscription id")
+ .to_owned();
+ subscriptions.push(subscription_id.clone());
+ websocket
+ .send(tungstenite::Message::Text(
+ json!(["EOSE", subscription_id]).to_string().into(),
+ ))
+ .expect("send nip46 eose");
+ send_pending_nip46_responses(&mut websocket, subscription_id.as_str(), &state);
+ }
+ Some("CLOSE") => {}
+ Some("EVENT") => {
+ let event: RadrootsNostrEvent =
+ serde_json::from_value(value.get(1).cloned().expect("nip46 relay event"))
+ .expect("parse nip46 relay event");
+ if event.kind == Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND) {
+ handle_nip46_sign_request(
+ &mut websocket,
+ &subscriptions,
+ event,
+ &remote_keys,
+ &user_keys,
+ &state,
+ );
+ } else {
+ handle_nip46_product_publish(&mut websocket, event, &state);
+ }
+ }
+ _ => {}
+ }
+ }
+}
+
+fn handle_nip46_sign_request(
+ websocket: &mut tungstenite::WebSocket<TcpStream>,
+ subscriptions: &[String],
+ event: RadrootsNostrEvent,
+ remote_keys: &Keys,
+ user_keys: &Keys,
+ state: &Nip46RelayState,
+) {
+ send_relay_ok(websocket, &event);
+ let decrypted = nip44::decrypt(remote_keys.secret_key(), &event.pubkey, &event.content)
+ .expect("decrypt nip46 request");
+ let request: RadrootsNostrConnectRequestMessage =
+ serde_json::from_str(&decrypted).expect("decode nip46 request");
+ let response = match request.request {
+ RadrootsNostrConnectRequest::SignEvent(unsigned_event) => {
+ let signed = unsigned_event
+ .sign_with_keys(user_keys)
+ .expect("sign nip46 request");
+ RadrootsNostrConnectResponse::SignedEvent(signed)
+ }
+ other => RadrootsNostrConnectResponse::Error {
+ result: None,
+ error: format!("unexpected test NIP-46 method `{}`", other.method()),
+ },
+ };
+ let response_event =
+ nip46_response_event(remote_keys, event.pubkey, request.id.as_str(), response);
+ state
+ .pending_responses
+ .lock()
+ .expect("pending response lock")
+ .push(response_event.clone());
+ for subscription_id in subscriptions {
+ send_nip46_response(websocket, subscription_id.as_str(), &response_event);
+ }
+ *state
+ .sign_request_count
+ .lock()
+ .expect("sign request count lock") += 1;
+ if matches!(state.finish, Nip46RelayFinish::SignResponse) {
+ state.done.store(true, Ordering::SeqCst);
+ }
+}
+
+fn send_pending_nip46_responses(
+ websocket: &mut tungstenite::WebSocket<TcpStream>,
+ subscription_id: &str,
+ state: &Nip46RelayState,
+) {
+ let responses = state
+ .pending_responses
+ .lock()
+ .expect("pending response lock")
+ .clone();
+ for response in responses {
+ send_nip46_response(websocket, subscription_id, &response);
+ }
+}
+
+fn send_nip46_response(
+ websocket: &mut tungstenite::WebSocket<TcpStream>,
+ subscription_id: &str,
+ response_event: &RadrootsNostrEvent,
+) {
+ websocket
+ .send(tungstenite::Message::Text(
+ json!(["EVENT", subscription_id, response_event])
+ .to_string()
+ .into(),
+ ))
+ .expect("send nip46 response event");
+}
+
+fn handle_nip46_product_publish(
+ websocket: &mut tungstenite::WebSocket<TcpStream>,
+ event: RadrootsNostrEvent,
+ state: &Nip46RelayState,
+) {
+ send_relay_ok(websocket, &event);
+ state
+ .published_events
+ .lock()
+ .expect("published events lock")
+ .push(event);
+ if matches!(state.finish, Nip46RelayFinish::ProductPublish) {
+ state.done.store(true, Ordering::SeqCst);
+ }
+}
+
+fn nip46_response_event(
+ remote_keys: &Keys,
+ client_public_key: PublicKey,
+ request_id: &str,
+ response: RadrootsNostrConnectResponse,
+) -> RadrootsNostrEvent {
+ let envelope = response
+ .into_envelope(request_id)
+ .expect("nip46 response envelope");
+ let payload = serde_json::to_string(&envelope).expect("nip46 response payload");
+ let ciphertext = nip44::encrypt(
+ remote_keys.secret_key(),
+ &client_public_key,
+ payload,
+ Version::V2,
+ )
+ .expect("nip46 response ciphertext");
+ EventBuilder::new(Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND), ciphertext)
+ .tag(Tag::public_key(client_public_key))
+ .sign_with_keys(remote_keys)
+ .expect("nip46 response event")
+}
+
+fn send_relay_ok(websocket: &mut tungstenite::WebSocket<TcpStream>, event: &RadrootsNostrEvent) {
+ websocket
+ .send(tungstenite::Message::Text(
+ json!(["OK", event.id.to_hex(), true, ""])
+ .to_string()
+ .into(),
+ ))
+ .expect("send relay ok");
+}
+
+fn nostr_keys_from_identity(identity: &RadrootsIdentity) -> Keys {
+ let secret_key_hex = identity.secret_key_hex();
+ Keys::new(SecretKey::from_hex(secret_key_hex.as_str()).expect("secret key"))
+}
+
+fn myc_nip46_config(
+ remote_signer_pubkey: &str,
+ relay_endpoint: &str,
+ managed_account_ref: &str,
+ session_ref: &str,
+) -> String {
+ format!(
+ r#"[signer]
+backend = "myc"
+
+[[capability_binding]]
+capability = "signer.remote_nip46"
+provider = "myc"
+target_kind = "explicit_endpoint"
+target = "bunker://{}?relay={}"
+managed_account_ref = "{}"
+signer_session_ref = "{}"
+"#,
+ toml_string(remote_signer_pubkey),
+ toml_string(relay_endpoint),
+ toml_string(managed_account_ref),
+ toml_string(session_ref),
+ )
+}
+
fn handle_radrootsd_proxy_connection(mut stream: TcpStream, expected_token: &str) -> Value {
let mut bytes = Vec::new();
let mut buffer = [0_u8; 1024];
@@ -1055,7 +1406,7 @@ fn config_get_radrootsd_proxy_with_token_file_reports_ready_transport() {
}
#[test]
-fn config_get_marks_radrootsd_proxy_unavailable_with_myc_signer() {
+fn config_get_marks_radrootsd_proxy_unconfigured_with_incomplete_myc_signer() {
let sandbox = RadrootsCliSandbox::new();
sandbox.write_app_config(
r#"[publish]
@@ -1084,12 +1435,12 @@ signer_session_ref = "session_ready"
assert!(output.status.success());
assert_eq!(value["operation_id"], "config.get");
assert_eq!(value["result"]["publish"]["transport"], "radrootsd_proxy");
- assert_eq!(value["result"]["publish"]["state"], "unavailable");
+ assert_eq!(value["result"]["publish"]["state"], "unconfigured");
assert_eq!(value["result"]["publish"]["executable"], false);
- assert_contains(&value["result"]["publish"]["reason"], "signer mode `local`");
+ assert_contains(&value["result"]["publish"]["reason"], "signer.remote_nip46");
assert_eq!(
value["result"]["publish"]["provider"]["state"],
- "unavailable"
+ "unconfigured"
);
assert_eq!(value["result"]["actions"][0], "radroots signer status get");
}
@@ -1183,7 +1534,7 @@ fn config_get_marks_relay_publish_ready_with_secret_backed_local_account() {
}
#[test]
-fn config_get_marks_relay_publish_unavailable_with_deferred_signer_mode() {
+fn config_get_marks_relay_publish_unconfigured_with_missing_myc_binding() {
let sandbox = RadrootsCliSandbox::new();
sandbox.json_success(&["--format", "json", "account", "create"]);
sandbox.write_app_config("[signer]\nbackend = \"myc\"\n");
@@ -1203,12 +1554,15 @@ fn config_get_marks_relay_publish_unavailable_with_deferred_signer_mode() {
);
assert_eq!(value["result"]["publish"]["relay"]["ready"], true);
assert_eq!(value["result"]["publish"]["signed_write_required"], true);
- assert_eq!(value["result"]["publish"]["state"], "unavailable");
+ assert_eq!(value["result"]["publish"]["state"], "unconfigured");
assert_eq!(value["result"]["publish"]["executable"], false);
- assert_contains(&value["result"]["publish"]["reason"], "signer mode `local`");
+ assert_contains(
+ &value["result"]["publish"]["reason"],
+ "signer.remote_nip46 binding is missing",
+ );
assert_eq!(
value["result"]["publish"]["provider"]["state"],
- "unavailable"
+ "unconfigured"
);
}
@@ -1246,7 +1600,7 @@ fn config_get_marks_relay_publish_unconfigured_with_watch_only_account() {
}
#[test]
-fn health_surfaces_publish_state_under_deferred_signer_mode() {
+fn health_surfaces_publish_state_under_missing_myc_binding() {
let sandbox = RadrootsCliSandbox::new();
let missing_myc = sandbox.root().join("bin/missing-myc");
let token_file = radrootsd_proxy_token_file(&sandbox);
@@ -1264,16 +1618,19 @@ fn health_surfaces_publish_state_under_deferred_signer_mode() {
assert_eq!(value["result"]["publish"]["executable"], false);
assert_eq!(
value["result"]["publish"]["provider"]["state"],
- "unavailable"
+ "unconfigured"
+ );
+ assert_contains(
+ &value["result"]["publish"]["reason"],
+ "signer.remote_nip46 binding is missing",
);
- assert_contains(&value["result"]["publish"]["reason"], "signer mode `local`");
assert_eq!(value["result"]["store"]["state"], "ready");
assert_eq!(
value["result"]["store"]["source"],
"SDK canonical event store and outbox"
);
assert_eq!(value["result"]["store"]["canonical_store"], "sdk");
- assert_eq!(value["result"]["signer"]["state"], "unavailable");
+ assert_eq!(value["result"]["signer"]["state"], "unconfigured");
assert_eq!(value["result"]["actions"][0], "radroots account create");
assert_eq!(value["result"]["actions"][1], "radroots signer status get");
assert_eq!(
@@ -1546,7 +1903,10 @@ fn radrootsd_proxy_listing_publish_update_and_archive_dry_run_without_direct_rel
assert!(output.status.success());
assert_eq!(value["operation_id"], format!("listing.{operation}"));
assert_eq!(value["result"]["state"], "dry_run");
- assert_eq!(value["result"]["source"], "SDK listing publish · local key");
+ assert_eq!(
+ value["result"]["source"],
+ "SDK listing publish · configured signer"
+ );
assert_eq!(value["result"]["dry_run"], true);
assert_eq!(
value["result"]["target_relays"]
@@ -1628,7 +1988,10 @@ token_file = "{}"
);
assert_eq!(value["operation_id"], "listing.publish");
assert_eq!(value["result"]["state"], "published");
- assert_eq!(value["result"]["source"], "SDK listing publish · local key");
+ assert_eq!(
+ value["result"]["source"],
+ "SDK listing publish · configured signer"
+ );
assert_eq!(value["result"]["dry_run"], false);
assert_eq!(
request["params"]["event"]["id"],
@@ -1661,6 +2024,242 @@ token_file = "{}"
}
#[test]
+fn direct_relay_listing_publish_uses_myc_nip46_sdk_signer() {
+ let sandbox = RadrootsCliSandbox::new();
+ let user_identity = identity_secret(90);
+ let client_identity = identity_secret(91);
+ let remote_identity = identity_secret(92);
+ let user_public = user_identity.to_public();
+ let user_keys = nostr_keys_from_identity(&user_identity);
+ let remote_keys = nostr_keys_from_identity(&remote_identity);
+ let remote_pubkey = remote_keys.public_key().to_hex();
+ let relay = Nip46RelayServer::new(
+ remote_keys.clone(),
+ user_keys,
+ Nip46RelayFinish::ProductPublish,
+ );
+ let relay_endpoint = relay.endpoint().to_owned();
+ let public_identity_file =
+ write_public_identity_profile(&sandbox, "myc-direct-user", &user_public);
+ let imported = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--approval-token",
+ "approve",
+ "account",
+ "import",
+ "--default",
+ public_identity_file.to_string_lossy().as_ref(),
+ ]);
+ let account_id = imported["result"]["account"]["id"]
+ .as_str()
+ .expect("account id");
+ store_test_session_secret(
+ &sandbox,
+ "session_ready",
+ client_identity.secret_key_hex().as_str(),
+ );
+ sandbox.write_app_config(
+ myc_nip46_config(
+ remote_pubkey.as_str(),
+ relay_endpoint.as_str(),
+ account_id,
+ "session_ready",
+ )
+ .as_str(),
+ );
+ let signer = sandbox.json_success(&["--format", "json", "signer", "status", "get"]);
+ assert_eq!(signer["result"]["state"], "ready");
+ assert_eq!(signer["result"]["binding"]["state"], "ready");
+ let farm = sandbox.json_success(&[
+ "--format",
+ "json",
+ "farm",
+ "create",
+ "--name",
+ "Myc Relay Farm",
+ "--location",
+ "farmstand",
+ "--country",
+ "US",
+ "--delivery-method",
+ "pickup",
+ ]);
+ let listing_file = create_listing_draft(&sandbox, "myc-direct-relay-live");
+ make_listing_publishable(
+ &listing_file,
+ farm["result"]["config"]["farm_d_tag"]
+ .as_str()
+ .expect("farm d tag"),
+ );
+
+ let output = sandbox
+ .command()
+ .args([
+ "--format",
+ "json",
+ "--approval-token",
+ "approve",
+ "--relay",
+ relay_endpoint.as_str(),
+ "listing",
+ "publish",
+ listing_file.to_string_lossy().as_ref(),
+ ])
+ .output()
+ .expect("run myc direct listing publish");
+ let value: Value = serde_json::from_slice(&output.stdout).expect("json output");
+
+ assert!(
+ output.status.success(),
+ "stderr `{}` stdout `{}`",
+ String::from_utf8_lossy(&output.stderr),
+ String::from_utf8_lossy(&output.stdout)
+ );
+ let report = relay.join();
+ assert_eq!(value["operation_id"], "listing.publish");
+ assert_eq!(value["result"]["state"], "published");
+ assert_eq!(
+ value["result"]["source"],
+ "SDK listing publish · configured signer"
+ );
+ assert_eq!(value["result"]["target_relays"][0], relay_endpoint);
+ assert!(report.connection_count >= 1);
+ assert!(report.req_count >= 1);
+ assert_eq!(report.sign_request_count, 1);
+ assert_eq!(report.published_events.len(), 1);
+ let published = &report.published_events[0];
+ assert_eq!(published.kind, Kind::Custom(KIND_LISTING as u16));
+ assert_eq!(published.pubkey.to_hex(), user_public.public_key_hex);
+ assert_eq!(
+ published.id.to_hex(),
+ value["result"]["event_id"].as_str().expect("event id")
+ );
+}
+
+#[test]
+fn radrootsd_proxy_listing_publish_uses_myc_nip46_sdk_signer() {
+ let sandbox = RadrootsCliSandbox::new();
+ let user_identity = identity_secret(93);
+ let client_identity = identity_secret(94);
+ let remote_identity = identity_secret(95);
+ let user_public = user_identity.to_public();
+ let user_keys = nostr_keys_from_identity(&user_identity);
+ let remote_keys = nostr_keys_from_identity(&remote_identity);
+ let remote_pubkey = remote_keys.public_key().to_hex();
+ let relay = Nip46RelayServer::new(
+ remote_keys.clone(),
+ user_keys,
+ Nip46RelayFinish::SignResponse,
+ );
+ let proxy = RadrootsdProxyJsonRpcServer::once("proxy_test_token");
+ let token_file = radrootsd_proxy_token_file(&sandbox);
+ let public_identity_file =
+ write_public_identity_profile(&sandbox, "myc-proxy-user", &user_public);
+ let imported = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--approval-token",
+ "approve",
+ "account",
+ "import",
+ "--default",
+ public_identity_file.to_string_lossy().as_ref(),
+ ]);
+ let account_id = imported["result"]["account"]["id"]
+ .as_str()
+ .expect("account id");
+ store_test_session_secret(
+ &sandbox,
+ "session_ready",
+ client_identity.secret_key_hex().as_str(),
+ );
+ let config = format!(
+ r#"[publish]
+transport = "radrootsd_proxy"
+
+[publish.radrootsd_proxy]
+url = "{}"
+token_file = "{}"
+
+{}"#,
+ toml_string(proxy.endpoint()),
+ toml_string(token_file.display().to_string().as_str()),
+ myc_nip46_config(
+ remote_pubkey.as_str(),
+ relay.endpoint(),
+ account_id,
+ "session_ready",
+ ),
+ );
+ sandbox.write_app_config(config.as_str());
+ let farm = sandbox.json_success(&[
+ "--format",
+ "json",
+ "farm",
+ "create",
+ "--name",
+ "Myc Proxy Farm",
+ "--location",
+ "farmstand",
+ "--country",
+ "US",
+ "--delivery-method",
+ "pickup",
+ ]);
+ let listing_file = create_listing_draft(&sandbox, "myc-radrootsd-proxy-live");
+ make_listing_publishable(
+ &listing_file,
+ farm["result"]["config"]["farm_d_tag"]
+ .as_str()
+ .expect("farm d tag"),
+ );
+
+ let output = sandbox
+ .command()
+ .args([
+ "--format",
+ "json",
+ "--approval-token",
+ "approve",
+ "listing",
+ "publish",
+ listing_file.to_string_lossy().as_ref(),
+ ])
+ .output()
+ .expect("run myc proxy listing publish");
+ let value: Value = serde_json::from_slice(&output.stdout).expect("json output");
+
+ assert!(
+ output.status.success(),
+ "stderr `{}` stdout `{}`",
+ String::from_utf8_lossy(&output.stderr),
+ String::from_utf8_lossy(&output.stdout)
+ );
+ let report = relay.join();
+ let request = proxy.join();
+ assert_eq!(value["operation_id"], "listing.publish");
+ assert_eq!(value["result"]["state"], "published");
+ assert_eq!(
+ value["result"]["source"],
+ "SDK listing publish · configured signer"
+ );
+ assert!(report.connection_count >= 1);
+ assert!(report.req_count >= 1);
+ assert_eq!(report.sign_request_count, 1);
+ assert_eq!(report.published_events.len(), 0);
+ assert_eq!(request["params"]["event"]["kind"], KIND_LISTING);
+ assert_eq!(
+ request["params"]["event"]["pubkey"],
+ user_public.public_key_hex
+ );
+ assert_eq!(
+ request["params"]["event"]["id"],
+ value["result"]["event_id"]
+ );
+}
+
+#[test]
fn listing_update_publish_attempts_direct_relay_with_approval() {
let sandbox = RadrootsCliSandbox::new();
sandbox.json_success(&["--format", "json", "account", "create"]);
@@ -2581,7 +3180,7 @@ fn offline_listing_publish_enqueues_sdk_outbox_without_direct_relay_push() {
assert_eq!(publish["result"]["state"], "queued");
assert_eq!(
publish["result"]["source"],
- "SDK listing publish · local key"
+ "SDK listing publish · configured signer"
);
assert_eq!(publish["result"]["target_relays"][0], relay);
assert_eq!(publish["result"]["actions"][0], "radroots sync push");
@@ -5022,7 +5621,7 @@ fn farm_publish_uses_sdk_outbox_without_legacy_signed_event_records() {
assert_eq!(publish["result"], serde_json::Value::Null);
assert_eq!(publish["errors"][0]["code"], "network_unavailable");
let detail = &publish["errors"][0]["detail"];
- assert_eq!(detail["source"], "SDK farm publish · local key");
+ assert_eq!(detail["source"], "SDK farm publish · configured signer");
assert_eq!(detail["state"], "unavailable");
assert_eq!(detail["profile"]["state"], "not_submitted");
assert_eq!(detail["profile"]["event_id"], serde_json::Value::Null);
@@ -5089,7 +5688,7 @@ fn listing_publish_failure_uses_sdk_outbox_without_legacy_local_event_record() {
assert_eq!(publish["errors"][0]["code"], "network_unavailable");
assert_eq!(
publish["errors"][0]["detail"]["source"],
- "SDK listing publish · local key"
+ "SDK listing publish · configured signer"
);
assert_eq!(publish["errors"][0]["detail"]["state"], "unavailable");
assert_eq!(