radrootsd

JSON-RPC bridge for Radroots event publishing
git clone https://radroots.dev/git/radrootsd.git
Log | Files | Refs | README | LICENSE

commit 4543e7e8b500dc249624aa104dcc8b9ab20413f5
parent e923d4cc5ba7857848c91563cf73fd327aa529f7
Author: triesap <tyson@radroots.org>
Date:   Fri, 27 Mar 2026 23:03:11 +0000

api: add typed bridge order request ingress

- add bridge.order.request on the rr-rs trade order envelope and NIP-99 listing address contract
- validate and canonicalize embedded-signer order requests before signing and publishing them
- share bridge publish helpers and generic job construction across listing and order write commands
- update bridge tests and radrootsd docs for order-scoped job-backed ingress

Diffstat:
Msrc/core/bridge/store.rs | 69++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/transport/jsonrpc/methods/bridge/listing_publish.rs | 58++++++++++++++--------------------------------------------
Msrc/transport/jsonrpc/methods/bridge/mod.rs | 3+++
Asrc/transport/jsonrpc/methods/bridge/order_request.rs | 354+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/transport/jsonrpc/methods/bridge/shared.rs | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/transport/jsonrpc/methods/mod.rs | 1+
6 files changed, 487 insertions(+), 49 deletions(-)

