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:
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, ®istry)?;
job_status::register(&mut m, ®istry)?;
listing_publish::register(&mut m, ®istry)?;
+ order_request::register(&mut m, ®istry)?;
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());
}