commit 1bf01554c5ecd3778dbe4486d898d4443268961f
parent 3f7964c064e12aea3eb736547822abcfbdb66d0f
Author: triesap <tyson@radroots.org>
Date: Thu, 7 May 2026 04:44:59 +0000
cli: wire radrootsd listing transport
- add the sdk bridge client dependency for listing publish
- submit daemon listing writes through bridge.listing.publish
- return daemon job and signer-session truth in listing output
- cover daemon publish routing without relay configuration
Diffstat:
5 files changed, 742 insertions(+), 41 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -197,6 +197,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4"
[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -336,6 +342,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
+name = "cfg_aliases"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
+[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -833,9 +845,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
+ "wasm-bindgen",
]
[[package]]
@@ -941,12 +955,94 @@ dependencies = [
]
[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
+name = "hyper"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+ "webpki-roots 1.0.7",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1114,6 +1210,12 @@ dependencies = [
]
[[package]]
+name = "ipnet"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+
+[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1251,6 +1353,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
[[package]]
+name = "lru-slab"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
+[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1662,6 +1770,61 @@ dependencies = [
]
[[package]]
+name = "quinn"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
+dependencies = [
+ "bytes",
+ "getrandom 0.3.4",
+ "lru-slab",
+ "rand 0.9.4",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror 2.0.18",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1705,6 +1868,7 @@ dependencies = [
"radroots_replica_db_schema",
"radroots_replica_sync",
"radroots_runtime_paths",
+ "radroots_sdk",
"radroots_secret_vault",
"radroots_sql_core",
"radroots_trade",
@@ -1919,6 +2083,19 @@ dependencies = [
]
[[package]]
+name = "radroots_sdk"
+version = "0.1.0-alpha.2"
+dependencies = [
+ "radroots_events",
+ "radroots_events_codec",
+ "radroots_identity",
+ "radroots_trade",
+ "reqwest",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
name = "radroots_secret_vault"
version = "0.1.0-alpha.2"
dependencies = [
@@ -2046,6 +2223,44 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "webpki-roots 1.0.7",
+]
+
+[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2128,6 +2343,12 @@ dependencies = [
]
[[package]]
+name = "rustc-hash"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
+
+[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2160,6 +2381,7 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
+ "web-time",
"zeroize",
]
@@ -2181,6 +2403,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
name = "salsa20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2316,6 +2544,18 @@ dependencies = [
]
[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2432,6 +2672,15 @@ dependencies = [
]
[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2702,6 +2951,51 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2763,6 +3057,12 @@ dependencies = [
]
[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
name = "ts-rs"
version = "11.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2950,6 +3250,15 @@ dependencies = [
]
[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3074,6 +3383,16 @@ dependencies = [
]
[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
@@ -37,6 +37,7 @@ 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" }
radroots_runtime_paths = { path = "../lib/crates/runtime_paths" }
+radroots_sdk = { path = "../lib/crates/sdk", features = ["radrootsd-client"] }
radroots_secret_vault = { path = "../lib/crates/secret_vault", features = ["std", "os-keyring"] }
radroots_sql_core = { path = "../lib/crates/sql_core", features = ["native"] }
radroots_trade = { path = "../lib/crates/trade" }
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -2635,6 +2635,10 @@ pub struct ListingMutationJobView {
pub signer_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub signer_session_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub relay_count: Option<usize>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub acknowledged_relay_count: Option<usize>,
}
#[derive(Debug, Clone, Serialize)]
diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs
@@ -21,6 +21,12 @@ use radroots_events_codec::d_tag::is_d_tag_base64url;
use radroots_events_codec::listing::encode::to_wire_parts_with_kind;
use radroots_events_codec::wire::WireEventParts;
use radroots_replica_db::ReplicaSql;
+use radroots_sdk::{
+ RadrootsSdkClient, RadrootsSdkConfig, RadrootsdAuth, SdkEnvironment, SdkPublishError,
+ SdkPublishReceipt, SdkRadrootsdListingPublishOptions, SdkRadrootsdPublishReceipt,
+ SdkRadrootsdSignerSessionRef, SdkTransportMode, SdkTransportReceipt,
+ SignerConfig as SdkSignerConfig,
+};
use radroots_sql_core::SqliteExecutor;
use radroots_trade::listing::publish::validate_listing_for_seller;
use radroots_trade::listing::validation::validate_listing_event;
@@ -28,12 +34,15 @@ use serde::{Deserialize, Serialize};
use crate::domain::runtime::{
FindPriceView, FindQuantityView, FindResultProvenanceView, ListingGetView, ListingListView,
- ListingMutationEventView, ListingMutationView, ListingNewView, ListingSummaryView,
- ListingValidateView, ListingValidationIssueView, RelayFailureView, SyncFreshnessView,
+ ListingMutationEventView, ListingMutationJobView, ListingMutationView, ListingNewView,
+ ListingSummaryView, ListingValidateView, ListingValidationIssueView, RelayFailureView,
+ SyncFreshnessView,
};
use crate::runtime::RuntimeError;
use crate::runtime::accounts;
-use crate::runtime::config::{PublishMode, RuntimeConfig, SignerBackend};
+use crate::runtime::config::{
+ PublishMode, RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend,
+};
use crate::runtime::direct_relay::{
DirectRelayFailure, DirectRelayPublishError, DirectRelayPublishReceipt,
publish_parts_with_identity,
@@ -52,8 +61,7 @@ const RELAY_LISTING_WRITE_SOURCE: &str = "direct Nostr relay publish · local ke
const RADROOTSD_LISTING_WRITE_SOURCE: &str = "radrootsd publish transport · signer session";
const DIRECT_RELAY_UNAVAILABLE_REASON: &str =
"direct Nostr relay publishing is not implemented for listing update";
-const RADROOTSD_LISTING_UNAVAILABLE_REASON: &str =
- "radrootsd listing publish transport is not implemented";
+const RADROOTSD_BRIDGE_LISTING_PUBLISH_METHOD: &str = "bridge.listing.publish";
const LISTING_DRAFTS_DIR: &str = "listings/drafts";
static D_TAG_COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -927,14 +935,14 @@ fn mutate(
listing_addr,
event_draft,
),
- PublishMode::Radrootsd => Ok(radrootsd_unavailable_view(
+ PublishMode::Radrootsd => mutate_via_radrootsd(
config,
args,
operation,
&canonical,
listing_addr,
- event_draft.event,
- )),
+ event_draft,
+ ),
}
}
@@ -1022,6 +1030,216 @@ fn mutate_via_direct_relay(
))
}
+fn mutate_via_radrootsd(
+ config: &RuntimeConfig,
+ args: &ListingMutationArgs,
+ operation: ListingMutationOperation,
+ canonical: &CanonicalListingDraft,
+ listing_addr: String,
+ event_draft: ListingMutationEventDraft,
+) -> Result<ListingMutationView, RuntimeError> {
+ let Some(signer_session_id) = resolve_radrootsd_signer_session_id(config, args) else {
+ return Ok(radrootsd_preflight_view(
+ config,
+ args,
+ operation,
+ canonical,
+ listing_addr,
+ event_draft.event,
+ "unconfigured",
+ "radrootsd listing publish requires `signer_session_id` input or a signer.remote_nip46 capability binding with signer_session_ref",
+ ));
+ };
+ if config.rpc.bridge_bearer_token.is_none() {
+ return Ok(radrootsd_preflight_view(
+ config,
+ args,
+ operation,
+ canonical,
+ listing_addr,
+ event_draft.event,
+ "unconfigured",
+ "radrootsd bridge bearer token is required for listing publish; set RADROOTS_RPC_BEARER_TOKEN",
+ ));
+ }
+
+ let receipt = publish_listing_via_radrootsd(
+ config,
+ &canonical.listing,
+ signer_session_id.as_str(),
+ args.idempotency_key.as_deref(),
+ )?;
+
+ radrootsd_mutation_view(
+ config,
+ args,
+ operation,
+ canonical,
+ listing_addr,
+ event_draft.event,
+ signer_session_id,
+ receipt,
+ )
+}
+
+fn publish_listing_via_radrootsd(
+ config: &RuntimeConfig,
+ listing: &RadrootsListing,
+ signer_session_id: &str,
+ idempotency_key: Option<&str>,
+) -> Result<SdkPublishReceipt, RuntimeError> {
+ let mut sdk_config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom);
+ sdk_config.transport = SdkTransportMode::Radrootsd;
+ sdk_config.signer = SdkSignerConfig::Nip46;
+ sdk_config.radrootsd.endpoint = Some(config.rpc.url.clone());
+ sdk_config.radrootsd.auth = config
+ .rpc
+ .bridge_bearer_token
+ .clone()
+ .map(RadrootsdAuth::BearerToken)
+ .unwrap_or(RadrootsdAuth::None);
+
+ let client = RadrootsSdkClient::from_config(sdk_config).map_err(|error| {
+ RuntimeError::Config(format!("configure radrootsd listing publish: {error}"))
+ })?;
+ let signer_session = SdkRadrootsdSignerSessionRef::from_session_id(signer_session_id);
+ let mut options = SdkRadrootsdListingPublishOptions::from_signer_session_ref(&signer_session);
+ if let Some(idempotency_key) = idempotency_key.filter(|value| !value.trim().is_empty()) {
+ options = options.with_idempotency_key(idempotency_key.to_owned());
+ }
+ let runtime = tokio::runtime::Builder::new_multi_thread()
+ .enable_all()
+ .build()
+ .map_err(|error| {
+ RuntimeError::Network(format!("build radrootsd listing publish runtime: {error}"))
+ })?;
+
+ runtime
+ .block_on(
+ client
+ .listing()
+ .publish_listing_via_radrootsd_with_options(listing, &options),
+ )
+ .map_err(map_sdk_listing_publish_error)
+}
+
+fn map_sdk_listing_publish_error(error: SdkPublishError) -> RuntimeError {
+ let message = format!("radrootsd listing publish failed: {error}");
+ match error {
+ SdkPublishError::Config(_)
+ | SdkPublishError::Encode(_)
+ | SdkPublishError::UnsupportedTransport { .. }
+ | SdkPublishError::UnsupportedSignerMode { .. } => RuntimeError::Config(message),
+ SdkPublishError::Relay(_)
+ | SdkPublishError::RelayNotAcknowledged { .. }
+ | SdkPublishError::Radrootsd(_) => RuntimeError::Network(message),
+ }
+}
+
+fn resolve_radrootsd_signer_session_id(
+ config: &RuntimeConfig,
+ args: &ListingMutationArgs,
+) -> Option<String> {
+ args.signer_session_id
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(str::to_owned)
+ .or_else(|| {
+ config
+ .capability_binding(SIGNER_REMOTE_NIP46_CAPABILITY)
+ .and_then(|binding| binding.signer_session_ref.as_deref())
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(str::to_owned)
+ })
+}
+
+fn radrootsd_mutation_view(
+ config: &RuntimeConfig,
+ args: &ListingMutationArgs,
+ operation: ListingMutationOperation,
+ canonical: &CanonicalListingDraft,
+ listing_addr: String,
+ mut event: ListingMutationEventView,
+ requested_session_id: String,
+ receipt: SdkPublishReceipt,
+) -> Result<ListingMutationView, RuntimeError> {
+ let SdkPublishReceipt {
+ event_kind,
+ event_id,
+ transport_receipt,
+ ..
+ } = receipt;
+ let SdkTransportReceipt::Radrootsd(radrootsd) = transport_receipt else {
+ return Err(RuntimeError::Config(
+ "radrootsd listing publish returned a non-radrootsd transport receipt".to_owned(),
+ ));
+ };
+ if let Some(event_id) = event_id.as_ref() {
+ event.event_id = Some(event_id.clone());
+ }
+ let event_addr = radrootsd
+ .event_addr
+ .clone()
+ .unwrap_or_else(|| listing_addr.clone());
+ let job_status = radrootsd.status.clone();
+ let state = match operation {
+ ListingMutationOperation::Archive => "archived",
+ ListingMutationOperation::Publish | ListingMutationOperation::Update => "published",
+ }
+ .to_owned();
+ let job = radrootsd_job_view(args, &requested_session_id, &radrootsd, job_status.clone());
+
+ Ok(ListingMutationView {
+ state,
+ operation: operation.as_str().to_owned(),
+ source: listing_write_source(config).to_owned(),
+ file: args.file.display().to_string(),
+ listing_id: canonical.listing_id.clone(),
+ listing_addr: listing_addr.clone(),
+ seller_pubkey: canonical.seller_pubkey.clone(),
+ event_kind: event_kind.unwrap_or(KIND_LISTING),
+ dry_run: false,
+ deduplicated: radrootsd.deduplicated,
+ target_relays: Vec::new(),
+ connected_relays: Vec::new(),
+ acknowledged_relays: Vec::new(),
+ failed_relays: Vec::new(),
+ job_id: radrootsd.job_id.clone(),
+ job_status,
+ signer_mode: radrootsd.signer_mode.clone(),
+ event_id,
+ event_addr: Some(event_addr),
+ idempotency_key: args.idempotency_key.clone(),
+ signer_session_id: radrootsd.signer_session_id.clone(),
+ requested_signer_session_id: Some(requested_session_id),
+ reason: None,
+ job: Some(job),
+ event: args.print_event.then_some(event),
+ actions: Vec::new(),
+ })
+}
+
+fn radrootsd_job_view(
+ args: &ListingMutationArgs,
+ requested_session_id: &str,
+ receipt: &SdkRadrootsdPublishReceipt,
+ state: Option<String>,
+) -> ListingMutationJobView {
+ ListingMutationJobView {
+ rpc_method: RADROOTSD_BRIDGE_LISTING_PUBLISH_METHOD.to_owned(),
+ state: state.unwrap_or_else(|| "accepted".to_owned()),
+ job_id: receipt.job_id.clone(),
+ idempotency_key: args.idempotency_key.clone(),
+ requested_signer_session_id: Some(requested_session_id.to_owned()),
+ signer_mode: receipt.signer_mode.clone(),
+ signer_session_id: receipt.signer_session_id.clone(),
+ relay_count: receipt.relay_count,
+ acknowledged_relay_count: receipt.acknowledged_relay_count,
+ }
+}
+
fn listing_write_source(config: &RuntimeConfig) -> &'static str {
match config.publish.mode {
PublishMode::NostrRelay => RELAY_LISTING_WRITE_SOURCE,
@@ -1518,16 +1736,18 @@ fn direct_relay_unavailable_view(
}
}
-fn radrootsd_unavailable_view(
+fn radrootsd_preflight_view(
config: &RuntimeConfig,
args: &ListingMutationArgs,
operation: ListingMutationOperation,
canonical: &CanonicalListingDraft,
listing_addr: String,
event_preview: ListingMutationEventView,
+ state: &str,
+ reason: impl Into<String>,
) -> ListingMutationView {
ListingMutationView {
- state: "unavailable".to_owned(),
+ state: state.to_owned(),
operation: operation.as_str().to_owned(),
source: listing_write_source(config).to_owned(),
file: args.file.display().to_string(),
@@ -1543,13 +1763,13 @@ fn radrootsd_unavailable_view(
failed_relays: Vec::new(),
job_id: None,
job_status: None,
- signer_mode: Some(config.signer.backend.as_str().to_owned()),
+ signer_mode: Some("nip46".to_owned()),
event_id: None,
event_addr: Some(listing_addr),
idempotency_key: args.idempotency_key.clone(),
signer_session_id: None,
requested_signer_session_id: args.signer_session_id.clone(),
- reason: Some(RADROOTSD_LISTING_UNAVAILABLE_REASON.to_owned()),
+ reason: Some(reason.into()),
job: None,
event: args.print_event.then_some(event_preview),
actions: Vec::new(),
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -1,9 +1,15 @@
mod support;
use std::fs;
+use std::io::{Read, Write};
+use std::net::{TcpListener, TcpStream};
use std::path::Path;
+use std::sync::mpsc::{self, Receiver};
+use std::thread::{self, JoinHandle};
+use std::time::Duration;
use serde_json::Value;
+use serde_json::json;
use support::{
RadrootsCliSandbox, assert_contains, assert_no_daemon_runtime_reference,
@@ -16,6 +22,114 @@ use support::{
const LISTING_ADDR: &str =
"30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg";
+struct JsonRpcRequest {
+ headers: String,
+ body: Value,
+}
+
+struct OneShotJsonRpcServer {
+ endpoint: String,
+ requests: Receiver<JsonRpcRequest>,
+ handle: JoinHandle<()>,
+}
+
+impl OneShotJsonRpcServer {
+ fn listing_publish() -> Self {
+ let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake radrootsd");
+ let endpoint = format!(
+ "http://{}/jsonrpc",
+ listener.local_addr().expect("fake radrootsd addr")
+ );
+ let (tx, requests) = mpsc::channel();
+ let handle = thread::spawn(move || {
+ let (mut stream, _) = listener.accept().expect("accept fake radrootsd request");
+ let request = read_jsonrpc_request(&mut stream);
+ tx.send(request).expect("send fake radrootsd request");
+ let response = json!({
+ "jsonrpc": "2.0",
+ "id": "radroots-sdk-listing-publish",
+ "result": {
+ "deduplicated": false,
+ "job": {
+ "job_id": "job_listing_publish_test",
+ "command": "bridge.listing.publish",
+ "status": "published",
+ "terminal": true,
+ "recovered_after_restart": false,
+ "signer_mode": "nip46",
+ "signer_session_id": "session_test",
+ "event_kind": 30402,
+ "event_id": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+ "event_addr": "30402:daemon_test:radrootsd-router",
+ "relay_count": 2,
+ "acknowledged_relay_count": 1
+ }
+ }
+ })
+ .to_string();
+ write!(
+ stream,
+ "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
+ response.len(),
+ response
+ )
+ .expect("write fake radrootsd response");
+ });
+ Self {
+ endpoint,
+ requests,
+ handle,
+ }
+ }
+
+ fn take_request(self) -> JsonRpcRequest {
+ let request = self
+ .requests
+ .recv_timeout(Duration::from_secs(5))
+ .expect("fake radrootsd request");
+ self.handle.join().expect("fake radrootsd join");
+ request
+ }
+}
+
+fn read_jsonrpc_request(stream: &mut TcpStream) -> JsonRpcRequest {
+ let mut bytes = Vec::new();
+ let mut buffer = [0_u8; 1024];
+ loop {
+ let count = stream.read(&mut buffer).expect("read fake radrootsd");
+ assert!(count > 0, "fake radrootsd request ended before headers");
+ bytes.extend_from_slice(&buffer[..count]);
+ if let Some(header_end) = find_header_end(&bytes) {
+ let headers = String::from_utf8_lossy(&bytes[..header_end]).to_string();
+ let content_length = content_length(&headers);
+ let body_start = header_end + 4;
+ while bytes.len() < body_start + content_length {
+ let count = stream.read(&mut buffer).expect("read fake radrootsd body");
+ assert!(count > 0, "fake radrootsd request ended before body");
+ bytes.extend_from_slice(&buffer[..count]);
+ }
+ let body = serde_json::from_slice(&bytes[body_start..body_start + content_length])
+ .expect("fake radrootsd json body");
+ return JsonRpcRequest { headers, body };
+ }
+ }
+}
+
+fn find_header_end(bytes: &[u8]) -> Option<usize> {
+ bytes.windows(4).position(|window| window == b"\r\n\r\n")
+}
+
+fn content_length(headers: &str) -> usize {
+ headers
+ .lines()
+ .find_map(|line| {
+ let (name, value) = line.split_once(':')?;
+ name.eq_ignore_ascii_case("content-length")
+ .then(|| value.trim().parse::<usize>().expect("content length"))
+ })
+ .expect("content-length header")
+}
+
#[test]
fn root_help_exposes_only_target_namespaces() {
let output = radroots().arg("--help").output().expect("run root help");
@@ -329,31 +443,74 @@ fn radrootsd_listing_publish_reaches_listing_router_without_relay_config() {
.as_str()
.expect("farm d tag"),
);
+ sandbox.write_app_config(
+ r#"[[capability_binding]]
+capability = "signer.remote_nip46"
+provider = "myc"
+target_kind = "explicit_endpoint"
+target = "http://myc.invalid"
+signer_session_ref = "session_test"
+"#,
+ );
+ let server = OneShotJsonRpcServer::listing_publish();
- let (output, value) = sandbox.json_output(&[
- "--format",
- "json",
- "--publish-mode",
- "radrootsd",
- "--approval-token",
- "approve",
- "listing",
- "publish",
- listing_file.to_string_lossy().as_ref(),
- ]);
+ let output = sandbox
+ .command()
+ .env("RADROOTS_RPC_URL", &server.endpoint)
+ .env("RADROOTS_RPC_BEARER_TOKEN", "bridge_test")
+ .args([
+ "--format",
+ "json",
+ "--publish-mode",
+ "radrootsd",
+ "--approval-token",
+ "approve",
+ "--idempotency-key",
+ "idem_listing",
+ "listing",
+ "publish",
+ listing_file.to_string_lossy().as_ref(),
+ ])
+ .output()
+ .expect("run radrootsd listing publish");
+ let value: Value = serde_json::from_slice(&output.stdout).expect("json output");
+ let request = server.take_request();
- assert!(!output.status.success());
- assert_eq!(output.status.code(), Some(3));
+ assert!(output.status.success());
assert_eq!(value["operation_id"], "listing.publish");
- assert_eq!(value["result"], Value::Null);
- assert_eq!(value["errors"][0]["code"], "provider_unavailable");
- assert_eq!(value["errors"][0]["detail"]["class"], "provider");
- assert_contains(&value["errors"][0]["message"], "radrootsd listing publish");
+ assert_eq!(
+ value["result"]["source"],
+ "radrootsd publish transport · signer session"
+ );
+ assert_eq!(value["result"]["job_id"], "job_listing_publish_test");
+ assert_eq!(value["result"]["job_status"], "published");
+ assert_eq!(value["result"]["event_id"], "e".repeat(64));
+ assert_eq!(
+ value["result"]["event_addr"],
+ "30402:daemon_test:radrootsd-router"
+ );
+ assert_eq!(value["result"]["signer_mode"], "nip46");
+ assert_eq!(value["result"]["signer_session_id"], "session_test");
+ assert_eq!(
+ value["result"]["requested_signer_session_id"],
+ "session_test"
+ );
+ assert_eq!(value["result"]["idempotency_key"], "idem_listing");
+ assert_eq!(
+ value["result"]["job"]["rpc_method"],
+ "bridge.listing.publish"
+ );
+ assert_eq!(value["result"]["job"]["relay_count"], 2);
+ assert_eq!(value["result"]["job"]["acknowledged_relay_count"], 1);
+ assert_eq!(request.body["method"], "bridge.listing.publish");
+ assert_eq!(request.body["params"]["kind"], 30402);
+ assert_eq!(request.body["params"]["signer_session_id"], "session_test");
+ assert_eq!(request.body["params"]["idempotency_key"], "idem_listing");
assert!(
- !value["errors"][0]["message"]
- .as_str()
- .expect("error message")
- .contains("configured relay")
+ request
+ .headers
+ .to_ascii_lowercase()
+ .contains("authorization: bearer bridge_test")
);
}
@@ -395,11 +552,11 @@ fn radrootsd_listing_publish_bypasses_relay_signer_preflight() {
]);
assert!(!output.status.success());
- assert_eq!(output.status.code(), Some(3));
+ assert_eq!(output.status.code(), Some(7));
assert_eq!(value["operation_id"], "listing.publish");
- assert_eq!(value["errors"][0]["code"], "provider_unavailable");
- assert_eq!(value["errors"][0]["detail"]["class"], "provider");
- assert_contains(&value["errors"][0]["message"], "radrootsd listing publish");
+ assert_eq!(value["errors"][0]["code"], "signer_unconfigured");
+ assert_eq!(value["errors"][0]["detail"]["class"], "signer");
+ assert_contains(&value["errors"][0]["message"], "signer_session_id");
assert!(
!value["errors"][0]["message"]
.as_str()
@@ -445,12 +602,12 @@ fn radrootsd_publish_mode_routes_listing_update() {
]);
assert!(!output.status.success());
- assert_eq!(output.status.code(), Some(3));
+ assert_eq!(output.status.code(), Some(7));
assert_eq!(value["operation_id"], "listing.update");
assert_eq!(value["result"], Value::Null);
- assert_eq!(value["errors"][0]["code"], "provider_unavailable");
- assert_eq!(value["errors"][0]["detail"]["class"], "provider");
- assert_contains(&value["errors"][0]["message"], "radrootsd listing publish");
+ assert_eq!(value["errors"][0]["code"], "signer_unconfigured");
+ assert_eq!(value["errors"][0]["detail"]["class"], "signer");
+ assert_contains(&value["errors"][0]["message"], "signer_session_id");
}
#[test]