diff --git a/src/core/bridge/store.rs b/src/core/bridge/store.rs @@ -158,18 +158,19 @@ impl BridgeJobStoreInner { } } -pub fn new_listing_publish_job( +pub fn new_publish_job( + command: &str, job_id: String, idempotency_key: Option<String>, event_kind: u32, event_id: String, - event_addr: String, + event_addr: Option<String>, delivery_policy: BridgeDeliveryPolicy, delivery_quorum: Option<usize>, ) -> BridgeJobRecord { BridgeJobRecord { job_id, - command: "bridge.listing.publish".to_string(), + command: command.to_string(), idempotency_key, status: BridgeJobStatus::Accepted, requested_at_unix: unix_timestamp_now(), @@ -177,7 +178,7 @@ pub fn new_listing_publish_job( signer_mode: "embedded_service_identity".to_string(), event_kind, event_id: Some(event_id), - event_addr: Some(event_addr), + event_addr, delivery_policy, delivery_quorum, relay_count: 0, @@ -190,6 +191,48 @@ pub fn new_listing_publish_job( } } +pub fn new_listing_publish_job( + job_id: String, + idempotency_key: Option<String>, + event_kind: u32, + event_id: String, + event_addr: String, + delivery_policy: BridgeDeliveryPolicy, + delivery_quorum: Option<usize>, +) -> BridgeJobRecord { + new_publish_job( + "bridge.listing.publish", + job_id, + idempotency_key, + event_kind, + event_id, + Some(event_addr), + delivery_policy, + delivery_quorum, + ) +} + +pub fn new_order_request_job( + job_id: String, + idempotency_key: Option<String>, + event_kind: u32, + event_id: String, + listing_addr: String, + delivery_policy: BridgeDeliveryPolicy, + delivery_quorum: Option<usize>, +) -> BridgeJobRecord { + new_publish_job( + "bridge.order.request", + job_id, + idempotency_key, + event_kind, + event_id, + Some(listing_addr), + delivery_policy, + delivery_quorum, + ) +} + fn unix_timestamp_now() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -202,7 +245,7 @@ mod tests { use crate::app::config::BridgeDeliveryPolicy; use crate::core::bridge::publish::BridgePublishExecution; - use super::{BridgeJobStatus, BridgeJobStore, new_listing_publish_job}; + use super::{BridgeJobStatus, BridgeJobStore, new_listing_publish_job, new_order_request_job}; #[test] fn reserve_returns_existing_job_for_same_idempotency_key() { @@ -298,4 +341,20 @@ mod tests { assert!(store.get("job-2").is_some()); assert_eq!(store.snapshot().retained_jobs, 1); } + + #[test] + fn order_request_job_uses_order_command_name() { + let job = new_order_request_job( + "job-1".to_string(), + Some("same".to_string()), + 5322, + "event-1".to_string(), + "30402:author:listing".to_string(), + BridgeDeliveryPolicy::Any, + None, + ); + + assert_eq!(job.command, "bridge.order.request"); + assert_eq!(job.event_addr.as_deref(), Some("30402:author:listing")); + } } diff --git a/src/transport/jsonrpc/methods/bridge/listing_publish.rs b/src/transport/jsonrpc/methods/bridge/listing_publish.rs @@ -5,12 +5,16 @@ use radroots_events_codec::listing::encode::to_wire_parts; use radroots_nostr::prelude::{radroots_event_from_nostr, radroots_nostr_build_event}; use radroots_nostr_signer::prelude::RadrootsNostrSignerBackend; use radroots_trade::listing::validation::validate_listing_event; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use uuid::Uuid; use crate::core::bridge::publish::{BridgePublishSettings, connect_and_publish_event}; -use crate::core::bridge::store::{BridgeJobRecord, new_listing_publish_job}; +use crate::core::bridge::store::new_listing_publish_job; use crate::transport::jsonrpc::auth::require_bridge_auth; +use crate::transport::jsonrpc::methods::bridge::shared::{ + BridgePublishResponse, bridge_signer_pubkey_hex, ensure_bridge_enabled, + normalize_idempotency_key, +}; use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError}; #[derive(Debug, Deserialize)] @@ -20,12 +24,6 @@ struct BridgeListingPublishParams { idempotency_key: Option<String>, } -#[derive(Clone, Debug, Serialize)] -struct BridgeListingPublishResponse { - deduplicated: bool, - job: BridgeJobRecord, -} - pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { registry.track("bridge.listing.publish"); m.register_async_method( @@ -36,7 +34,7 @@ pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Res .parse() .map_err(|e| RpcError::InvalidParams(e.to_string()))?; let response = publish_listing(ctx.as_ref().clone(), params).await?; - Ok::<BridgeListingPublishResponse, RpcError>(response) + Ok::<BridgePublishResponse, RpcError>(response) }, )?; Ok(()) @@ -45,22 +43,11 @@ pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Res async fn publish_listing( ctx: RpcContext, params: BridgeListingPublishParams, -) -> Result<BridgeListingPublishResponse, RpcError> { - if !ctx.state.bridge_config.enabled { - return Err(RpcError::Other("bridge ingress is disabled".to_string())); - } - +) -> Result<BridgePublishResponse, RpcError> { + ensure_bridge_enabled(&ctx)?; let idempotency_key = normalize_idempotency_key(params.idempotency_key)?; - let signer_identity = ctx - .state - .bridge_signer - .signer_identity() - .map_err(|error| RpcError::Other(format!("bridge signer unavailable: {error}")))? - .ok_or_else(|| RpcError::Other("bridge signer identity is missing".to_string()))?; - let listing = canonicalize_listing_for_embedded_signer( - params.listing, - signer_identity.public_key_hex.as_str(), - ); + let signer_pubkey = bridge_signer_pubkey_hex(&ctx)?; + let listing = canonicalize_listing_for_embedded_signer(params.listing, signer_pubkey.as_str()); let parts = to_wire_parts(&listing) .map_err(|error| RpcError::InvalidParams(format!("invalid listing contract: {error}")))?; let builder = radroots_nostr_build_event(parts.kind, parts.content, parts.tags) @@ -87,7 +74,7 @@ async fn publish_listing( let job = match reserved { Ok(job) => job, Err(existing) => { - return Ok(BridgeListingPublishResponse { + return Ok(BridgePublishResponse { deduplicated: true, job: existing, }); @@ -106,23 +93,12 @@ async fn publish_listing( .complete(&job.job_id, execution) .ok_or_else(|| RpcError::Other("bridge job disappeared during completion".to_string()))?; - Ok(BridgeListingPublishResponse { + Ok(BridgePublishResponse { deduplicated: false, job, }) } -fn normalize_idempotency_key(value: Option<String>) -> Result<Option<String>, RpcError> { - let value = value.map(|value| value.trim().to_string()); - match value { - Some(value) if value.is_empty() => Err(RpcError::InvalidParams( - "idempotency_key cannot be empty".to_string(), - )), - Some(value) => Ok(Some(value)), - None => Ok(None), - } -} - fn canonicalize_listing_for_embedded_signer( mut listing: RadrootsListing, signer_pubkey: &str, @@ -145,13 +121,7 @@ mod tests { RadrootsListingProduct, }; - use super::{canonicalize_listing_for_embedded_signer, normalize_idempotency_key}; - - #[test] - fn normalize_idempotency_key_rejects_empty_values() { - let err = normalize_idempotency_key(Some(" ".to_string())).expect_err("empty key"); - assert!(err.to_string().contains("idempotency_key")); - } + use super::canonicalize_listing_for_embedded_signer; #[test] fn canonicalize_listing_sets_missing_farm_pubkey() { diff --git a/src/transport/jsonrpc/methods/bridge/mod.rs b/src/transport/jsonrpc/methods/bridge/mod.rs @@ -5,6 +5,8 @@ use crate::transport::jsonrpc::{MethodRegistry, RpcContext}; mod job_status; mod listing_publish; +mod order_request; +mod shared; mod status; pub fn module(ctx: RpcContext, registry: MethodRegistry) -> Result<RpcModule<RpcContext>> { @@ -12,5 +14,6 @@ pub fn module(ctx: RpcContext, registry: MethodRegistry) -> Result<RpcModule<Rpc status::register(&mut m, &registry)?; job_status::register(&mut m, &registry)?; listing_publish::register(&mut m, &registry)?; + order_request::register(&mut m, &registry)?; Ok(m) } diff --git a/src/transport/jsonrpc/methods/bridge/order_request.rs b/src/transport/jsonrpc/methods/bridge/order_request.rs @@ -0,0 +1,354 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use radroots_events::kinds::KIND_LISTING; +use radroots_nostr::prelude::{radroots_nostr_build_event, radroots_nostr_parse_pubkey}; +use radroots_nostr_signer::prelude::RadrootsNostrSignerBackend; +use radroots_trade::listing::{ + dvm::{ + TradeListingAddress, TradeListingEnvelope, TradeListingMessageType, + trade_listing_envelope_event_build, + }, + order::{TradeOrder, TradeOrderStatus}, +}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::core::bridge::publish::{BridgePublishSettings, connect_and_publish_event}; +use crate::core::bridge::store::new_order_request_job; +use crate::transport::jsonrpc::auth::require_bridge_auth; +use crate::transport::jsonrpc::methods::bridge::shared::{ + BridgePublishResponse, bridge_signer_pubkey_hex, ensure_bridge_enabled, + normalize_idempotency_key, +}; +use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError}; + +#[derive(Debug, Deserialize)] +struct BridgeOrderRequestParams { + order: TradeOrder, + #[serde(default)] + idempotency_key: Option<String>, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("bridge.order.request"); + m.register_async_method( + "bridge.order.request", + |params, ctx, extensions| async move { + require_bridge_auth(&extensions)?; + let params: BridgeOrderRequestParams = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + let response = publish_order_request(ctx.as_ref().clone(), params).await?; + Ok::<BridgePublishResponse, RpcError>(response) + }, + )?; + Ok(()) +} + +async fn publish_order_request( + ctx: RpcContext, + params: BridgeOrderRequestParams, +) -> Result<BridgePublishResponse, RpcError> { + ensure_bridge_enabled(&ctx)?; + + let idempotency_key = normalize_idempotency_key(params.idempotency_key)?; + let signer_pubkey = bridge_signer_pubkey_hex(&ctx)?; + let order = canonicalize_order_request_for_embedded_signer(params.order, &signer_pubkey)?; + let envelope = TradeListingEnvelope::new( + TradeListingMessageType::OrderRequest, + order.listing_addr.clone(), + Some(order.order_id.clone()), + order.clone(), + ); + envelope.validate().map_err(|error| { + RpcError::InvalidParams(format!("invalid order request envelope: {error}")) + })?; + let built = trade_listing_envelope_event_build( + order.seller_pubkey.clone(), + TradeListingMessageType::OrderRequest, + order.listing_addr.clone(), + Some(order.order_id.clone()), + &order, + ) + .map_err(|error| RpcError::Other(format!("failed to build order request event: {error}")))?; + let builder = radroots_nostr_build_event(u32::from(built.kind), built.content, built.tags) + .map_err(|error| { + RpcError::Other(format!("failed to build order request event: {error}")) + })?; + let signed = ctx + .state + .bridge_signer + .sign_event_builder(builder) + .map_err(|error| RpcError::Other(format!("failed to sign order request event: {error}")))?; + let event = signed.event; + + let reserved = ctx.state.bridge_jobs.reserve(new_order_request_job( + Uuid::new_v4().to_string(), + idempotency_key, + u32::from(TradeListingMessageType::OrderRequest.kind()), + event.id.to_hex(), + order.listing_addr.clone(), + ctx.state.bridge_config.delivery_policy, + ctx.state.bridge_config.delivery_quorum, + )); + let job = match reserved { + Ok(job) => job, + Err(existing) => { + return Ok(BridgePublishResponse { + deduplicated: true, + job: existing, + }); + } + }; + + let execution = connect_and_publish_event( + &ctx.state.client, + &BridgePublishSettings::from_config(&ctx.state.bridge_config), + &event, + ) + .await; + let job = ctx + .state + .bridge_jobs + .complete(&job.job_id, execution) + .ok_or_else(|| RpcError::Other("bridge job disappeared during completion".to_string()))?; + + Ok(BridgePublishResponse { + deduplicated: false, + job, + }) +} + +fn canonicalize_order_request_for_embedded_signer( + mut order: TradeOrder, + signer_pubkey: &str, +) -> Result<TradeOrder, RpcError> { + let order_id = + normalized_required_string(std::mem::take(&mut order.order_id), "order.order_id")?; + let listing_addr_raw = normalized_required_string( + std::mem::take(&mut order.listing_addr), + "order.listing_addr", + )?; + let listing_addr = TradeListingAddress::parse(&listing_addr_raw) + .map_err(|error| RpcError::InvalidParams(format!("invalid order.listing_addr: {error}")))?; + if u32::from(listing_addr.kind) != KIND_LISTING { + return Err(RpcError::InvalidParams( + "order.listing_addr must reference a public NIP-99 listing".to_string(), + )); + } + + let buyer_pubkey = if order.buyer_pubkey.trim().is_empty() { + signer_pubkey.to_string() + } else { + normalized_required_string( + std::mem::take(&mut order.buyer_pubkey), + "order.buyer_pubkey", + )? + }; + if buyer_pubkey != signer_pubkey { + return Err(RpcError::InvalidParams( + "order.buyer_pubkey must match the bridge signer identity".to_string(), + )); + } + + let seller_pubkey = if order.seller_pubkey.trim().is_empty() { + listing_addr.seller_pubkey.clone() + } else { + normalized_required_string( + std::mem::take(&mut order.seller_pubkey), + "order.seller_pubkey", + )? + }; + if seller_pubkey != listing_addr.seller_pubkey { + return Err(RpcError::InvalidParams( + "order.seller_pubkey must match order.listing_addr seller".to_string(), + )); + } + + radroots_nostr_parse_pubkey(&buyer_pubkey) + .map_err(|error| RpcError::InvalidParams(format!("invalid order.buyer_pubkey: {error}")))?; + radroots_nostr_parse_pubkey(&seller_pubkey).map_err(|error| { + RpcError::InvalidParams(format!("invalid order.seller_pubkey: {error}")) + })?; + + if order.items.is_empty() { + return Err(RpcError::InvalidParams( + "order.items must contain at least one item".to_string(), + )); + } + for (index, item) in order.items.iter_mut().enumerate() { + item.bin_id = normalized_required_string(item.bin_id.clone(), "order.items[].bin_id")?; + if item.bin_count == 0 { + return Err(RpcError::InvalidParams(format!( + "order.items[{index}].bin_count must be greater than zero" + ))); + } + } + + if order.status != TradeOrderStatus::Requested { + return Err(RpcError::InvalidParams( + "order.status must be requested for bridge.order.request".to_string(), + )); + } + + order.order_id = order_id; + order.listing_addr = listing_addr.as_str(); + order.buyer_pubkey = buyer_pubkey; + order.seller_pubkey = seller_pubkey; + order.notes = normalize_optional_string(order.notes); + if order.discounts.as_ref().is_some_and(Vec::is_empty) { + order.discounts = None; + } + Ok(order) +} + +fn normalized_required_string(value: String, field: &str) -> Result<String, RpcError> { + let value = value.trim().to_string(); + if value.is_empty() { + return Err(RpcError::InvalidParams(format!("{field} cannot be empty"))); + } + Ok(value) +} + +fn normalize_optional_string(value: Option<String>) -> Option<String> { + value.and_then(|value| { + let value = value.trim().to_string(); + if value.is_empty() { None } else { Some(value) } + }) +} + +#[cfg(test)] +mod tests { + use radroots_core::RadrootsCoreDiscountValue; + use radroots_identity::RadrootsIdentity; + use radroots_nostr::prelude::RadrootsNostrMetadata; + use radroots_trade::listing::order::{TradeOrder, TradeOrderItem, TradeOrderStatus}; + + use crate::app::config::{BridgeConfig, Nip46Config}; + use crate::core::Radrootsd; + use crate::transport::jsonrpc::{MethodRegistry, RpcContext}; + + use super::{ + BridgeOrderRequestParams, canonicalize_order_request_for_embedded_signer, + normalize_optional_string, publish_order_request, + }; + + #[test] + fn canonicalize_order_request_sets_missing_buyer_and_seller_pubkeys() { + let order = canonicalize_order_request_for_embedded_signer( + base_order("", "", TradeOrderStatus::Requested), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + .expect("canonicalize"); + + assert_eq!( + order.buyer_pubkey, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + assert_eq!( + order.seller_pubkey, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ); + } + + #[test] + fn canonicalize_order_request_rejects_non_requested_status() { + let err = canonicalize_order_request_for_embedded_signer( + base_order( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "", + TradeOrderStatus::Draft, + ), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + .expect_err("status should fail"); + assert!(err.to_string().contains("order.status")); + } + + #[test] + fn canonicalize_order_request_rejects_items_with_zero_bin_count() { + let mut order = base_order( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "", + TradeOrderStatus::Requested, + ); + order.items[0].bin_count = 0; + let err = canonicalize_order_request_for_embedded_signer( + order, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + .expect_err("zero bin count"); + assert!(err.to_string().contains("bin_count")); + } + + #[test] + fn normalize_optional_string_trims_blank_values() { + assert_eq!(normalize_optional_string(Some(" ".to_string())), None); + assert_eq!( + normalize_optional_string(Some(" note ".to_string())), + Some("note".to_string()) + ); + } + + #[tokio::test] + async fn publish_order_request_is_job_backed_and_idempotent() { + let identity = RadrootsIdentity::generate(); + let metadata: RadrootsNostrMetadata = + serde_json::from_str(r#"{"name":"radrootsd-test"}"#).expect("metadata"); + let state = Radrootsd::new( + identity, + metadata, + BridgeConfig { + enabled: true, + bearer_token: Some("secret".to_string()), + ..BridgeConfig::default() + }, + Nip46Config::default(), + ) + .expect("state"); + let ctx = RpcContext::new(state, MethodRegistry::default()); + let params = BridgeOrderRequestParams { + order: base_order("", "", TradeOrderStatus::Requested), + idempotency_key: Some("same-key".to_string()), + }; + + let first = publish_order_request(ctx.clone(), params) + .await + .expect("first"); + assert!(!first.deduplicated); + assert_eq!(first.job.command, "bridge.order.request"); + assert_eq!(first.job.event_addr.as_deref(), Some(base_listing_addr())); + + let second = publish_order_request( + ctx, + BridgeOrderRequestParams { + order: base_order("", "", TradeOrderStatus::Requested), + idempotency_key: Some("same-key".to_string()), + }, + ) + .await + .expect("second"); + assert!(second.deduplicated); + assert_eq!(second.job.job_id, first.job.job_id); + } + + fn base_listing_addr() -> &'static str { + "30402:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:AAAAAAAAAAAAAAAAAAAAAg" + } + + fn base_order(buyer_pubkey: &str, seller_pubkey: &str, status: TradeOrderStatus) -> TradeOrder { + TradeOrder { + order_id: "order-1".to_string(), + listing_addr: base_listing_addr().to_string(), + buyer_pubkey: buyer_pubkey.to_string(), + seller_pubkey: seller_pubkey.to_string(), + items: vec![TradeOrderItem { + bin_id: "bin-1".to_string(), + bin_count: 2, + }], + discounts: Some(Vec::<RadrootsCoreDiscountValue>::new()), + notes: Some(" note ".to_string()), + status, + } + } +} diff --git a/src/transport/jsonrpc/methods/bridge/shared.rs b/src/transport/jsonrpc/methods/bridge/shared.rs @@ -0,0 +1,51 @@ +use anyhow::Result; +use radroots_nostr_signer::prelude::RadrootsNostrSignerBackend; +use serde::Serialize; + +use crate::core::bridge::store::BridgeJobRecord; +use crate::transport::jsonrpc::{RpcContext, RpcError}; + +#[derive(Clone, Debug, Serialize)] +pub(super) struct BridgePublishResponse { + pub deduplicated: bool, + pub job: BridgeJobRecord, +} + +pub(super) fn ensure_bridge_enabled(ctx: &RpcContext) -> Result<(), RpcError> { + if !ctx.state.bridge_config.enabled { + return Err(RpcError::Other("bridge ingress is disabled".to_string())); + } + Ok(()) +} + +pub(super) fn bridge_signer_pubkey_hex(ctx: &RpcContext) -> Result<String, RpcError> { + Ok(ctx + .state + .bridge_signer + .signer_identity() + .map_err(|error| RpcError::Other(format!("bridge signer unavailable: {error}")))? + .ok_or_else(|| RpcError::Other("bridge signer identity is missing".to_string()))? + .public_key_hex) +} + +pub(super) fn normalize_idempotency_key(value: Option<String>) -> Result<Option<String>, RpcError> { + let value = value.map(|value| value.trim().to_string()); + match value { + Some(value) if value.is_empty() => Err(RpcError::InvalidParams( + "idempotency_key cannot be empty".to_string(), + )), + Some(value) => Ok(Some(value)), + None => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use super::normalize_idempotency_key; + + #[test] + fn normalize_idempotency_key_rejects_empty_values() { + let err = normalize_idempotency_key(Some(" ".to_string())).expect_err("empty key"); + assert!(err.to_string().contains("idempotency_key")); + } +} diff --git a/src/transport/jsonrpc/methods/mod.rs b/src/transport/jsonrpc/methods/mod.rs @@ -60,6 +60,7 @@ mod tests { assert!(root.method("bridge.status").is_some()); assert!(root.method("bridge.job.status").is_some()); assert!(root.method("bridge.listing.publish").is_some()); + assert!(root.method("bridge.order.request").is_some()); assert!(root.method("nip46.connect").is_none()); }