radrootsd

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

commit 71aecb031a1caf03e1c73d91d7538074c5ff4882
parent 6b311689a6b95475baf4a707d584c6f3fe7c9463
Author: triesap <triesap@radroots.dev>
Date:   Sat,  3 Jan 2026 16:00:55 +0000

jsonrpc: refactor into context-based modules

- Move RPC implementation under src/api/jsonrpc with server builder
- Introduce RpcContext and MethodRegistry for shared state and method tracking
- Restructure config into [config.rpc] with typed RpcConfig and defaults
- Update dependencies and nostr crates; add identity public_key and publish profile on startup

Diffstat:
MCargo.lock | 64+++++++++++++++++++++++++++++++++++++++++++---------------------
Mconfig.toml | 7++++---
Midentity.json | 3++-
Mrust-toolchain.toml | 3+--
Asrc/api/jsonrpc/context.rs | 17+++++++++++++++++
Asrc/api/jsonrpc/error.rs | 30++++++++++++++++++++++++++++++
Rsrc/rpc/domains/mod.rs -> src/api/jsonrpc/methods/domains/mod.rs | 0
Asrc/api/jsonrpc/methods/domains/trade/listing/dvm.rs | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/api/jsonrpc/methods/domains/trade/listing/get.rs | 43+++++++++++++++++++++++++++++++++++++++++++
Asrc/api/jsonrpc/methods/domains/trade/listing/helpers.rs | 229+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/api/jsonrpc/methods/domains/trade/listing/list.rs | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/api/jsonrpc/methods/domains/trade/listing/mod.rs | 25+++++++++++++++++++++++++
Asrc/api/jsonrpc/methods/domains/trade/listing/orders.rs | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/api/jsonrpc/methods/domains/trade/listing/series.rs | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rsrc/rpc/domains/trade/listing/types.rs -> src/api/jsonrpc/methods/domains/trade/listing/types.rs | 0
Asrc/api/jsonrpc/methods/domains/trade/mod.rs | 14++++++++++++++
Asrc/api/jsonrpc/methods/events/listing/list.rs | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/api/jsonrpc/methods/events/listing/mod.rs | 14++++++++++++++
Asrc/api/jsonrpc/methods/events/listing/publish.rs | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Rsrc/rpc/events/mod.rs -> src/api/jsonrpc/methods/events/mod.rs | 0
Asrc/api/jsonrpc/methods/events/post/list.rs | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/api/jsonrpc/methods/events/post/mod.rs | 14++++++++++++++
Asrc/api/jsonrpc/methods/events/post/publish.rs | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/api/jsonrpc/methods/events/profile/list.rs | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/api/jsonrpc/methods/events/profile/mod.rs | 14++++++++++++++
Asrc/api/jsonrpc/methods/events/profile/publish.rs | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/api/jsonrpc/methods/mod.rs | 25+++++++++++++++++++++++++
Asrc/api/jsonrpc/methods/relays/add.rs | 28++++++++++++++++++++++++++++
Asrc/api/jsonrpc/methods/relays/connect.rs | 43+++++++++++++++++++++++++++++++++++++++++++
Asrc/api/jsonrpc/methods/relays/list.rs | 16++++++++++++++++
Asrc/api/jsonrpc/methods/relays/mod.rs | 22++++++++++++++++++++++
Asrc/api/jsonrpc/methods/relays/remove.rs | 28++++++++++++++++++++++++++++
Asrc/api/jsonrpc/methods/relays/status.rs | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/api/jsonrpc/methods/system.rs | 27+++++++++++++++++++++++++++
Asrc/api/jsonrpc/mod.rs | 36++++++++++++++++++++++++++++++++++++
Asrc/api/jsonrpc/registry.rs | 23+++++++++++++++++++++++
Asrc/api/jsonrpc/server.rs | 30++++++++++++++++++++++++++++++
Asrc/api/mod.rs | 3+++
Msrc/config.rs | 70+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/lib.rs | 23++++++++++++++++++-----
Msrc/main.rs | 2++
Dsrc/rpc/domains/trade/listing/dvm.rs | 94-------------------------------------------------------------------------------
Dsrc/rpc/domains/trade/listing/get.rs | 42------------------------------------------
Dsrc/rpc/domains/trade/listing/helpers.rs | 231-------------------------------------------------------------------------------
Dsrc/rpc/domains/trade/listing/list.rs | 80-------------------------------------------------------------------------------
Dsrc/rpc/domains/trade/listing/mod.rs | 25-------------------------
Dsrc/rpc/domains/trade/listing/orders.rs | 93-------------------------------------------------------------------------------
Dsrc/rpc/domains/trade/listing/series.rs | 103-------------------------------------------------------------------------------
Dsrc/rpc/domains/trade/mod.rs | 14--------------
Dsrc/rpc/error.rs | 28----------------------------
Dsrc/rpc/events/listing/list.rs | 78------------------------------------------------------------------------------
Dsrc/rpc/events/listing/mod.rs | 14--------------
Dsrc/rpc/events/listing/publish.rs | 48------------------------------------------------
Dsrc/rpc/events/post/list.rs | 69---------------------------------------------------------------------
Dsrc/rpc/events/post/mod.rs | 14--------------
Dsrc/rpc/events/post/publish.rs | 54------------------------------------------------------
Dsrc/rpc/events/profile/list.rs | 57---------------------------------------------------------
Dsrc/rpc/events/profile/mod.rs | 14--------------
Dsrc/rpc/events/profile/publish.rs | 54------------------------------------------------------
Dsrc/rpc/mod.rs | 29-----------------------------
Dsrc/rpc/relays/add.rs | 28----------------------------
Dsrc/rpc/relays/connect.rs | 43-------------------------------------------
Dsrc/rpc/relays/list.rs | 16----------------
Dsrc/rpc/relays/mod.rs | 22----------------------
Dsrc/rpc/relays/remove.rs | 28----------------------------
Dsrc/rpc/relays/status.rs | 57---------------------------------------------------------
Dsrc/rpc/system.rs | 43-------------------------------------------
67 files changed, 1620 insertions(+), 1411 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -313,6 +313,12 @@ dependencies = [ ] [[package]] +name = "btreecap" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6160c957d8aa33d0a8ba1dbab98e3cb57023ad9374c501441e88559f99e6c4c9" + +[[package]] name = "bumpalo" version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -540,7 +546,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] @@ -831,6 +836,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] name = "hex-conservative" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1122,9 +1133,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" dependencies = [ "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", ] [[package]] @@ -1373,9 +1381,7 @@ dependencies = [ [[package]] name = "nostr" -version = "0.43.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62a97d745f1bd8d5e05a978632bbb87b0614567d5142906fe7c86fb2440faac6" +version = "0.44.1" dependencies = [ "aes", "base64 0.22.1", @@ -1385,8 +1391,10 @@ dependencies = [ "cbc", "chacha20", "chacha20poly1305", - "getrandom 0.2.16", + "hex", "instant", + "once_cell", + "rand 0.9.2", "scrypt", "secp256k1", "serde", @@ -1397,24 +1405,29 @@ dependencies = [ [[package]] name = "nostr-database" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c75a8c2175d2785ba73cfddef21d1e30da5fbbdf158569b6808ba44973a15b" +version = "0.44.0" dependencies = [ + "btreecap", "lru", "nostr", "tokio", ] [[package]] +name = "nostr-gossip" +version = "0.44.0" +dependencies = [ + "nostr", +] + +[[package]] name = "nostr-relay-pool" -version = "0.43.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b2f43b70d13dfc50508a13cd902e11f4625312b2ce0e4b7c4c2283fd04001bd" +version = "0.44.0" dependencies = [ "async-utility", "async-wsocket", "atomic-destructor", + "hex", "lru", "negentropy", "nostr", @@ -1425,15 +1438,15 @@ dependencies = [ [[package]] name = "nostr-sdk" -version = "0.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "599f8963d6a1522a13b1a2b0ea6e168acfc367706606f1d33fa595e91fa22db0" +version = "0.44.1" dependencies = [ "async-utility", "nostr", "nostr-database", + "nostr-gossip", "nostr-relay-pool", "tokio", + "tracing", ] [[package]] @@ -1777,6 +1790,7 @@ name = "radroots-identity" version = "0.1.0" dependencies = [ "nostr", + "radroots-events", "radroots-runtime", "serde", "serde_json", @@ -1785,6 +1799,16 @@ dependencies = [ ] [[package]] +name = "radroots-log" +version = "0.1.0" +dependencies = [ + "thiserror 1.0.69", + "tracing", + "tracing-appender", + "tracing-subscriber", +] + +[[package]] name = "radroots-nostr" version = "0.1.0" dependencies = [ @@ -1806,6 +1830,7 @@ dependencies = [ "anyhow", "clap", "config", + "radroots-log", "serde", "serde_json", "tempfile", @@ -1813,8 +1838,6 @@ dependencies = [ "tokio", "toml", "tracing", - "tracing-appender", - "tracing-subscriber", ] [[package]] @@ -2143,7 +2166,6 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ - "rand 0.8.5", "secp256k1-sys", "serde", ] @@ -3064,7 +3086,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.1", ] [[package]] diff --git a/config.toml b/config.toml @@ -11,7 +11,9 @@ name = "radrootsd" [config] logs_dir = "logs" -rpc_addr = "127.0.0.1:7070" relays = [ "ws://127.0.0.1:8080" -] -\ No newline at end of file +] + +[config.rpc] +addr = "127.0.0.1:7070" diff --git a/identity.json b/identity.json @@ -1,3 +1,4 @@ { - "secret_key": "e8f44e66f455231d6f22334dfa87d014c75c2718e13de7915dba627f0c78f42f" + "secret_key": "e8f44e66f455231d6f22334dfa87d014c75c2718e13de7915dba627f0c78f42f", + "public_key": "1bdeb0682b20be945089d730166513fd5867b9af0dcfbbee067c14cae1b295f1" } \ No newline at end of file diff --git a/rust-toolchain.toml b/rust-toolchain.toml @@ -1,2 +1,2 @@ [toolchain] -channel = "1.88.0" -\ No newline at end of file +channel = "1.88.0" diff --git a/src/api/jsonrpc/context.rs b/src/api/jsonrpc/context.rs @@ -0,0 +1,17 @@ +#![forbid(unsafe_code)] + +use crate::radrootsd::Radrootsd; + +use super::registry::MethodRegistry; + +#[derive(Clone)] +pub struct RpcContext { + pub state: Radrootsd, + pub methods: MethodRegistry, +} + +impl RpcContext { + pub fn new(state: Radrootsd, methods: MethodRegistry) -> Self { + Self { state, methods } + } +} diff --git a/src/api/jsonrpc/error.rs b/src/api/jsonrpc/error.rs @@ -0,0 +1,30 @@ +#![forbid(unsafe_code)] + +use jsonrpsee::types::{ErrorObject, ErrorObjectOwned}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum RpcError { + #[error("failed to add relay {0}: {1}")] + AddRelay(String, String), + #[error("no relays configured; call relays.add first")] + NoRelays, + #[error("invalid params: {0}")] + InvalidParams(String), + #[error("method not found: {0}")] + MethodNotFound(String), + #[error("{0}")] + Other(String), +} + +impl From<RpcError> for ErrorObjectOwned { + fn from(err: RpcError) -> Self { + match err { + RpcError::InvalidParams(msg) => ErrorObject::owned(-32602, msg, None::<()>), + RpcError::MethodNotFound(name) => { + ErrorObject::owned(-32601, format!("method not found: {name}"), None::<()>) + } + other => ErrorObject::owned(-32000, other.to_string(), None::<()>), + } + } +} diff --git a/src/rpc/domains/mod.rs b/src/api/jsonrpc/methods/domains/mod.rs diff --git a/src/api/jsonrpc/methods/domains/trade/listing/dvm.rs b/src/api/jsonrpc/methods/domains/trade/listing/dvm.rs @@ -0,0 +1,95 @@ +#![forbid(unsafe_code)] + +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::{Deserialize, Serialize}; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; +use radroots_nostr::prelude::radroots_nostr_parse_pubkeys; +use radroots_trade::listing::dvm_kinds::TRADE_LISTING_DVM_KINDS; + +use super::helpers::{fetch_dvm_events, parse_listing_addr}; +use super::types::DvmEventView; + +#[derive(Debug, Deserialize)] +struct TradeListingDvmListParams { + listing_addr: String, + #[serde(default)] + order_id: Option<String>, + #[serde(default)] + authors: Option<Vec<String>>, + #[serde(default)] + recipients: Option<Vec<String>>, + #[serde(default)] + kinds: Option<Vec<u16>>, + #[serde(default)] + limit: Option<u64>, + #[serde(default)] + since: Option<u64>, + #[serde(default)] + until: Option<u64>, + #[serde(default)] + timeout_secs: Option<u64>, +} + +#[derive(Clone, Debug, Serialize)] +struct TradeListingDvmListResponse { + events: Vec<DvmEventView>, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("trade.listing.dvm.list"); + m.register_async_method("trade.listing.dvm.list", |params, ctx, _| async move { + if ctx.state.client.relays().await.is_empty() { + return Err(RpcError::NoRelays); + } + + let TradeListingDvmListParams { + listing_addr, + order_id, + authors, + recipients, + kinds, + limit, + since, + until, + timeout_secs, + } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + + let addr = parse_listing_addr(&listing_addr)?; + let kinds = kinds.unwrap_or_else(|| TRADE_LISTING_DVM_KINDS.to_vec()); + let authors = match authors { + Some(authors) => Some( + radroots_nostr_parse_pubkeys(&authors) + .map_err(|e| RpcError::InvalidParams(format!("invalid author: {e}")))?, + ), + None => None, + }; + let recipients = match recipients { + Some(recipients) => Some( + radroots_nostr_parse_pubkeys(&recipients) + .map_err(|e| RpcError::InvalidParams(format!("invalid recipient: {e}")))?, + ), + None => None, + }; + + let events = fetch_dvm_events( + &ctx.state.client, + &addr, + &kinds, + order_id.as_deref(), + authors.as_deref(), + recipients.as_deref(), + since, + until, + limit, + timeout_secs.unwrap_or(10), + ) + .await?; + + Ok::<TradeListingDvmListResponse, RpcError>(TradeListingDvmListResponse { events }) + })?; + Ok(()) +} diff --git a/src/api/jsonrpc/methods/domains/trade/listing/get.rs b/src/api/jsonrpc/methods/domains/trade/listing/get.rs @@ -0,0 +1,43 @@ +#![forbid(unsafe_code)] + +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::{Deserialize, Serialize}; +use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; + +use super::helpers::{fetch_latest_listing_event, listing_view, parse_listing_addr}; +use super::types::ListingEventView; + +#[derive(Debug, Deserialize)] +struct TradeListingGetParams { + listing_addr: String, + #[serde(default)] + timeout_secs: Option<u64>, +} + +#[derive(Clone, Debug, Serialize)] +struct TradeListingGetResponse { + listing: Option<ListingEventView>, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("trade.listing.get"); + m.register_async_method("trade.listing.get", |params, ctx, _| async move { + if ctx.state.client.relays().await.is_empty() { + return Err(RpcError::NoRelays); + } + + let TradeListingGetParams { + listing_addr, + timeout_secs, + } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + + let addr = parse_listing_addr(&listing_addr)?; + let latest = fetch_latest_listing_event(&ctx.state.client, &addr, timeout_secs.unwrap_or(10)).await?; + let listing = latest.as_ref().map(listing_view); + Ok::<TradeListingGetResponse, RpcError>(TradeListingGetResponse { listing }) + })?; + Ok(()) +} diff --git a/src/api/jsonrpc/methods/domains/trade/listing/helpers.rs b/src/api/jsonrpc/methods/domains/trade/listing/helpers.rs @@ -0,0 +1,229 @@ +#![forbid(unsafe_code)] + +use std::collections::HashMap; +use std::time::Duration; + +use radroots_nostr::prelude::{ + radroots_nostr_parse_pubkey, + RadrootsNostrClient, + RadrootsNostrCoordinate, + RadrootsNostrEvent, + RadrootsNostrFilter, + RadrootsNostrKind, + RadrootsNostrPublicKey, + RadrootsNostrTimestamp, +}; +use radroots_trade::listing::{ + codec::listing_from_event_parts, + dvm::{TradeListingAddress, TradeListingEnvelope}, +}; + +use super::types::{DvmEventView, ListingEventView, NostrEventView, TradeListingOrderSummary}; +use crate::api::jsonrpc::RpcError; + +pub(crate) const LISTING_KIND: u16 = 30402; + +pub(crate) fn event_tags(event: &RadrootsNostrEvent) -> Vec<Vec<String>> { + event.tags.iter().map(|t| t.as_slice().to_vec()).collect() +} + +pub(crate) fn event_view(event: &RadrootsNostrEvent) -> NostrEventView { + NostrEventView { + id: event.id.to_string(), + author: event.pubkey.to_string(), + created_at: event.created_at.as_secs(), + kind: event.kind.as_u16() as u32, + tags: event_tags(event), + content: event.content.clone(), + sig: event.sig.to_string(), + } +} + +pub(crate) fn listing_view(event: &RadrootsNostrEvent) -> ListingEventView { + let tags = event_tags(event); + let listing = listing_from_event_parts(&tags, &event.content).ok(); + ListingEventView { + event: event_view(event), + listing, + } +} + +pub(crate) fn parse_listing_addr(listing_addr: &str) -> Result<TradeListingAddress, RpcError> { + let addr = TradeListingAddress::parse(listing_addr) + .map_err(|_| RpcError::InvalidParams("invalid listing_addr".to_string()))?; + if addr.kind != LISTING_KIND { + return Err(RpcError::InvalidParams("unsupported listing kind".to_string())); + } + Ok(addr) +} + +pub(crate) fn listing_filter(addr: &TradeListingAddress) -> Result<RadrootsNostrFilter, RpcError> { + let author = radroots_nostr_parse_pubkey(&addr.seller_pubkey) + .map_err(|e| RpcError::InvalidParams(format!("invalid listing author: {e}")))?; + Ok(RadrootsNostrFilter::new() + .kind(RadrootsNostrKind::Custom(addr.kind)) + .author(author) + .identifier(addr.listing_id.clone())) +} + +pub(crate) async fn fetch_latest_listing_event( + client: &RadrootsNostrClient, + listing_addr: &TradeListingAddress, + timeout_secs: u64, +) -> Result<Option<RadrootsNostrEvent>, RpcError> { + let mut filter = listing_filter(listing_addr)?; + filter = filter.limit(25); + let events = client + .fetch_events(filter, Duration::from_secs(timeout_secs)) + .await + .map_err(|e| RpcError::Other(format!("fetch failed: {e}")))?; + let mut latest: Option<RadrootsNostrEvent> = None; + for event in events { + match &latest { + Some(cur) if event.created_at <= cur.created_at => {} + _ => latest = Some(event), + } + } + Ok(latest) +} + +pub(crate) fn dvm_filter( + listing_addr: &TradeListingAddress, + kinds: &[u16], +) -> Result<RadrootsNostrFilter, RpcError> { + let author = radroots_nostr_parse_pubkey(&listing_addr.seller_pubkey) + .map_err(|e| RpcError::InvalidParams(format!("invalid listing author: {e}")))?; + let coordinate = RadrootsNostrCoordinate::new( + RadrootsNostrKind::Custom(listing_addr.kind), + author, + ) + .identifier(listing_addr.listing_id.clone()); + let kinds = kinds + .iter() + .map(|kind| RadrootsNostrKind::Custom(*kind)) + .collect::<Vec<_>>(); + Ok(RadrootsNostrFilter::new() + .kinds(kinds) + .coordinate(&coordinate)) +} + +pub(crate) fn dvm_event_view(event: &RadrootsNostrEvent) -> DvmEventView { + let envelope = serde_json::from_str::<TradeListingEnvelope<serde_json::Value>>(&event.content) + .ok(); + let envelope_error = envelope + .as_ref() + .and_then(|env| env.validate().err()) + .map(|err| err.to_string()) + .or_else(|| { + if envelope.is_some() { + None + } else { + Some("invalid envelope json".to_string()) + } + }); + DvmEventView { + event: event_view(event), + envelope, + envelope_error, + } +} + +pub(crate) async fn fetch_dvm_events( + client: &RadrootsNostrClient, + listing_addr: &TradeListingAddress, + kinds: &[u16], + order_id: Option<&str>, + authors: Option<&[RadrootsNostrPublicKey]>, + recipients: Option<&[RadrootsNostrPublicKey]>, + since: Option<u64>, + until: Option<u64>, + limit: Option<u64>, + timeout_secs: u64, +) -> Result<Vec<DvmEventView>, RpcError> { + let mut filter = dvm_filter(listing_addr, kinds)?; + + if let Some(order_id) = order_id { + filter = filter.identifier(order_id); + } + if let Some(authors) = authors { + filter = filter.authors(authors.to_vec()); + } + if let Some(recipients) = recipients { + filter = filter.pubkeys(recipients.to_vec()); + } + if let Some(since) = since { + filter = filter.since(RadrootsNostrTimestamp::from_secs(since)); + } + if let Some(until) = until { + filter = filter.until(RadrootsNostrTimestamp::from_secs(until)); + } + if let Some(limit) = limit { + filter = filter.limit(limit.min(1000) as usize); + } + + let events = client + .fetch_events(filter, Duration::from_secs(timeout_secs)) + .await + .map_err(|e| RpcError::Other(format!("fetch failed: {e}")))?; + + let mut out = events + .into_iter() + .map(|event| dvm_event_view(&event)) + .collect::<Vec<_>>(); + out.sort_by(|a, b| a.event.created_at.cmp(&b.event.created_at)); + Ok(out) +} + +pub(crate) fn order_id_from_event(event: &DvmEventView) -> Option<String> { + if let Some(envelope) = &event.envelope { + if let Some(order_id) = &envelope.order_id { + return Some(order_id.clone()); + } + } + event + .event + .tags + .iter() + .find_map(|tag| match tag.get(0).map(String::as_str) { + Some("d") => tag.get(1).cloned(), + _ => None, + }) +} + +pub(crate) fn order_summaries( + events: &[DvmEventView], + listing_addr: &str, +) -> Vec<TradeListingOrderSummary> { + let mut summary_map: HashMap<String, TradeListingOrderSummary> = HashMap::new(); + + for event in events { + let order_id = match order_id_from_event(event) { + Some(id) => id, + None => continue, + }; + let entry = summary_map.entry(order_id.clone()).or_insert_with(|| { + TradeListingOrderSummary { + order_id, + listing_addr: listing_addr.to_string(), + event_count: 0, + first_seen_at: event.event.created_at, + last_seen_at: event.event.created_at, + last_event_id: event.event.id.clone(), + last_event_kind: event.event.kind, + } + }); + entry.event_count += 1; + if event.event.created_at < entry.first_seen_at { + entry.first_seen_at = event.event.created_at; + } + if event.event.created_at >= entry.last_seen_at { + entry.last_seen_at = event.event.created_at; + entry.last_event_id = event.event.id.clone(); + entry.last_event_kind = event.event.kind; + } + } + + let mut summaries: Vec<TradeListingOrderSummary> = summary_map.into_values().collect(); + summaries.sort_by(|a, b| b.last_seen_at.cmp(&a.last_seen_at)); + summaries +} diff --git a/src/api/jsonrpc/methods/domains/trade/listing/list.rs b/src/api/jsonrpc/methods/domains/trade/listing/list.rs @@ -0,0 +1,82 @@ +#![forbid(unsafe_code)] + +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; +use radroots_nostr::prelude::{ + radroots_nostr_parse_pubkeys, + RadrootsNostrFilter, + RadrootsNostrKind, + RadrootsNostrTimestamp, +}; + +use super::helpers::{listing_view, LISTING_KIND}; +use super::types::ListingEventView; + +#[derive(Debug, Default, Deserialize)] +struct TradeListingListParams { + #[serde(default)] + authors: Option<Vec<String>>, + #[serde(default)] + limit: Option<u64>, + #[serde(default)] + since: Option<u64>, + #[serde(default)] + until: Option<u64>, +} + +#[derive(Clone, Debug, Serialize)] +struct TradeListingListResponse { + listings: Vec<ListingEventView>, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("trade.listing.list"); + m.register_async_method("trade.listing.list", |params, ctx, _| async move { + if ctx.state.client.relays().await.is_empty() { + return Err(RpcError::NoRelays); + } + + let TradeListingListParams { + authors, + limit, + since, + until, + } = params.parse().unwrap_or_default(); + + let limit = limit.unwrap_or(50).min(1000) as usize; + + let mut filter = RadrootsNostrFilter::new() + .kind(RadrootsNostrKind::Custom(LISTING_KIND)) + .limit(limit); + if let Some(authors) = authors { + let pks = radroots_nostr_parse_pubkeys(&authors) + .map_err(|e| RpcError::InvalidParams(format!("invalid author: {e}")))?; + filter = filter.authors(pks); + } else { + filter = filter.author(ctx.state.pubkey); + } + if let Some(since) = since { + filter = filter.since(RadrootsNostrTimestamp::from_secs(since)); + } + if let Some(until) = until { + filter = filter.until(RadrootsNostrTimestamp::from_secs(until)); + } + + let events = ctx + .state + .client + .fetch_events(filter, Duration::from_secs(10)) + .await + .map_err(|e| RpcError::Other(format!("fetch failed: {e}")))?; + + let mut listings = events.into_iter().map(|ev| listing_view(&ev)).collect::<Vec<_>>(); + listings.sort_by(|a, b| b.event.created_at.cmp(&a.event.created_at)); + + Ok::<TradeListingListResponse, RpcError>(TradeListingListResponse { listings }) + })?; + Ok(()) +} diff --git a/src/api/jsonrpc/methods/domains/trade/listing/mod.rs b/src/api/jsonrpc/methods/domains/trade/listing/mod.rs @@ -0,0 +1,25 @@ +#![forbid(unsafe_code)] + +use anyhow::Result; +use jsonrpsee::server::RpcModule; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext}; + +pub mod dvm; +pub mod get; +pub mod list; +pub mod orders; +pub mod series; + +mod helpers; +mod types; + +pub fn module(ctx: RpcContext, registry: MethodRegistry) -> Result<RpcModule<RpcContext>> { + let mut m = RpcModule::new(ctx); + get::register(&mut m, &registry)?; + list::register(&mut m, &registry)?; + dvm::register(&mut m, &registry)?; + series::register(&mut m, &registry)?; + orders::register(&mut m, &registry)?; + Ok(m) +} diff --git a/src/api/jsonrpc/methods/domains/trade/listing/orders.rs b/src/api/jsonrpc/methods/domains/trade/listing/orders.rs @@ -0,0 +1,94 @@ +#![forbid(unsafe_code)] + +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::{Deserialize, Serialize}; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; +use radroots_nostr::prelude::radroots_nostr_parse_pubkeys; +use radroots_trade::listing::dvm_kinds::TRADE_LISTING_DVM_KINDS; + +use super::helpers::{fetch_dvm_events, order_summaries, parse_listing_addr}; +use super::types::TradeListingOrderSummary; + +#[derive(Debug, Deserialize)] +struct TradeListingOrdersParams { + listing_addr: String, + #[serde(default)] + authors: Option<Vec<String>>, + #[serde(default)] + recipients: Option<Vec<String>>, + #[serde(default)] + kinds: Option<Vec<u16>>, + #[serde(default)] + limit: Option<u64>, + #[serde(default)] + since: Option<u64>, + #[serde(default)] + until: Option<u64>, + #[serde(default)] + timeout_secs: Option<u64>, +} + +#[derive(Clone, Debug, Serialize)] +struct TradeListingOrdersResponse { + orders: Vec<TradeListingOrderSummary>, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("trade.listing.orders.list"); + m.register_async_method("trade.listing.orders.list", |params, ctx, _| async move { + if ctx.state.client.relays().await.is_empty() { + return Err(RpcError::NoRelays); + } + + let TradeListingOrdersParams { + listing_addr, + authors, + recipients, + kinds, + limit, + since, + until, + timeout_secs, + } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + + let addr = parse_listing_addr(&listing_addr)?; + let kinds = kinds.unwrap_or_else(|| TRADE_LISTING_DVM_KINDS.to_vec()); + let authors = match authors { + Some(authors) => Some( + radroots_nostr_parse_pubkeys(&authors) + .map_err(|e| RpcError::InvalidParams(format!("invalid author: {e}")))?, + ), + None => None, + }; + let recipients = match recipients { + Some(recipients) => Some( + radroots_nostr_parse_pubkeys(&recipients) + .map_err(|e| RpcError::InvalidParams(format!("invalid recipient: {e}")))?, + ), + None => None, + }; + + let events = fetch_dvm_events( + &ctx.state.client, + &addr, + &kinds, + None, + authors.as_deref(), + recipients.as_deref(), + since, + until, + limit, + timeout_secs.unwrap_or(10), + ) + .await?; + + let orders = order_summaries(&events, &listing_addr); + + Ok::<TradeListingOrdersResponse, RpcError>(TradeListingOrdersResponse { orders }) + })?; + Ok(()) +} diff --git a/src/api/jsonrpc/methods/domains/trade/listing/series.rs b/src/api/jsonrpc/methods/domains/trade/listing/series.rs @@ -0,0 +1,104 @@ +#![forbid(unsafe_code)] + +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::{Deserialize, Serialize}; +use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; +use radroots_trade::listing::dvm_kinds::TRADE_LISTING_DVM_KINDS; + +use super::helpers::{ + fetch_dvm_events, fetch_latest_listing_event, listing_view, order_summaries, parse_listing_addr, +}; +use super::types::{TradeListingOrderSummary, TradeListingSeriesView}; + +#[derive(Debug, Deserialize)] +struct TradeListingSeriesParams { + listing_addr: String, + #[serde(default)] + order_id: Option<String>, + #[serde(default)] + include_listing: Option<bool>, + #[serde(default)] + include_dvm: Option<bool>, + #[serde(default)] + limit: Option<u64>, + #[serde(default)] + since: Option<u64>, + #[serde(default)] + until: Option<u64>, + #[serde(default)] + timeout_secs: Option<u64>, +} + +#[derive(Clone, Debug, Serialize)] +struct TradeListingSeriesResponse { + series: TradeListingSeriesView, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("trade.listing.series.get"); + m.register_async_method("trade.listing.series.get", |params, ctx, _| async move { + if ctx.state.client.relays().await.is_empty() { + return Err(RpcError::NoRelays); + } + + let TradeListingSeriesParams { + listing_addr, + order_id, + include_listing, + include_dvm, + limit, + since, + until, + timeout_secs, + } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + + let addr = parse_listing_addr(&listing_addr)?; + let include_listing = include_listing.unwrap_or(true); + let include_dvm = include_dvm.unwrap_or(true); + + let listing = if include_listing { + fetch_latest_listing_event(&ctx.state.client, &addr, timeout_secs.unwrap_or(10)) + .await? + .as_ref() + .map(listing_view) + } else { + None + }; + + let dvm_events = if include_dvm { + fetch_dvm_events( + &ctx.state.client, + &addr, + &TRADE_LISTING_DVM_KINDS, + order_id.as_deref(), + None, + None, + since, + until, + limit, + timeout_secs.unwrap_or(10), + ) + .await? + } else { + Vec::new() + }; + + let orders = if include_dvm { + order_summaries(&dvm_events, &listing_addr) + } else { + Vec::<TradeListingOrderSummary>::new() + }; + + let series = TradeListingSeriesView { + listing, + dvm_events, + orders, + }; + + Ok::<TradeListingSeriesResponse, RpcError>(TradeListingSeriesResponse { series }) + })?; + Ok(()) +} diff --git a/src/rpc/domains/trade/listing/types.rs b/src/api/jsonrpc/methods/domains/trade/listing/types.rs diff --git a/src/api/jsonrpc/methods/domains/trade/mod.rs b/src/api/jsonrpc/methods/domains/trade/mod.rs @@ -0,0 +1,14 @@ +#![forbid(unsafe_code)] + +use anyhow::Result; +use jsonrpsee::server::RpcModule; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext}; + +pub mod listing; + +pub fn module(ctx: RpcContext, registry: MethodRegistry) -> Result<RpcModule<RpcContext>> { + let mut m = RpcModule::new(ctx.clone()); + m.merge(listing::module(ctx, registry)?)?; + Ok(m) +} diff --git a/src/api/jsonrpc/methods/events/listing/list.rs b/src/api/jsonrpc/methods/events/listing/list.rs @@ -0,0 +1,80 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::Deserialize; +use serde_json::{Value as JsonValue, json}; +use std::time::Duration; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; +use radroots_nostr::prelude::{ + radroots_nostr_parse_pubkeys, + RadrootsNostrFilter, + RadrootsNostrKind, +}; +use radroots_trade::listing::codec::listing_from_event_parts; + +#[derive(Debug, Default, Deserialize)] +struct ListListingParams { + #[serde(default)] + authors: Option<Vec<String>>, + #[serde(default)] + limit: Option<u64>, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("events.listing.list"); + m.register_async_method("events.listing.list", |params, ctx, _| async move { + if ctx.state.client.relays().await.is_empty() { + return Err(RpcError::NoRelays); + } + + let ListListingParams { authors, limit } = params.parse().unwrap_or_default(); + let limit = limit.unwrap_or(50).min(1000); + + let mut filter = RadrootsNostrFilter::new().limit((limit as u16).into()); + + let kinds: Vec<u32> = vec![30402]; + let kinds_conv = kinds + .into_iter() + .map(|k| RadrootsNostrKind::Custom(k as u16)) + .collect::<Vec<_>>(); + filter = filter.kinds(kinds_conv); + + if let Some(auths) = authors { + let pks = radroots_nostr_parse_pubkeys(&auths) + .map_err(|e| RpcError::InvalidParams(format!("invalid author: {e}")))?; + filter = filter.authors(pks); + } else { + filter = filter.author(ctx.state.pubkey); + } + + let events = ctx + .state + .client + .fetch_events(filter, Duration::from_secs(10)) + .await + .map_err(|e| RpcError::Other(format!("fetch failed: {e}")))?; + + let items = events + .into_iter() + .map(|ev| { + let tags: Vec<Vec<String>> = + ev.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + let listing = listing_from_event_parts(&tags, &ev.content).ok(); + + json!({ + "id": ev.id.to_string(), + "author": ev.pubkey.to_string(), + "created_at": ev.created_at.as_secs(), + "kind": ev.kind.as_u16() as u32, + "tags": tags, + "content": ev.content, + "sig": ev.sig.to_string(), + "listing": listing, + }) + }) + .collect::<Vec<_>>(); + + Ok::<JsonValue, RpcError>(json!({ "listings": items })) + })?; + Ok(()) +} diff --git a/src/api/jsonrpc/methods/events/listing/mod.rs b/src/api/jsonrpc/methods/events/listing/mod.rs @@ -0,0 +1,14 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext}; + +pub mod list; +pub mod publish; + +pub fn module(ctx: RpcContext, registry: MethodRegistry) -> Result<RpcModule<RpcContext>> { + let mut m = RpcModule::new(ctx); + list::register(&mut m, &registry)?; + publish::register(&mut m, &registry)?; + Ok(m) +} diff --git a/src/api/jsonrpc/methods/events/listing/publish.rs b/src/api/jsonrpc/methods/events/listing/publish.rs @@ -0,0 +1,49 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::Deserialize; +use serde_json::{Value as JsonValue, json}; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; +use radroots_events::listing::RadrootsListing; +use radroots_nostr::prelude::{radroots_nostr_build_event, radroots_nostr_send_event}; +use radroots_trade::listing::codec::listing_tags_build; + +#[derive(Debug, Deserialize)] +struct PublishListingParams { + listing: RadrootsListing, + #[serde(default)] + tags: Option<Vec<Vec<String>>>, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("events.listing.publish"); + m.register_async_method("events.listing.publish", |params, ctx, _| async move { + if ctx.state.client.relays().await.is_empty() { + return Err(RpcError::NoRelays); + } + + let PublishListingParams { listing, tags } = + params.parse().map_err(|e| RpcError::InvalidParams(e.to_string()))?; + + let content = serde_json::to_string(&listing) + .map_err(|e| RpcError::InvalidParams(format!("invalid listing json: {e}")))?; + let mut tag_slices = listing_tags_build(&listing) + .map_err(|e| RpcError::InvalidParams(format!("invalid listing tags: {e}")))?; + if let Some(extra_tags) = tags { + tag_slices.extend(extra_tags); + } + let builder = radroots_nostr_build_event(30402, content, tag_slices) + .map_err(|e| RpcError::Other(format!("failed to build listing event: {e}")))?; + + let out = radroots_nostr_send_event(&ctx.state.client, builder) + .await + .map_err(|e| RpcError::Other(format!("failed to publish listing: {e}")))?; + + Ok::<JsonValue, RpcError>(json!({ + "id": out.id().to_string(), + "sent": out.success.into_iter().map(|u| u.to_string()).collect::<Vec<_>>(), + "failed": out.failed.into_iter().map(|(u,e)| (u.to_string(), e.to_string())).collect::<Vec<_>>(), + })) + })?; + Ok(()) +} diff --git a/src/rpc/events/mod.rs b/src/api/jsonrpc/methods/events/mod.rs diff --git a/src/api/jsonrpc/methods/events/post/list.rs b/src/api/jsonrpc/methods/events/post/list.rs @@ -0,0 +1,71 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::Deserialize; +use serde_json::{Value as JsonValue, json}; +use std::time::Duration; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; +use radroots_nostr::prelude::{ + radroots_nostr_parse_pubkeys, + RadrootsNostrFilter, + RadrootsNostrKind, +}; + +#[derive(Debug, Default, Deserialize)] +struct ListProfilesParams { + #[serde(default)] + authors: Option<Vec<String>>, + #[serde(default)] + limit: Option<u64>, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("events.post.list"); + m.register_async_method("events.post.list", |params, ctx, _| async move { + if ctx.state.client.relays().await.is_empty() { + return Err(RpcError::NoRelays); + } + + let ListProfilesParams { authors, limit } = params.parse().unwrap_or_default(); + let limit = limit.unwrap_or(50); + + let mut filter = RadrootsNostrFilter::new() + .kind(RadrootsNostrKind::TextNote) + .limit(limit.try_into().unwrap()); + if let Some(auths) = authors { + let pks = radroots_nostr_parse_pubkeys(&auths) + .map_err(|e| RpcError::InvalidParams(format!("invalid author: {e}")))?; + filter = filter.authors(pks); + } else { + filter = filter.author(ctx.state.pubkey); + } + + let events = ctx + .state + .client + .fetch_events(filter, Duration::from_secs(10)) + .await + .map_err(|e| RpcError::Other(format!("fetch failed: {e}")))?; + + let items: Vec<JsonValue> = events + .into_iter() + .map(|ev| { + let tags: Vec<Vec<String>> = + ev.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + json!({ + "id": ev.id.to_string(), + "author": ev.pubkey.to_string(), + "created_at": ev.created_at.as_secs(), + "kind": ev.kind.as_u16() as u32, + "tags": tags, + "content": ev.content, + "sig": ev.sig.to_string(), + }) + }) + .collect(); + + Ok::<JsonValue, RpcError>(json!({ "Profiles": items })) + })?; + + Ok(()) +} diff --git a/src/api/jsonrpc/methods/events/post/mod.rs b/src/api/jsonrpc/methods/events/post/mod.rs @@ -0,0 +1,14 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext}; + +pub mod list; +pub mod publish; + +pub fn module(ctx: RpcContext, registry: MethodRegistry) -> Result<RpcModule<RpcContext>> { + let mut m = RpcModule::new(ctx); + list::register(&mut m, &registry)?; + publish::register(&mut m, &registry)?; + Ok(m) +} diff --git a/src/api/jsonrpc/methods/events/post/publish.rs b/src/api/jsonrpc/methods/events/post/publish.rs @@ -0,0 +1,55 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::Deserialize; +use serde_json::{Value as JsonValue, json}; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; +use radroots_nostr::prelude::{radroots_nostr_build_event, radroots_nostr_send_event}; + +#[derive(Debug, Deserialize)] +struct PublishProfileParams { + content: String, + #[serde(default)] + tags: Option<Vec<Vec<String>>>, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("events.post.publish"); + m.register_async_method("events.post.publish", |params, ctx, _| async move { + let relays = ctx.state.client.relays().await; + if relays.is_empty() { + return Err(RpcError::NoRelays); + } + + let PublishProfileParams { content, tags } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + + if content.trim().is_empty() { + return Err(RpcError::InvalidParams("content must not be empty".into())); + } + + let builder = radroots_nostr_build_event(1, content, tags.unwrap_or_default()) + .map_err(|e| RpcError::Other(format!("failed to build note: {e}")))?; + + let output = radroots_nostr_send_event(&ctx.state.client, builder) + .await + .map_err(|e| RpcError::Other(format!("failed to publish note: {e}")))?; + + let id_hex = output.id().to_string(); + let sent: Vec<String> = output.success.into_iter().map(|u| u.to_string()).collect(); + let failed: Vec<(String, String)> = output + .failed + .into_iter() + .map(|(u, e)| (u.to_string(), e.to_string())) + .collect(); + + Ok::<JsonValue, RpcError>(json!({ + "id": id_hex, + "sent": sent, + "failed": failed + })) + })?; + + Ok(()) +} diff --git a/src/api/jsonrpc/methods/events/profile/list.rs b/src/api/jsonrpc/methods/events/profile/list.rs @@ -0,0 +1,58 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde_json::{Value as JsonValue, json}; +use std::time::Duration; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; +use radroots_nostr::prelude::{ + radroots_nostr_fetch_metadata_for_author, + radroots_nostr_npub_string, +}; + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("events.profile.list"); + m.register_async_method("events.profile.list", |_params, ctx, _| async move { + if ctx.state.client.relays().await.is_empty() { + return Err(RpcError::NoRelays); + } + + let me_pk = ctx.state.pubkey; + + let latest = radroots_nostr_fetch_metadata_for_author(&ctx.state.client, me_pk, Duration::from_secs(10)) + .await + .map_err(|e| RpcError::Other(format!("metadata fetch failed: {e}")))?; + + let npub = radroots_nostr_npub_string(&me_pk) + .ok_or_else(|| RpcError::Other("bech32 encode failed".into()))?; + + let row = if let Some(ev) = latest { + let parsed: Option<serde_json::Value> = serde_json::from_str(&ev.content).ok(); + let profile: Option<radroots_events::profile::RadrootsProfile> = + serde_json::from_str(&ev.content).ok(); + + json!({ + "author_hex": me_pk.to_string(), + "author_npub": npub, + "event_id": ev.id.to_string(), + "created_at": ev.created_at.as_secs(), + "content": ev.content, + "metadata_json": parsed, + "radroots_profile": profile, + }) + } else { + json!({ + "author_hex": me_pk.to_string(), + "author_npub": npub, + "event_id": null, + "created_at": null, + "content": null, + "metadata_json": null, + "radroots_profile": null + }) + }; + + Ok::<JsonValue, RpcError>(json!({ "profiles": [row] })) + })?; + + Ok(()) +} diff --git a/src/api/jsonrpc/methods/events/profile/mod.rs b/src/api/jsonrpc/methods/events/profile/mod.rs @@ -0,0 +1,14 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext}; + +pub mod list; +pub mod publish; + +pub fn module(ctx: RpcContext, registry: MethodRegistry) -> Result<RpcModule<RpcContext>> { + let mut m = RpcModule::new(ctx); + list::register(&mut m, &registry)?; + publish::register(&mut m, &registry)?; + Ok(m) +} diff --git a/src/api/jsonrpc/methods/events/profile/publish.rs b/src/api/jsonrpc/methods/events/profile/publish.rs @@ -0,0 +1,55 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::Deserialize; +use serde_json::{Value as JsonValue, json}; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; + +use radroots_events::profile::RadrootsProfile; +use radroots_events_codec::profile::encode::to_metadata; +use radroots_nostr::prelude::{ + radroots_nostr_build_metadata_event, + radroots_nostr_send_event, +}; + +#[derive(Debug, Deserialize)] +struct PublishProfileParams { + profile: RadrootsProfile, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("events.profile.publish"); + m.register_async_method("events.profile.publish", |params, ctx, _| async move { + let relays = ctx.state.client.relays().await; + if relays.is_empty() { + return Err(RpcError::NoRelays); + } + + let PublishProfileParams { profile } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + + let metadata = to_metadata(&profile).map_err(|e| RpcError::InvalidParams(e.to_string()))?; + let builder = radroots_nostr_build_metadata_event(&metadata); + + let output = radroots_nostr_send_event(&ctx.state.client, builder) + .await + .map_err(|e| RpcError::Other(format!("failed to publish metadata: {e}")))?; + + let id_hex = output.id().to_string(); + let sent: Vec<String> = output.success.into_iter().map(|u| u.to_string()).collect(); + let failed: Vec<(String, String)> = output + .failed + .into_iter() + .map(|(u, e)| (u.to_string(), e.to_string())) + .collect(); + + Ok::<JsonValue, RpcError>(json!({ + "id": id_hex, + "sent": sent, + "failed": failed + })) + })?; + + Ok(()) +} diff --git a/src/api/jsonrpc/methods/mod.rs b/src/api/jsonrpc/methods/mod.rs @@ -0,0 +1,25 @@ +#![forbid(unsafe_code)] + +use anyhow::Result; +use jsonrpsee::server::RpcModule; + +use super::{context::RpcContext, registry::MethodRegistry}; + +pub mod domains; +pub mod events; +pub mod relays; +pub mod system; + +pub fn register_all( + root: &mut RpcModule<RpcContext>, + ctx: RpcContext, + registry: MethodRegistry, +) -> Result<()> { + root.merge(system::module(ctx.clone(), registry.clone())?)?; + root.merge(relays::module(ctx.clone(), registry.clone())?)?; + root.merge(events::profile::module(ctx.clone(), registry.clone())?)?; + root.merge(events::post::module(ctx.clone(), registry.clone())?)?; + root.merge(events::listing::module(ctx.clone(), registry.clone())?)?; + root.merge(domains::trade::module(ctx, registry)?)?; + Ok(()) +} diff --git a/src/api/jsonrpc/methods/relays/add.rs b/src/api/jsonrpc/methods/relays/add.rs @@ -0,0 +1,28 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use radroots_nostr::prelude::radroots_nostr_add_relay; +use serde::Deserialize; +use serde_json::{Value as JsonValue, json}; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; + +#[derive(Debug, Deserialize)] +struct AddParams { + url: String, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("relays.add"); + m.register_async_method("relays.add", |params, ctx, _| async move { + let AddParams { url } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + + radroots_nostr_add_relay(&ctx.state.client, &url) + .await + .map_err(|e| RpcError::AddRelay(url.clone(), e.to_string()))?; + + Ok::<JsonValue, RpcError>(json!({ "added": url })) + })?; + Ok(()) +} diff --git a/src/api/jsonrpc/methods/relays/connect.rs b/src/api/jsonrpc/methods/relays/connect.rs @@ -0,0 +1,43 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde_json::{Value as JsonValue, json}; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; + +use radroots_nostr::prelude::{radroots_nostr_connect, RadrootsNostrRelayStatus}; + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("relays.connect"); + m.register_async_method("relays.connect", |_p, ctx, _| async move { + let relays = ctx.state.client.relays().await; + if relays.is_empty() { + return Err(RpcError::NoRelays); + } + + let mut connected = 0usize; + let mut connecting = 0usize; + let mut disconnected = 0usize; + + for (_, r) in &relays { + match r.status() { + RadrootsNostrRelayStatus::Connected => connected += 1, + RadrootsNostrRelayStatus::Connecting => connecting += 1, + _ => disconnected += 1, + } + } + + let need_connect = disconnected > 0; + if need_connect { + let client = ctx.state.client.clone(); + tokio::spawn(async move { radroots_nostr_connect(&client).await }); + } + + Ok::<JsonValue, RpcError>(json!({ + "connected": connected, + "connecting": connecting, + "disconnected": disconnected, + "spawned_connect": need_connect + })) + })?; + Ok(()) +} diff --git a/src/api/jsonrpc/methods/relays/list.rs b/src/api/jsonrpc/methods/relays/list.rs @@ -0,0 +1,16 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde_json::{Value as JsonValue, json}; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("relays.list"); + m.register_async_method("relays.list", |_p, ctx, _| async move { + let relays = ctx.state.client.relays().await; + Ok::<JsonValue, RpcError>(json!( + relays.keys().map(|u| u.to_string()).collect::<Vec<_>>() + )) + })?; + Ok(()) +} diff --git a/src/api/jsonrpc/methods/relays/mod.rs b/src/api/jsonrpc/methods/relays/mod.rs @@ -0,0 +1,22 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext}; + +pub mod add; +pub mod connect; +pub mod list; +pub mod remove; +pub mod status; + +pub fn module(ctx: RpcContext, registry: MethodRegistry) -> Result<RpcModule<RpcContext>> { + let mut m = RpcModule::new(ctx); + + add::register(&mut m, &registry)?; + remove::register(&mut m, &registry)?; + list::register(&mut m, &registry)?; + status::register(&mut m, &registry)?; + connect::register(&mut m, &registry)?; + + Ok(m) +} diff --git a/src/api/jsonrpc/methods/relays/remove.rs b/src/api/jsonrpc/methods/relays/remove.rs @@ -0,0 +1,28 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use radroots_nostr::prelude::radroots_nostr_remove_relay; +use serde::Deserialize; +use serde_json::{Value as JsonValue, json}; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; + +#[derive(Debug, Deserialize)] +struct RemoveParams { + url: String, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("relays.remove"); + m.register_async_method("relays.remove", |params, ctx, _| async move { + let RemoveParams { url } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + + radroots_nostr_remove_relay(&ctx.state.client, &url) + .await + .map_err(|e| RpcError::Other(format!("failed to remove relay {url}: {e}")))?; + + Ok::<JsonValue, RpcError>(json!({ "removed": url })) + })?; + Ok(()) +} diff --git a/src/api/jsonrpc/methods/relays/status.rs b/src/api/jsonrpc/methods/relays/status.rs @@ -0,0 +1,57 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::Deserialize; +use serde_json::{Map as JsonMap, Value as JsonValue, json}; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; +use radroots_nostr::prelude::fetch_nip11; + +#[derive(Debug, Deserialize)] +struct StatusParams { + #[serde(default)] + include_nip11: bool, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("relays.status"); + m.register_async_method("relays.status", |params, ctx, _| async move { + let StatusParams { include_nip11 } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + + let relays = ctx.state.client.relays().await; + let mut out = Vec::with_capacity(relays.len()); + + for (relay_url, relay) in relays { + let url_str = relay_url.to_string(); + let status_str = format!("{}", relay.status()); + let parsed = reqwest::Url::parse(&url_str).ok(); + + let mut row = JsonMap::new(); + row.insert("url".into(), json!(url_str)); + row.insert("status".into(), json!(status_str)); + + if let Some(u) = &parsed { + row.insert("scheme".into(), json!(u.scheme())); + if let Some(h) = u.host_str() { + row.insert("host".into(), json!(h)); + row.insert("onion".into(), json!(h.ends_with(".onion"))); + } + if let Some(p) = u.port() { + row.insert("port".into(), json!(p)); + } + } + + if include_nip11 { + if let Some(doc) = fetch_nip11(row["url"].as_str().unwrap()).await { + row.insert("nip11".into(), json!(doc)); + } + } + + out.push(JsonValue::Object(row)); + } + + Ok::<JsonValue, RpcError>(json!(out)) + })?; + Ok(()) +} diff --git a/src/api/jsonrpc/methods/system.rs b/src/api/jsonrpc/methods/system.rs @@ -0,0 +1,27 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde_json::json; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext}; + +pub fn module(ctx: RpcContext, registry: MethodRegistry) -> Result<RpcModule<RpcContext>> { + let mut m = RpcModule::new(ctx); + + registry.track("system.ping"); + m.register_method("system.ping", |_p, _ctx, _| "pong")?; + + registry.track("system.get_info"); + m.register_method("system.get_info", |_p, ctx, _| { + let uptime = ctx.state.started.elapsed().as_secs(); + json!({ + "version": ctx.state.info.get("version"), + "build": ctx.state.info.get("build"), + "uptime_secs": uptime, + }) + })?; + + registry.track("system.help"); + m.register_method("system.help", |_p, ctx, _| ctx.methods.list())?; + + Ok(m) +} diff --git a/src/api/jsonrpc/mod.rs b/src/api/jsonrpc/mod.rs @@ -0,0 +1,36 @@ +#![forbid(unsafe_code)] + +use std::net::SocketAddr; + +use anyhow::Result; +use jsonrpsee::server::{RpcModule, ServerHandle}; + +use crate::config::RpcConfig; +use crate::radrootsd::Radrootsd; + +mod context; +mod error; +mod registry; +mod server; + +pub mod methods; + +pub use context::RpcContext; +pub use error::RpcError; +pub use registry::MethodRegistry; + +pub async fn start_rpc( + state: Radrootsd, + addr: SocketAddr, + rpc_cfg: &RpcConfig, +) -> Result<ServerHandle> { + let registry = MethodRegistry::default(); + let ctx = RpcContext::new(state, registry.clone()); + let server = server::build_server(addr, rpc_cfg).await?; + + let mut root = RpcModule::new(ctx.clone()); + methods::register_all(&mut root, ctx, registry)?; + + let handle = server.start(root); + Ok(handle) +} diff --git a/src/api/jsonrpc/registry.rs b/src/api/jsonrpc/registry.rs @@ -0,0 +1,23 @@ +#![forbid(unsafe_code)] + +use std::sync::{Arc, RwLock}; + +#[derive(Clone, Default)] +pub struct MethodRegistry { + inner: Arc<RwLock<Vec<String>>>, +} + +impl MethodRegistry { + pub fn track(&self, name: &'static str) { + let mut methods = self.inner.write().unwrap_or_else(|e| e.into_inner()); + if methods.iter().any(|entry| entry == name) { + return; + } + methods.push(name.to_string()); + methods.sort(); + } + + pub fn list(&self) -> Vec<String> { + self.inner.read().unwrap_or_else(|e| e.into_inner()).clone() + } +} diff --git a/src/api/jsonrpc/server.rs b/src/api/jsonrpc/server.rs @@ -0,0 +1,30 @@ +#![forbid(unsafe_code)] + +use std::net::SocketAddr; + +use anyhow::Result; +use jsonrpsee::server::{BatchRequestConfig, Server, ServerBuilder, ServerConfigBuilder}; + +use crate::config::RpcConfig; + +pub async fn build_server(addr: SocketAddr, rpc_cfg: &RpcConfig) -> Result<Server> { + let mut builder = ServerConfigBuilder::new() + .max_request_body_size(rpc_cfg.max_request_body_size) + .max_response_body_size(rpc_cfg.max_response_body_size) + .max_connections(rpc_cfg.max_connections) + .max_subscriptions_per_connection(rpc_cfg.max_subscriptions_per_connection) + .set_message_buffer_capacity(rpc_cfg.message_buffer_capacity); + + if let Some(limit) = rpc_cfg.batch_request_limit { + let cfg = if limit == 0 { + BatchRequestConfig::Disabled + } else { + BatchRequestConfig::Limit(limit) + }; + builder = builder.set_batch_request_config(cfg); + } + + let server_cfg = builder.build(); + let server = ServerBuilder::with_config(server_cfg).build(addr).await?; + Ok(server) +} diff --git a/src/api/mod.rs b/src/api/mod.rs @@ -0,0 +1,3 @@ +#![forbid(unsafe_code)] + +pub mod jsonrpc; diff --git a/src/config.rs b/src/config.rs @@ -1,13 +1,81 @@ use radroots_nostr::prelude::RadrootsNostrMetadata; use serde::{Deserialize, Serialize}; +fn default_rpc_addr() -> String { + "127.0.0.1:7070".to_string() +} + +fn default_max_request_body_size() -> u32 { + 10 * 1024 * 1024 +} + +fn default_max_response_body_size() -> u32 { + 10 * 1024 * 1024 +} + +fn default_max_connections() -> u32 { + 100 +} + +fn default_max_subscriptions_per_connection() -> u32 { + 1024 +} + +fn default_message_buffer_capacity() -> u32 { + 1024 +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct RpcConfig { + #[serde(default = "default_rpc_addr")] + pub addr: String, + #[serde(default = "default_max_request_body_size")] + pub max_request_body_size: u32, + #[serde(default = "default_max_response_body_size")] + pub max_response_body_size: u32, + #[serde(default = "default_max_connections")] + pub max_connections: u32, + #[serde(default = "default_max_subscriptions_per_connection")] + pub max_subscriptions_per_connection: u32, + #[serde(default = "default_message_buffer_capacity")] + pub message_buffer_capacity: u32, + #[serde(default)] + pub batch_request_limit: Option<u32>, +} + +impl Default for RpcConfig { + fn default() -> Self { + Self { + addr: default_rpc_addr(), + max_request_body_size: default_max_request_body_size(), + max_response_body_size: default_max_response_body_size(), + max_connections: default_max_connections(), + max_subscriptions_per_connection: default_max_subscriptions_per_connection(), + message_buffer_capacity: default_message_buffer_capacity(), + batch_request_limit: None, + } + } +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Configuration { pub logs_dir: String, - pub rpc_addr: String, + #[serde(default)] + pub rpc: RpcConfig, + #[serde(default)] + pub rpc_addr: Option<String>, + #[serde(default)] pub relays: Vec<String>, } +impl Configuration { + pub fn rpc_addr(&self) -> &str { + self.rpc_addr + .as_deref() + .unwrap_or(self.rpc.addr.as_str()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Settings { pub metadata: RadrootsNostrMetadata, diff --git a/src/lib.rs b/src/lib.rs @@ -1,7 +1,9 @@ +#![forbid(unsafe_code)] + +pub mod api; pub mod cli; pub mod config; pub mod radrootsd; -pub mod rpc; use anyhow::Result; @@ -10,13 +12,14 @@ use tracing::info; use crate::radrootsd::Radrootsd; use radroots_identity::RadrootsIdentity; +use radroots_nostr::prelude::radroots_nostr_publish_identity_profile; pub async fn run_radrootsd(settings: &config::Settings, args: &cli_args) -> Result<()> { let identity = RadrootsIdentity::load_or_generate( args.identity.as_ref(), args.allow_generate_identity, )?; - let keys = identity.into_keys(); + let keys = identity.keys().clone(); let radrootsd = Radrootsd::new(keys, settings.metadata.clone()); @@ -27,6 +30,7 @@ pub async fn run_radrootsd(settings: &config::Settings, args: &cli_args) -> Resu if !settings.config.relays.is_empty() { let client = radrootsd.client.clone(); let md = settings.metadata.clone(); + let identity = identity.clone(); let has_metadata = serde_json::to_value(&md) .ok() .and_then(|v| v.as_object().cloned()) @@ -35,7 +39,16 @@ pub async fn run_radrootsd(settings: &config::Settings, args: &cli_args) -> Resu tokio::spawn(async move { client.connect().await; - if has_metadata { + let profile_published = + match radroots_nostr_publish_identity_profile(&client, &identity).await { + Ok(Some(_)) => true, + Ok(None) => false, + Err(e) => { + tracing::warn!("Failed to publish identity profile: {e}"); + false + } + }; + if has_metadata && !profile_published { if let Err(e) = client.set_metadata(&md).await { tracing::warn!("Failed to publish metadata on startup: {e}"); } else { @@ -45,8 +58,8 @@ pub async fn run_radrootsd(settings: &config::Settings, args: &cli_args) -> Resu }); } - let addr: std::net::SocketAddr = settings.config.rpc_addr.parse()?; - let handle = rpc::start_rpc(radrootsd.clone(), addr).await?; + let addr: std::net::SocketAddr = settings.config.rpc_addr().parse()?; + let handle = api::jsonrpc::start_rpc(radrootsd.clone(), addr, &settings.config.rpc).await?; info!("JSON-RPC listening on {addr}"); let stop_handle = handle.clone(); diff --git a/src/main.rs b/src/main.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + use anyhow::{Context, Result}; use radrootsd::{cli_args, config, run_radrootsd}; use std::process::ExitCode; diff --git a/src/rpc/domains/trade/listing/dvm.rs b/src/rpc/domains/trade/listing/dvm.rs @@ -1,94 +0,0 @@ -#![forbid(unsafe_code)] - -use anyhow::Result; -use jsonrpsee::server::RpcModule; -use serde::{Deserialize, Serialize}; - -use crate::{radrootsd::Radrootsd, rpc::RpcError}; -use radroots_nostr::prelude::radroots_nostr_parse_pubkeys; -use radroots_trade::listing::dvm_kinds::TRADE_LISTING_DVM_KINDS; - -use super::helpers::{fetch_dvm_events, parse_listing_addr}; -use super::types::DvmEventView; - -#[derive(Debug, Deserialize)] -struct TradeListingDvmListParams { - listing_addr: String, - #[serde(default)] - order_id: Option<String>, - #[serde(default)] - authors: Option<Vec<String>>, - #[serde(default)] - recipients: Option<Vec<String>>, - #[serde(default)] - kinds: Option<Vec<u16>>, - #[serde(default)] - limit: Option<u64>, - #[serde(default)] - since: Option<u64>, - #[serde(default)] - until: Option<u64>, - #[serde(default)] - timeout_secs: Option<u64>, -} - -#[derive(Clone, Debug, Serialize)] -struct TradeListingDvmListResponse { - events: Vec<DvmEventView>, -} - -pub fn register(m: &mut RpcModule<Radrootsd>) -> Result<()> { - m.register_async_method("trade.listing.dvm.list", |params, ctx, _| async move { - if ctx.client.relays().await.is_empty() { - return Err(RpcError::NoRelays); - } - - let TradeListingDvmListParams { - listing_addr, - order_id, - authors, - recipients, - kinds, - limit, - since, - until, - timeout_secs, - } = params - .parse() - .map_err(|e| RpcError::InvalidParams(e.to_string()))?; - - let addr = parse_listing_addr(&listing_addr)?; - let kinds = kinds.unwrap_or_else(|| TRADE_LISTING_DVM_KINDS.to_vec()); - let authors = match authors { - Some(authors) => Some( - radroots_nostr_parse_pubkeys(&authors) - .map_err(|e| RpcError::InvalidParams(format!("invalid author: {e}")))?, - ), - None => None, - }; - let recipients = match recipients { - Some(recipients) => Some( - radroots_nostr_parse_pubkeys(&recipients) - .map_err(|e| RpcError::InvalidParams(format!("invalid recipient: {e}")))?, - ), - None => None, - }; - - let events = fetch_dvm_events( - &ctx.client, - &addr, - &kinds, - order_id.as_deref(), - authors.as_deref(), - recipients.as_deref(), - since, - until, - limit, - timeout_secs.unwrap_or(10), - ) - .await?; - - Ok::<TradeListingDvmListResponse, RpcError>(TradeListingDvmListResponse { events }) - })?; - Ok(()) -} diff --git a/src/rpc/domains/trade/listing/get.rs b/src/rpc/domains/trade/listing/get.rs @@ -1,42 +0,0 @@ -#![forbid(unsafe_code)] - -use anyhow::Result; -use jsonrpsee::server::RpcModule; -use serde::{Deserialize, Serialize}; -use crate::{radrootsd::Radrootsd, rpc::RpcError}; - -use super::helpers::{fetch_latest_listing_event, listing_view, parse_listing_addr}; -use super::types::ListingEventView; - -#[derive(Debug, Deserialize)] -struct TradeListingGetParams { - listing_addr: String, - #[serde(default)] - timeout_secs: Option<u64>, -} - -#[derive(Clone, Debug, Serialize)] -struct TradeListingGetResponse { - listing: Option<ListingEventView>, -} - -pub fn register(m: &mut RpcModule<Radrootsd>) -> Result<()> { - m.register_async_method("trade.listing.get", |params, ctx, _| async move { - if ctx.client.relays().await.is_empty() { - return Err(RpcError::NoRelays); - } - - let TradeListingGetParams { - listing_addr, - timeout_secs, - } = params - .parse() - .map_err(|e| RpcError::InvalidParams(e.to_string()))?; - - let addr = parse_listing_addr(&listing_addr)?; - let latest = fetch_latest_listing_event(&ctx.client, &addr, timeout_secs.unwrap_or(10)).await?; - let listing = latest.as_ref().map(listing_view); - Ok::<TradeListingGetResponse, RpcError>(TradeListingGetResponse { listing }) - })?; - Ok(()) -} diff --git a/src/rpc/domains/trade/listing/helpers.rs b/src/rpc/domains/trade/listing/helpers.rs @@ -1,231 +0,0 @@ -#![forbid(unsafe_code)] - -use std::collections::HashMap; -use std::time::Duration; - -use radroots_nostr::prelude::{ - radroots_nostr_parse_pubkey, - RadrootsNostrClient, - RadrootsNostrCoordinate, - RadrootsNostrEvent, - RadrootsNostrFilter, - RadrootsNostrKind, - RadrootsNostrPublicKey, - RadrootsNostrTimestamp, -}; -use radroots_trade::listing::{ - codec::listing_from_event_parts, - dvm::{TradeListingAddress, TradeListingEnvelope}, -}; - -use crate::rpc::domains::trade::listing::types::{ - DvmEventView, ListingEventView, NostrEventView, TradeListingOrderSummary, -}; -use crate::rpc::RpcError; - -pub(crate) const LISTING_KIND: u16 = 30402; - -pub(crate) fn event_tags(event: &RadrootsNostrEvent) -> Vec<Vec<String>> { - event.tags.iter().map(|t| t.as_slice().to_vec()).collect() -} - -pub(crate) fn event_view(event: &RadrootsNostrEvent) -> NostrEventView { - NostrEventView { - id: event.id.to_string(), - author: event.pubkey.to_string(), - created_at: event.created_at.as_u64(), - kind: event.kind.as_u16() as u32, - tags: event_tags(event), - content: event.content.clone(), - sig: event.sig.to_string(), - } -} - -pub(crate) fn listing_view(event: &RadrootsNostrEvent) -> ListingEventView { - let tags = event_tags(event); - let listing = listing_from_event_parts(&tags, &event.content).ok(); - ListingEventView { - event: event_view(event), - listing, - } -} - -pub(crate) fn parse_listing_addr(listing_addr: &str) -> Result<TradeListingAddress, RpcError> { - let addr = TradeListingAddress::parse(listing_addr) - .map_err(|_| RpcError::InvalidParams("invalid listing_addr".to_string()))?; - if addr.kind != LISTING_KIND { - return Err(RpcError::InvalidParams("unsupported listing kind".to_string())); - } - Ok(addr) -} - -pub(crate) fn listing_filter(addr: &TradeListingAddress) -> Result<RadrootsNostrFilter, RpcError> { - let author = radroots_nostr_parse_pubkey(&addr.seller_pubkey) - .map_err(|e| RpcError::InvalidParams(format!("invalid listing author: {e}")))?; - Ok(RadrootsNostrFilter::new() - .kind(RadrootsNostrKind::Custom(addr.kind)) - .author(author) - .identifier(addr.listing_id.clone())) -} - -pub(crate) async fn fetch_latest_listing_event( - client: &RadrootsNostrClient, - listing_addr: &TradeListingAddress, - timeout_secs: u64, -) -> Result<Option<RadrootsNostrEvent>, RpcError> { - let mut filter = listing_filter(listing_addr)?; - filter = filter.limit(25); - let events = client - .fetch_events(filter, Duration::from_secs(timeout_secs)) - .await - .map_err(|e| RpcError::Other(format!("fetch failed: {e}")))?; - let mut latest: Option<RadrootsNostrEvent> = None; - for event in events { - match &latest { - Some(cur) if event.created_at <= cur.created_at => {} - _ => latest = Some(event), - } - } - Ok(latest) -} - -pub(crate) fn dvm_filter( - listing_addr: &TradeListingAddress, - kinds: &[u16], -) -> Result<RadrootsNostrFilter, RpcError> { - let author = radroots_nostr_parse_pubkey(&listing_addr.seller_pubkey) - .map_err(|e| RpcError::InvalidParams(format!("invalid listing author: {e}")))?; - let coordinate = RadrootsNostrCoordinate::new( - RadrootsNostrKind::Custom(listing_addr.kind), - author, - ) - .identifier(listing_addr.listing_id.clone()); - let kinds = kinds - .iter() - .map(|kind| RadrootsNostrKind::Custom(*kind)) - .collect::<Vec<_>>(); - Ok(RadrootsNostrFilter::new() - .kinds(kinds) - .coordinate(&coordinate)) -} - -pub(crate) fn dvm_event_view(event: &RadrootsNostrEvent) -> DvmEventView { - let envelope = serde_json::from_str::<TradeListingEnvelope<serde_json::Value>>(&event.content) - .ok(); - let envelope_error = envelope - .as_ref() - .and_then(|env| env.validate().err()) - .map(|err| err.to_string()) - .or_else(|| { - if envelope.is_some() { - None - } else { - Some("invalid envelope json".to_string()) - } - }); - DvmEventView { - event: event_view(event), - envelope, - envelope_error, - } -} - -pub(crate) async fn fetch_dvm_events( - client: &RadrootsNostrClient, - listing_addr: &TradeListingAddress, - kinds: &[u16], - order_id: Option<&str>, - authors: Option<&[RadrootsNostrPublicKey]>, - recipients: Option<&[RadrootsNostrPublicKey]>, - since: Option<u64>, - until: Option<u64>, - limit: Option<u64>, - timeout_secs: u64, -) -> Result<Vec<DvmEventView>, RpcError> { - let mut filter = dvm_filter(listing_addr, kinds)?; - - if let Some(order_id) = order_id { - filter = filter.identifier(order_id); - } - if let Some(authors) = authors { - filter = filter.authors(authors.to_vec()); - } - if let Some(recipients) = recipients { - filter = filter.pubkeys(recipients.to_vec()); - } - if let Some(since) = since { - filter = filter.since(RadrootsNostrTimestamp::from_secs(since)); - } - if let Some(until) = until { - filter = filter.until(RadrootsNostrTimestamp::from_secs(until)); - } - if let Some(limit) = limit { - filter = filter.limit(limit.min(1000) as usize); - } - - let events = client - .fetch_events(filter, Duration::from_secs(timeout_secs)) - .await - .map_err(|e| RpcError::Other(format!("fetch failed: {e}")))?; - - let mut out = events - .into_iter() - .map(|event| dvm_event_view(&event)) - .collect::<Vec<_>>(); - out.sort_by(|a, b| a.event.created_at.cmp(&b.event.created_at)); - Ok(out) -} - -pub(crate) fn order_id_from_event(event: &DvmEventView) -> Option<String> { - if let Some(envelope) = &event.envelope { - if let Some(order_id) = &envelope.order_id { - return Some(order_id.clone()); - } - } - event - .event - .tags - .iter() - .find_map(|tag| match tag.get(0).map(String::as_str) { - Some("d") => tag.get(1).cloned(), - _ => None, - }) -} - -pub(crate) fn order_summaries( - events: &[DvmEventView], - listing_addr: &str, -) -> Vec<TradeListingOrderSummary> { - let mut summary_map: HashMap<String, TradeListingOrderSummary> = HashMap::new(); - - for event in events { - let order_id = match order_id_from_event(event) { - Some(id) => id, - None => continue, - }; - let entry = summary_map.entry(order_id.clone()).or_insert_with(|| { - TradeListingOrderSummary { - order_id, - listing_addr: listing_addr.to_string(), - event_count: 0, - first_seen_at: event.event.created_at, - last_seen_at: event.event.created_at, - last_event_id: event.event.id.clone(), - last_event_kind: event.event.kind, - } - }); - entry.event_count += 1; - if event.event.created_at < entry.first_seen_at { - entry.first_seen_at = event.event.created_at; - } - if event.event.created_at >= entry.last_seen_at { - entry.last_seen_at = event.event.created_at; - entry.last_event_id = event.event.id.clone(); - entry.last_event_kind = event.event.kind; - } - } - - let mut summaries: Vec<TradeListingOrderSummary> = summary_map.into_values().collect(); - summaries.sort_by(|a, b| b.last_seen_at.cmp(&a.last_seen_at)); - summaries -} diff --git a/src/rpc/domains/trade/listing/list.rs b/src/rpc/domains/trade/listing/list.rs @@ -1,80 +0,0 @@ -#![forbid(unsafe_code)] - -use anyhow::Result; -use jsonrpsee::server::RpcModule; -use serde::{Deserialize, Serialize}; -use std::time::Duration; - -use crate::{radrootsd::Radrootsd, rpc::RpcError}; -use radroots_nostr::prelude::{ - radroots_nostr_parse_pubkeys, - RadrootsNostrFilter, - RadrootsNostrKind, - RadrootsNostrTimestamp, -}; - -use super::helpers::{listing_view, LISTING_KIND}; -use super::types::ListingEventView; - -#[derive(Debug, Default, Deserialize)] -struct TradeListingListParams { - #[serde(default)] - authors: Option<Vec<String>>, - #[serde(default)] - limit: Option<u64>, - #[serde(default)] - since: Option<u64>, - #[serde(default)] - until: Option<u64>, -} - -#[derive(Clone, Debug, Serialize)] -struct TradeListingListResponse { - listings: Vec<ListingEventView>, -} - -pub fn register(m: &mut RpcModule<Radrootsd>) -> Result<()> { - m.register_async_method("trade.listing.list", |params, ctx, _| async move { - if ctx.client.relays().await.is_empty() { - return Err(RpcError::NoRelays); - } - - let TradeListingListParams { - authors, - limit, - since, - until, - } = params.parse().unwrap_or_default(); - - let limit = limit.unwrap_or(50).min(1000) as usize; - - let mut filter = RadrootsNostrFilter::new() - .kind(RadrootsNostrKind::Custom(LISTING_KIND)) - .limit(limit); - if let Some(authors) = authors { - let pks = radroots_nostr_parse_pubkeys(&authors) - .map_err(|e| RpcError::InvalidParams(format!("invalid author: {e}")))?; - filter = filter.authors(pks); - } else { - filter = filter.author(ctx.pubkey); - } - if let Some(since) = since { - filter = filter.since(RadrootsNostrTimestamp::from_secs(since)); - } - if let Some(until) = until { - filter = filter.until(RadrootsNostrTimestamp::from_secs(until)); - } - - let events = ctx - .client - .fetch_events(filter, Duration::from_secs(10)) - .await - .map_err(|e| RpcError::Other(format!("fetch failed: {e}")))?; - - let mut listings = events.into_iter().map(|ev| listing_view(&ev)).collect::<Vec<_>>(); - listings.sort_by(|a, b| b.event.created_at.cmp(&a.event.created_at)); - - Ok::<TradeListingListResponse, RpcError>(TradeListingListResponse { listings }) - })?; - Ok(()) -} diff --git a/src/rpc/domains/trade/listing/mod.rs b/src/rpc/domains/trade/listing/mod.rs @@ -1,25 +0,0 @@ -#![forbid(unsafe_code)] - -use anyhow::Result; -use jsonrpsee::server::RpcModule; - -use crate::radrootsd::Radrootsd; - -pub mod dvm; -pub mod get; -pub mod list; -pub mod orders; -pub mod series; - -mod helpers; -mod types; - -pub fn module(radrootsd: Radrootsd) -> Result<RpcModule<Radrootsd>> { - let mut m = RpcModule::new(radrootsd); - get::register(&mut m)?; - list::register(&mut m)?; - dvm::register(&mut m)?; - series::register(&mut m)?; - orders::register(&mut m)?; - Ok(m) -} diff --git a/src/rpc/domains/trade/listing/orders.rs b/src/rpc/domains/trade/listing/orders.rs @@ -1,93 +0,0 @@ -#![forbid(unsafe_code)] - -use anyhow::Result; -use jsonrpsee::server::RpcModule; -use serde::{Deserialize, Serialize}; - -use crate::{radrootsd::Radrootsd, rpc::RpcError}; -use radroots_nostr::prelude::radroots_nostr_parse_pubkeys; -use radroots_trade::listing::dvm_kinds::TRADE_LISTING_DVM_KINDS; - -use super::helpers::{fetch_dvm_events, order_summaries, parse_listing_addr}; -use super::types::TradeListingOrderSummary; - -#[derive(Debug, Deserialize)] -struct TradeListingOrdersParams { - listing_addr: String, - #[serde(default)] - authors: Option<Vec<String>>, - #[serde(default)] - recipients: Option<Vec<String>>, - #[serde(default)] - kinds: Option<Vec<u16>>, - #[serde(default)] - limit: Option<u64>, - #[serde(default)] - since: Option<u64>, - #[serde(default)] - until: Option<u64>, - #[serde(default)] - timeout_secs: Option<u64>, -} - -#[derive(Clone, Debug, Serialize)] -struct TradeListingOrdersResponse { - orders: Vec<TradeListingOrderSummary>, -} - -pub fn register(m: &mut RpcModule<Radrootsd>) -> Result<()> { - m.register_async_method("trade.listing.orders.list", |params, ctx, _| async move { - if ctx.client.relays().await.is_empty() { - return Err(RpcError::NoRelays); - } - - let TradeListingOrdersParams { - listing_addr, - authors, - recipients, - kinds, - limit, - since, - until, - timeout_secs, - } = params - .parse() - .map_err(|e| RpcError::InvalidParams(e.to_string()))?; - - let addr = parse_listing_addr(&listing_addr)?; - let kinds = kinds.unwrap_or_else(|| TRADE_LISTING_DVM_KINDS.to_vec()); - let authors = match authors { - Some(authors) => Some( - radroots_nostr_parse_pubkeys(&authors) - .map_err(|e| RpcError::InvalidParams(format!("invalid author: {e}")))?, - ), - None => None, - }; - let recipients = match recipients { - Some(recipients) => Some( - radroots_nostr_parse_pubkeys(&recipients) - .map_err(|e| RpcError::InvalidParams(format!("invalid recipient: {e}")))?, - ), - None => None, - }; - - let events = fetch_dvm_events( - &ctx.client, - &addr, - &kinds, - None, - authors.as_deref(), - recipients.as_deref(), - since, - until, - limit, - timeout_secs.unwrap_or(10), - ) - .await?; - - let orders = order_summaries(&events, &listing_addr); - - Ok::<TradeListingOrdersResponse, RpcError>(TradeListingOrdersResponse { orders }) - })?; - Ok(()) -} diff --git a/src/rpc/domains/trade/listing/series.rs b/src/rpc/domains/trade/listing/series.rs @@ -1,103 +0,0 @@ -#![forbid(unsafe_code)] - -use anyhow::Result; -use jsonrpsee::server::RpcModule; -use serde::{Deserialize, Serialize}; -use crate::{radrootsd::Radrootsd, rpc::RpcError}; -use radroots_trade::listing::dvm_kinds::TRADE_LISTING_DVM_KINDS; - -use super::helpers::{ - fetch_dvm_events, fetch_latest_listing_event, listing_view, order_summaries, parse_listing_addr, -}; -use super::types::{TradeListingOrderSummary, TradeListingSeriesView}; - -#[derive(Debug, Deserialize)] -struct TradeListingSeriesParams { - listing_addr: String, - #[serde(default)] - order_id: Option<String>, - #[serde(default)] - include_listing: Option<bool>, - #[serde(default)] - include_dvm: Option<bool>, - #[serde(default)] - limit: Option<u64>, - #[serde(default)] - since: Option<u64>, - #[serde(default)] - until: Option<u64>, - #[serde(default)] - timeout_secs: Option<u64>, -} - -#[derive(Clone, Debug, Serialize)] -struct TradeListingSeriesResponse { - series: TradeListingSeriesView, -} - -pub fn register(m: &mut RpcModule<Radrootsd>) -> Result<()> { - m.register_async_method("trade.listing.series.get", |params, ctx, _| async move { - if ctx.client.relays().await.is_empty() { - return Err(RpcError::NoRelays); - } - - let TradeListingSeriesParams { - listing_addr, - order_id, - include_listing, - include_dvm, - limit, - since, - until, - timeout_secs, - } = params - .parse() - .map_err(|e| RpcError::InvalidParams(e.to_string()))?; - - let addr = parse_listing_addr(&listing_addr)?; - let include_listing = include_listing.unwrap_or(true); - let include_dvm = include_dvm.unwrap_or(true); - - let listing = if include_listing { - fetch_latest_listing_event(&ctx.client, &addr, timeout_secs.unwrap_or(10)) - .await? - .as_ref() - .map(listing_view) - } else { - None - }; - - let dvm_events = if include_dvm { - fetch_dvm_events( - &ctx.client, - &addr, - &TRADE_LISTING_DVM_KINDS, - order_id.as_deref(), - None, - None, - since, - until, - limit, - timeout_secs.unwrap_or(10), - ) - .await? - } else { - Vec::new() - }; - - let orders = if include_dvm { - order_summaries(&dvm_events, &listing_addr) - } else { - Vec::<TradeListingOrderSummary>::new() - }; - - let series = TradeListingSeriesView { - listing, - dvm_events, - orders, - }; - - Ok::<TradeListingSeriesResponse, RpcError>(TradeListingSeriesResponse { series }) - })?; - Ok(()) -} diff --git a/src/rpc/domains/trade/mod.rs b/src/rpc/domains/trade/mod.rs @@ -1,14 +0,0 @@ -#![forbid(unsafe_code)] - -use anyhow::Result; -use jsonrpsee::server::RpcModule; - -use crate::radrootsd::Radrootsd; - -pub mod listing; - -pub fn module(radrootsd: Radrootsd) -> Result<RpcModule<Radrootsd>> { - let mut m = RpcModule::new(radrootsd.clone()); - m.merge(listing::module(radrootsd)?)?; - Ok(m) -} diff --git a/src/rpc/error.rs b/src/rpc/error.rs @@ -1,28 +0,0 @@ -use jsonrpsee::types::{ErrorObject, ErrorObjectOwned}; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum RpcError { - #[error("failed to add relay {0}: {1}")] - AddRelay(String, String), - #[error("no relays configured; call relays.add first")] - NoRelays, - #[error("invalid params: {0}")] - InvalidParams(String), - #[error("method not found: {0}")] - MethodNotFound(String), - #[error("{0}")] - Other(String), -} - -impl From<RpcError> for ErrorObjectOwned { - fn from(err: RpcError) -> Self { - match err { - RpcError::InvalidParams(msg) => ErrorObject::owned(-32602, msg, None::<()>), - RpcError::MethodNotFound(name) => { - ErrorObject::owned(-32601, format!("method not found: {name}"), None::<()>) - } - other => ErrorObject::owned(-32000, other.to_string(), None::<()>), - } - } -} diff --git a/src/rpc/events/listing/list.rs b/src/rpc/events/listing/list.rs @@ -1,78 +0,0 @@ -use anyhow::Result; -use jsonrpsee::server::RpcModule; -use serde::Deserialize; -use serde_json::{Value as JsonValue, json}; -use std::time::Duration; - -use crate::{radrootsd::Radrootsd, rpc::RpcError}; -use radroots_nostr::prelude::{ - radroots_nostr_parse_pubkeys, - RadrootsNostrFilter, - RadrootsNostrKind, -}; -use radroots_trade::listing::codec::listing_from_event_parts; - -#[derive(Debug, Default, Deserialize)] -struct ListListingParams { - #[serde(default)] - authors: Option<Vec<String>>, - #[serde(default)] - limit: Option<u64>, -} - -pub fn register(m: &mut RpcModule<Radrootsd>) -> Result<()> { - m.register_async_method("events.listing.list", |params, ctx, _| async move { - if ctx.client.relays().await.is_empty() { - return Err(RpcError::NoRelays); - } - - let ListListingParams { authors, limit } = params.parse().unwrap_or_default(); - let limit = limit.unwrap_or(50).min(1000); - - let mut filter = RadrootsNostrFilter::new().limit((limit as u16).into()); - - let kinds: Vec<u32> = vec![30402]; - let kinds_conv = kinds - .into_iter() - .map(|k| RadrootsNostrKind::Custom(k as u16)) - .collect::<Vec<_>>(); - filter = filter.kinds(kinds_conv); - - if let Some(auths) = authors { - let pks = radroots_nostr_parse_pubkeys(&auths) - .map_err(|e| RpcError::InvalidParams(format!("invalid author: {e}")))?; - filter = filter.authors(pks); - } else { - filter = filter.author(ctx.pubkey); - } - - let events = ctx - .client - .fetch_events(filter, Duration::from_secs(10)) - .await - .map_err(|e| RpcError::Other(format!("fetch failed: {e}")))?; - - let items = events - .into_iter() - .map(|ev| { - let tags: Vec<Vec<String>> = - ev.tags.iter().map(|t| t.as_slice().to_vec()).collect(); - let listing = listing_from_event_parts(&tags, &ev.content).ok(); - - json!({ - "id": ev.id.to_string(), - "author": ev.pubkey.to_string(), - "created_at": ev.created_at.as_u64(), - "kind": ev.kind.as_u16() as u32, - "tags": tags, - "content": ev.content, - "sig": ev.sig.to_string(), - "listing": listing, - }) - }) - .collect::<Vec<_>>(); - - Ok::<JsonValue, RpcError>(json!({ "listings": items })) - })?; - Ok(()) -} diff --git a/src/rpc/events/listing/mod.rs b/src/rpc/events/listing/mod.rs @@ -1,14 +0,0 @@ -use anyhow::Result; -use jsonrpsee::server::RpcModule; - -use crate::radrootsd::Radrootsd; - -pub mod list; -pub mod publish; - -pub fn module(radrootsd: Radrootsd) -> Result<RpcModule<Radrootsd>> { - let mut m = RpcModule::new(radrootsd); - list::register(&mut m)?; - publish::register(&mut m)?; - Ok(m) -} diff --git a/src/rpc/events/listing/publish.rs b/src/rpc/events/listing/publish.rs @@ -1,48 +0,0 @@ -use anyhow::Result; -use jsonrpsee::server::RpcModule; -use serde::Deserialize; -use serde_json::{Value as JsonValue, json}; - -use crate::{radrootsd::Radrootsd, rpc::RpcError}; -use radroots_events::listing::RadrootsListing; -use radroots_nostr::prelude::{radroots_nostr_build_event, radroots_nostr_send_event}; -use radroots_trade::listing::codec::listing_tags_build; - -#[derive(Debug, Deserialize)] -struct PublishListingParams { - listing: RadrootsListing, - #[serde(default)] - tags: Option<Vec<Vec<String>>>, -} - -pub fn register(m: &mut RpcModule<Radrootsd>) -> Result<()> { - m.register_async_method("events.listing.publish", |params, ctx, _| async move { - if ctx.client.relays().await.is_empty() { - return Err(RpcError::NoRelays); - } - - let PublishListingParams { listing, tags } = - params.parse().map_err(|e| RpcError::InvalidParams(e.to_string()))?; - - let content = serde_json::to_string(&listing) - .map_err(|e| RpcError::InvalidParams(format!("invalid listing json: {e}")))?; - let mut tag_slices = listing_tags_build(&listing) - .map_err(|e| RpcError::InvalidParams(format!("invalid listing tags: {e}")))?; - if let Some(extra_tags) = tags { - tag_slices.extend(extra_tags); - } - let builder = radroots_nostr_build_event(30402, content, tag_slices) - .map_err(|e| RpcError::Other(format!("failed to build listing event: {e}")))?; - - let out = radroots_nostr_send_event(&ctx.client, builder) - .await - .map_err(|e| RpcError::Other(format!("failed to publish listing: {e}")))?; - - Ok::<JsonValue, RpcError>(json!({ - "id": out.id().to_string(), - "sent": out.success.into_iter().map(|u| u.to_string()).collect::<Vec<_>>(), - "failed": out.failed.into_iter().map(|(u,e)| (u.to_string(), e.to_string())).collect::<Vec<_>>(), - })) - })?; - Ok(()) -} diff --git a/src/rpc/events/post/list.rs b/src/rpc/events/post/list.rs @@ -1,69 +0,0 @@ -use anyhow::Result; -use jsonrpsee::server::RpcModule; -use serde::Deserialize; -use serde_json::{Value as JsonValue, json}; -use std::time::Duration; - -use crate::{radrootsd::Radrootsd, rpc::RpcError}; -use radroots_nostr::prelude::{ - radroots_nostr_parse_pubkeys, - RadrootsNostrFilter, - RadrootsNostrKind, -}; - -#[derive(Debug, Default, Deserialize)] -struct ListProfilesParams { - #[serde(default)] - authors: Option<Vec<String>>, - #[serde(default)] - limit: Option<u64>, -} - -pub fn register(m: &mut RpcModule<Radrootsd>) -> Result<()> { - m.register_async_method("events.post.list", |params, ctx, _| async move { - if ctx.client.relays().await.is_empty() { - return Err(RpcError::NoRelays); - } - - let ListProfilesParams { authors, limit } = params.parse().unwrap_or_default(); - let limit = limit.unwrap_or(50); - - let mut filter = RadrootsNostrFilter::new() - .kind(RadrootsNostrKind::TextNote) - .limit(limit.try_into().unwrap()); - if let Some(auths) = authors { - let pks = radroots_nostr_parse_pubkeys(&auths) - .map_err(|e| RpcError::InvalidParams(format!("invalid author: {e}")))?; - filter = filter.authors(pks); - } else { - filter = filter.author(ctx.pubkey); - } - - let events = ctx - .client - .fetch_events(filter, Duration::from_secs(10)) - .await - .map_err(|e| RpcError::Other(format!("fetch failed: {e}")))?; - - let items: Vec<JsonValue> = events - .into_iter() - .map(|ev| { - let tags: Vec<Vec<String>> = - ev.tags.iter().map(|t| t.as_slice().to_vec()).collect(); - json!({ - "id": ev.id.to_string(), - "author": ev.pubkey.to_string(), - "created_at": ev.created_at.as_u64(), - "kind": ev.kind.as_u16() as u32, - "tags": tags, - "content": ev.content, - "sig": ev.sig.to_string(), - }) - }) - .collect(); - - Ok::<JsonValue, RpcError>(json!({ "Profiles": items })) - })?; - - Ok(()) -} diff --git a/src/rpc/events/post/mod.rs b/src/rpc/events/post/mod.rs @@ -1,14 +0,0 @@ -use anyhow::Result; -use jsonrpsee::server::RpcModule; - -use crate::radrootsd::Radrootsd; - -pub mod list; -pub mod publish; - -pub fn module(radrootsd: Radrootsd) -> Result<RpcModule<Radrootsd>> { - let mut m = RpcModule::new(radrootsd); - list::register(&mut m)?; - publish::register(&mut m)?; - Ok(m) -} diff --git a/src/rpc/events/post/publish.rs b/src/rpc/events/post/publish.rs @@ -1,54 +0,0 @@ -use anyhow::Result; -use jsonrpsee::server::RpcModule; -use serde::Deserialize; -use serde_json::{Value as JsonValue, json}; - -use crate::{radrootsd::Radrootsd, rpc::RpcError}; -use radroots_nostr::prelude::{radroots_nostr_build_event, radroots_nostr_send_event}; - -#[derive(Debug, Deserialize)] -struct PublishProfileParams { - content: String, - #[serde(default)] - tags: Option<Vec<Vec<String>>>, -} - -pub fn register(m: &mut RpcModule<Radrootsd>) -> Result<()> { - m.register_async_method("events.post.publish", |params, ctx, _| async move { - let relays = ctx.client.relays().await; - if relays.is_empty() { - return Err(RpcError::NoRelays); - } - - let PublishProfileParams { content, tags } = params - .parse() - .map_err(|e| RpcError::InvalidParams(e.to_string()))?; - - if content.trim().is_empty() { - return Err(RpcError::InvalidParams("content must not be empty".into())); - } - - let builder = radroots_nostr_build_event(1, content, tags.unwrap_or_default()) - .map_err(|e| RpcError::Other(format!("failed to build note: {e}")))?; - - let output = radroots_nostr_send_event(&ctx.client, builder) - .await - .map_err(|e| RpcError::Other(format!("failed to publish note: {e}")))?; - - let id_hex = output.id().to_string(); - let sent: Vec<String> = output.success.into_iter().map(|u| u.to_string()).collect(); - let failed: Vec<(String, String)> = output - .failed - .into_iter() - .map(|(u, e)| (u.to_string(), e.to_string())) - .collect(); - - Ok::<JsonValue, RpcError>(json!({ - "id": id_hex, - "sent": sent, - "failed": failed - })) - })?; - - Ok(()) -} diff --git a/src/rpc/events/profile/list.rs b/src/rpc/events/profile/list.rs @@ -1,57 +0,0 @@ -use anyhow::Result; -use jsonrpsee::server::RpcModule; -use serde_json::{Value as JsonValue, json}; -use std::time::Duration; - -use crate::{radrootsd::Radrootsd, rpc::RpcError}; -use radroots_nostr::prelude::{ - radroots_nostr_fetch_metadata_for_author, - radroots_nostr_npub_string, -}; - -pub fn register(m: &mut RpcModule<Radrootsd>) -> Result<()> { - m.register_async_method("events.profile.list", |_params, ctx, _| async move { - if ctx.client.relays().await.is_empty() { - return Err(RpcError::NoRelays); - } - - let me_pk = ctx.pubkey; - - let latest = radroots_nostr_fetch_metadata_for_author(&ctx.client, me_pk, Duration::from_secs(10)) - .await - .map_err(|e| RpcError::Other(format!("metadata fetch failed: {e}")))?; - - let npub = radroots_nostr_npub_string(&me_pk) - .ok_or_else(|| RpcError::Other("bech32 encode failed".into()))?; - - let row = if let Some(ev) = latest { - let parsed: Option<serde_json::Value> = serde_json::from_str(&ev.content).ok(); - let profile: Option<radroots_events::profile::RadrootsProfile> = - serde_json::from_str(&ev.content).ok(); - - json!({ - "author_hex": me_pk.to_string(), - "author_npub": npub, - "event_id": ev.id.to_string(), - "created_at": ev.created_at.as_u64(), - "content": ev.content, - "metadata_json": parsed, - "radroots_profile": profile, - }) - } else { - json!({ - "author_hex": me_pk.to_string(), - "author_npub": npub, - "event_id": null, - "created_at": null, - "content": null, - "metadata_json": null, - "radroots_profile": null - }) - }; - - Ok::<JsonValue, RpcError>(json!({ "profiles": [row] })) - })?; - - Ok(()) -} diff --git a/src/rpc/events/profile/mod.rs b/src/rpc/events/profile/mod.rs @@ -1,14 +0,0 @@ -use anyhow::Result; -use jsonrpsee::server::RpcModule; - -use crate::radrootsd::Radrootsd; - -pub mod list; -pub mod publish; - -pub fn module(radrootsd: Radrootsd) -> Result<RpcModule<Radrootsd>> { - let mut m = RpcModule::new(radrootsd); - list::register(&mut m)?; - publish::register(&mut m)?; - Ok(m) -} diff --git a/src/rpc/events/profile/publish.rs b/src/rpc/events/profile/publish.rs @@ -1,54 +0,0 @@ -use anyhow::Result; -use jsonrpsee::server::RpcModule; -use serde::Deserialize; -use serde_json::{Value as JsonValue, json}; - -use crate::{radrootsd::Radrootsd, rpc::RpcError}; - -use radroots_events::profile::RadrootsProfile; -use radroots_events_codec::profile::encode::to_metadata; -use radroots_nostr::prelude::{ - radroots_nostr_build_metadata_event, - radroots_nostr_send_event, -}; - -#[derive(Debug, Deserialize)] -struct PublishProfileParams { - profile: RadrootsProfile, -} - -pub fn register(m: &mut RpcModule<Radrootsd>) -> Result<()> { - m.register_async_method("events.profile.publish", |params, ctx, _| async move { - let relays = ctx.client.relays().await; - if relays.is_empty() { - return Err(RpcError::NoRelays); - } - - let PublishProfileParams { profile } = params - .parse() - .map_err(|e| RpcError::InvalidParams(e.to_string()))?; - - let metadata = to_metadata(&profile).map_err(|e| RpcError::InvalidParams(e.to_string()))?; - let builder = radroots_nostr_build_metadata_event(&metadata); - - let output = radroots_nostr_send_event(&ctx.client, builder) - .await - .map_err(|e| RpcError::Other(format!("failed to publish metadata: {e}")))?; - - let id_hex = output.id().to_string(); - let sent: Vec<String> = output.success.into_iter().map(|u| u.to_string()).collect(); - let failed: Vec<(String, String)> = output - .failed - .into_iter() - .map(|(u, e)| (u.to_string(), e.to_string())) - .collect(); - - Ok::<JsonValue, RpcError>(json!({ - "id": id_hex, - "sent": sent, - "failed": failed - })) - })?; - - Ok(()) -} diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs @@ -1,29 +0,0 @@ -use std::net::SocketAddr; - -use anyhow::Result; -use jsonrpsee::server::{RpcModule, Server, ServerHandle}; - -use crate::radrootsd::Radrootsd; - -mod error; -mod domains; -mod events; -mod relays; -mod system; - -pub use error::RpcError; - -pub async fn start_rpc(radrootsd: Radrootsd, addr: SocketAddr) -> Result<ServerHandle> { - let server = Server::builder().build(addr).await?; - - let mut root = RpcModule::new(radrootsd.clone()); - root.merge(system::module(radrootsd.clone())?)?; - root.merge(relays::module(radrootsd.clone())?)?; - root.merge(events::profile::module(radrootsd.clone())?)?; - root.merge(events::post::module(radrootsd.clone())?)?; - root.merge(events::listing::module(radrootsd.clone())?)?; - root.merge(domains::trade::module(radrootsd.clone())?)?; - - let handle = server.start(root); - Ok(handle) -} diff --git a/src/rpc/relays/add.rs b/src/rpc/relays/add.rs @@ -1,28 +0,0 @@ -use anyhow::Result; -use jsonrpsee::RpcModule; -use radroots_nostr::prelude::radroots_nostr_add_relay; -use serde::Deserialize; -use serde_json::{Value as JsonValue, json}; - -use crate::radrootsd::Radrootsd; -use crate::rpc::RpcError; - -#[derive(Debug, Deserialize)] -struct AddParams { - url: String, -} - -pub fn register(m: &mut RpcModule<Radrootsd>) -> Result<()> { - m.register_async_method("relays.add", |params, ctx, _| async move { - let AddParams { url } = params - .parse() - .map_err(|e| RpcError::InvalidParams(e.to_string()))?; - - radroots_nostr_add_relay(&ctx.client, &url) - .await - .map_err(|e| RpcError::AddRelay(url.clone(), e.to_string()))?; - - Ok::<JsonValue, RpcError>(json!({ "added": url })) - })?; - Ok(()) -} diff --git a/src/rpc/relays/connect.rs b/src/rpc/relays/connect.rs @@ -1,43 +0,0 @@ -use anyhow::Result; -use jsonrpsee::RpcModule; -use serde_json::{Value as JsonValue, json}; - -use crate::radrootsd::Radrootsd; -use crate::rpc::RpcError; - -use radroots_nostr::prelude::{radroots_nostr_connect, RadrootsNostrRelayStatus}; - -pub fn register(m: &mut RpcModule<Radrootsd>) -> Result<()> { - m.register_async_method("relays.connect", |_p, ctx, _| async move { - let relays = ctx.client.relays().await; - if relays.is_empty() { - return Err(RpcError::NoRelays); - } - - let mut connected = 0usize; - let mut connecting = 0usize; - let mut disconnected = 0usize; - - for (_, r) in &relays { - match r.status() { - RadrootsNostrRelayStatus::Connected => connected += 1, - RadrootsNostrRelayStatus::Connecting => connecting += 1, - _ => disconnected += 1, - } - } - - let need_connect = disconnected > 0; - if need_connect { - let client = ctx.client.clone(); - tokio::spawn(async move { radroots_nostr_connect(&client).await }); - } - - Ok::<JsonValue, RpcError>(json!({ - "connected": connected, - "connecting": connecting, - "disconnected": disconnected, - "spawned_connect": need_connect - })) - })?; - Ok(()) -} diff --git a/src/rpc/relays/list.rs b/src/rpc/relays/list.rs @@ -1,16 +0,0 @@ -use anyhow::Result; -use jsonrpsee::RpcModule; -use serde_json::{Value as JsonValue, json}; - -use crate::radrootsd::Radrootsd; -use crate::rpc::RpcError; - -pub fn register(m: &mut RpcModule<Radrootsd>) -> Result<()> { - m.register_async_method("relays.list", |_p, ctx, _| async move { - let relays = ctx.client.relays().await; - Ok::<JsonValue, RpcError>(json!( - relays.keys().map(|u| u.to_string()).collect::<Vec<_>>() - )) - })?; - Ok(()) -} diff --git a/src/rpc/relays/mod.rs b/src/rpc/relays/mod.rs @@ -1,22 +0,0 @@ -use anyhow::Result; -use jsonrpsee::RpcModule; - -use crate::radrootsd::Radrootsd; - -pub mod add; -pub mod connect; -pub mod list; -pub mod remove; -pub mod status; - -pub fn module(radrootsd: Radrootsd) -> Result<RpcModule<Radrootsd>> { - let mut m = RpcModule::new(radrootsd); - - add::register(&mut m)?; - remove::register(&mut m)?; - list::register(&mut m)?; - status::register(&mut m)?; - connect::register(&mut m)?; - - Ok(m) -} diff --git a/src/rpc/relays/remove.rs b/src/rpc/relays/remove.rs @@ -1,28 +0,0 @@ -use anyhow::Result; -use jsonrpsee::RpcModule; -use radroots_nostr::prelude::radroots_nostr_remove_relay; -use serde::Deserialize; -use serde_json::{Value as JsonValue, json}; - -use crate::radrootsd::Radrootsd; -use crate::rpc::RpcError; - -#[derive(Debug, Deserialize)] -struct RemoveParams { - url: String, -} - -pub fn register(m: &mut RpcModule<Radrootsd>) -> Result<()> { - m.register_async_method("relays.remove", |params, ctx, _| async move { - let RemoveParams { url } = params - .parse() - .map_err(|e| RpcError::InvalidParams(e.to_string()))?; - - radroots_nostr_remove_relay(&ctx.client, &url) - .await - .map_err(|e| RpcError::Other(format!("failed to remove relay {url}: {e}")))?; - - Ok::<JsonValue, RpcError>(json!({ "removed": url })) - })?; - Ok(()) -} diff --git a/src/rpc/relays/status.rs b/src/rpc/relays/status.rs @@ -1,57 +0,0 @@ -use anyhow::Result; -use jsonrpsee::RpcModule; -use serde::Deserialize; -use serde_json::{Map as JsonMap, Value as JsonValue, json}; - -use crate::radrootsd::Radrootsd; -use crate::rpc::RpcError; -use radroots_nostr::prelude::fetch_nip11; - -#[derive(Debug, Deserialize)] -struct StatusParams { - #[serde(default)] - include_nip11: bool, -} - -pub fn register(m: &mut RpcModule<Radrootsd>) -> Result<()> { - m.register_async_method("relays.status", |params, ctx, _| async move { - let StatusParams { include_nip11 } = params - .parse() - .map_err(|e| RpcError::InvalidParams(e.to_string()))?; - - let relays = ctx.client.relays().await; - let mut out = Vec::with_capacity(relays.len()); - - for (relay_url, relay) in relays { - let url_str = relay_url.to_string(); - let status_str = format!("{}", relay.status()); - let parsed = reqwest::Url::parse(&url_str).ok(); - - let mut row = JsonMap::new(); - row.insert("url".into(), json!(url_str)); - row.insert("status".into(), json!(status_str)); - - if let Some(u) = &parsed { - row.insert("scheme".into(), json!(u.scheme())); - if let Some(h) = u.host_str() { - row.insert("host".into(), json!(h)); - row.insert("onion".into(), json!(h.ends_with(".onion"))); - } - if let Some(p) = u.port() { - row.insert("port".into(), json!(p)); - } - } - - if include_nip11 { - if let Some(doc) = fetch_nip11(row["url"].as_str().unwrap()).await { - row.insert("nip11".into(), json!(doc)); - } - } - - out.push(JsonValue::Object(row)); - } - - Ok::<JsonValue, RpcError>(json!(out)) - })?; - Ok(()) -} diff --git a/src/rpc/system.rs b/src/rpc/system.rs @@ -1,43 +0,0 @@ -use anyhow::Result; -use jsonrpsee::RpcModule; -use serde_json::json; - -use crate::radrootsd::Radrootsd; - -pub fn module(radrootsd: Radrootsd) -> Result<RpcModule<Radrootsd>> { - let mut m = RpcModule::new(radrootsd); - - m.register_method("system.ping", |_p, _ctx, _| "pong")?; - - m.register_method("system.get_info", |_p, ctx, _| { - let uptime = ctx.started.elapsed().as_secs(); - json!({ - "version": ctx.info.get("version"), - "build": ctx.info.get("build"), - "uptime_secs": uptime, - }) - })?; - - m.register_method("system.help", |_p, _ctx, _| { - vec![ - /* %% radrootsd-methods %% */ - "system.get_info", - "system.help", - "system.ping", - "events.listing.list", - "events.listing.publish", - "events.post.list", - "events.post.publish", - "events.profile.list", - "events.profile.publish", - "relays.add", - "relays.connect", - "relays.list", - "relays.remove", - "relays.status", - /* %% radrootsd-methods %% */ - ] - })?; - - Ok(m) -}