rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

commit 160e24603ea94affc98e788bbcf437c1254bece1
parent 023f017adbd475cbd91a69621b878675bf3cc0c9
Author: triesap <137732411+triesap@users.noreply.github.com>
Date:   Mon, 14 Apr 2025 17:09:54 +0000

Adds reference event validation to job requests order handler. Creates NIP-99 classified events interface.

Diffstat:
Msrc/events/job_request.rs | 56+++++++++++++++++++++++++++++++++++++++-----------------
Msrc/handlers/job_request_order.rs | 86++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/handlers/job_request_preview.rs | 9+++++----
Msrc/handlers/job_request_quote.rs | 9+++++----
Msrc/lib.rs | 1+
Asrc/models/event_classified.rs | 204+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/models/mod.rs | 1+
Msrc/utils/mod.rs | 1+
Msrc/utils/nostr.rs | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Asrc/utils/unit.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 473 insertions(+), 32 deletions(-)

diff --git a/src/events/job_request.rs b/src/events/job_request.rs @@ -7,25 +7,35 @@ use nostr_sdk::RelayPoolNotification; use tracing::{info, warn}; use crate::KIND_JOB_REQUEST; -use crate::handlers::job_request_order::handle_job_request_order; +use crate::handlers::job_request_order::{JobRequestOrderError, handle_job_request_order}; use crate::handlers::job_request_preview::handle_job_request_preview; use crate::handlers::job_request_quote::handle_job_request_quote; use crate::utils::nostr::{ - nostr_event_job_request_feedback, nostr_filter_kind, nostr_filter_new_events, - nostr_tag_at_value, nostr_tag_first_value, nostr_tag_relays_parse, nostr_tag_slice, - nostr_tags_resolve, + NostrTagsResolveError, nostr_event_job_request_feedback, nostr_filter_kind, + nostr_filter_new_events, nostr_tag_at_value, nostr_tag_first_value, nostr_tag_relays_parse, + nostr_tag_slice, nostr_tags_resolve, }; +use crate::utils::unit::MassUnitError; #[derive(thiserror::Error, Debug)] pub enum JobRequestError { + #[error("Order request error: {0}")] + JobRequestOrder(#[from] JobRequestOrderError), + + #[error("Mass unit error: {0}")] + MassUnit(#[from] MassUnitError), + + #[error("Mass unit error: {0}")] + NostrTagsResolve(#[from] NostrTagsResolveError), + #[error("Invalid job request input type: {0}")] InvalidInputType(String), #[error("Invalid job request input marker: {0}")] InvalidInputMarker(String), - #[error("Failure to resolve event tags: {0}")] - TagResolution(#[from] anyhow::Error), + #[error("Deserialization error: {0}")] + Serde(#[from] serde_json::Error), #[error("Failure to process request")] Failure, @@ -135,9 +145,9 @@ pub async fn subscriber(keys: Keys, relays: Vec<String>) -> Result<()> { async fn handle_error( error: JobRequestError, event: Event, - keys: Keys, + _keys: Keys, client: Client, - job_req: Option<JobRequest>, + _job_req: Option<JobRequest>, ) -> Result<()> { warn!("job_request handle_error error {}", error); warn!("job_request handle_error event {:?}", { event.clone() }); @@ -153,8 +163,8 @@ async fn handle_error( async fn handle_event(event: Event, keys: Keys, client: Client) -> Result<(), JobRequestError> { let job_req = parse_event(&event, &keys)?; - for input in &job_req.inputs { - let marker = input + for job_req_input in &job_req.inputs { + let marker = job_req_input .marker .as_ref() .ok_or_else(|| JobRequestError::InvalidInputMarker(job_req.id.to_string()))?; @@ -164,9 +174,10 @@ async fn handle_event(event: Event, keys: Keys, client: Client) -> Result<(), Jo process_job_request( handle_job_request_order, event.clone(), - job_req.clone(), keys.clone(), client.clone(), + job_req.clone(), + job_req_input.clone(), ) .await; } @@ -174,9 +185,10 @@ async fn handle_event(event: Event, keys: Keys, client: Client) -> Result<(), Jo process_job_request( handle_job_request_quote, event.clone(), - job_req.clone(), keys.clone(), client.clone(), + job_req.clone(), + job_req_input.clone(), ) .await; } @@ -184,9 +196,10 @@ async fn handle_event(event: Event, keys: Keys, client: Client) -> Result<(), Jo process_job_request( handle_job_request_preview, event.clone(), - job_req.clone(), keys.clone(), client.clone(), + job_req.clone(), + job_req_input.clone(), ) .await; } @@ -197,7 +210,7 @@ async fn handle_event(event: Event, keys: Keys, client: Client) -> Result<(), Jo } fn parse_event(event: &Event, keys: &Keys) -> Result<JobRequest, JobRequestError> { - let tags = nostr_tags_resolve(event, keys).map_err(JobRequestError::TagResolution)?; + let tags = nostr_tags_resolve(event, keys)?; let mut inputs = vec![]; let mut output = None; let mut bid_msat = None; @@ -282,11 +295,12 @@ fn parse_event(event: &Event, keys: &Keys) -> Result<JobRequest, JobRequestError async fn process_job_request<F, Fut>( handler: F, event: Event, - job_req: JobRequest, keys: Keys, client: Client, + job_req: JobRequest, + job_req_input: JobRequestInput, ) where - F: FnOnce(Event, JobRequest, Keys, Client) -> Fut, + F: FnOnce(Event, Keys, Client, JobRequest, JobRequestInput) -> Fut, Fut: std::future::Future<Output = Result<(), JobRequestError>>, { let error_event = event.clone(); @@ -294,7 +308,15 @@ async fn process_job_request<F, Fut>( let error_keys = keys.clone(); let error_client = client.clone(); - if let Err(err) = handler(event, job_req.clone(), keys.clone(), client.clone()).await { + if let Err(err) = handler( + event, + keys.clone(), + client.clone(), + job_req.clone(), + job_req_input.clone(), + ) + .await + { let _ = handle_error( err, error_event, diff --git a/src/handlers/job_request_order.rs b/src/handlers/job_request_order.rs @@ -1,17 +1,93 @@ use anyhow::Result; use nostr::{event::Event, key::Keys}; use nostr_sdk::Client; +use serde::Deserialize; +use thiserror::Error; use tracing::info; -use crate::events::job_request::{JobRequest, JobRequestError}; +use crate::{ + events::job_request::{JobRequest, JobRequestError, JobRequestInput}, + models::event_classified::EventClassified, + utils::nostr::nostr_fetch_event_by_id, +}; + +#[derive(Debug, Error)] +pub enum JobRequestOrderError { + #[error("Failure to parse the reference event {0}")] + ReferenceEventParse(String), + + #[error("Failure to fetch the reference event {0}")] + ReferenceEventFetch(String), + + #[error("Reference event not found {0}")] + ReferenceEventMissing(String), +} + +#[derive(Debug, Deserialize)] +pub struct JobRequestOrderDataQuantity { + pub amount: f64, + pub unit: String, + pub count: u32, + pub mass_g: f64, + pub label: String, +} + +#[derive(Debug, Deserialize)] +pub struct JobRequestOrderDataPrice { + pub amount: f64, + pub currency: String, + pub quantity_amount: f64, + pub quantity_unit: String, +} + +#[derive(Debug, Deserialize)] +pub struct JobRequestOrderDataOrder { + pub price: JobRequestOrderDataPrice, + pub quantity: JobRequestOrderDataQuantity, +} + +#[derive(Debug, Deserialize)] +pub struct JobRequestOrderDataEvent { + pub id: String, +} + +#[derive(Debug, Deserialize)] +pub struct JobRequestOrderData { + pub event: JobRequestOrderDataEvent, + pub order: JobRequestOrderDataOrder, +} pub async fn handle_job_request_order( - event: Event, - job_req: JobRequest, - keys: Keys, + _event: Event, + _keys: Keys, client: Client, + _job_req: JobRequest, + job_req_input: JobRequestInput, ) -> Result<(), JobRequestError> { - info!("handle_job_request_order job_req: {:?}", job_req); + let order_data: JobRequestOrderData = serde_json::from_str(&job_req_input.data)?; + + info!("handle_job_request_order order_data: {:?}", order_data); + + let fetched_ref_event: Option<Event> = + nostr_fetch_event_by_id(client.clone(), &order_data.event.id.clone()) + .await + .map_err(|_| JobRequestOrderError::ReferenceEventFetch(order_data.event.id.clone()))?; + let ref_event: &Event = + fetched_ref_event + .as_ref() + .ok_or(JobRequestOrderError::ReferenceEventMissing( + order_data.event.id.clone(), + ))?; + + info!("handle_job_request_order ref_event: {:?}", ref_event); + + let ref_classified = EventClassified::from_event(ref_event) + .map_err(|_| JobRequestOrderError::ReferenceEventParse(order_data.event.id.clone()))?; + + info!( + "handle_job_request_order ref_classified: {:?}", + ref_classified + ); Ok(()) } diff --git a/src/handlers/job_request_preview.rs b/src/handlers/job_request_preview.rs @@ -3,13 +3,14 @@ use nostr::{event::Event, key::Keys}; use nostr_sdk::Client; use tracing::info; -use crate::events::job_request::{JobRequest, JobRequestError}; +use crate::events::job_request::{JobRequest, JobRequestError, JobRequestInput}; pub async fn handle_job_request_preview( - event: Event, + _event: Event, + _keys: Keys, + _client: Client, job_req: JobRequest, - keys: Keys, - client: Client, + _job_req_input: JobRequestInput, ) -> Result<(), JobRequestError> { info!("handle_job_request_preview job_req: {:?}", job_req); diff --git a/src/handlers/job_request_quote.rs b/src/handlers/job_request_quote.rs @@ -3,13 +3,14 @@ use nostr::{event::Event, key::Keys}; use nostr_sdk::Client; use tracing::info; -use crate::events::job_request::{JobRequest, JobRequestError}; +use crate::events::job_request::{JobRequest, JobRequestError, JobRequestInput}; pub async fn handle_job_request_quote( - event: Event, + _event: Event, + _keys: Keys, + _client: Client, job_req: JobRequest, - keys: Keys, - client: Client, + _job_req_input: JobRequestInput, ) -> Result<(), JobRequestError> { info!("handle_job_request_quote job_req: {:?}", job_req); diff --git a/src/lib.rs b/src/lib.rs @@ -2,6 +2,7 @@ pub mod config; pub mod events; pub mod handlers; pub mod keys; +pub mod models; pub mod utils; pub const KIND_JOB_REQUEST: u16 = 5300; diff --git a/src/models/event_classified.rs b/src/models/event_classified.rs @@ -0,0 +1,204 @@ +use anyhow::Result; +use nostr::{EventId, event::Event}; +use serde::{Deserialize, Serialize}; + +use crate::utils::{ + nostr::{ + nostr_tag_match_geohash, nostr_tag_match_l, nostr_tag_match_location, + nostr_tag_match_summary, nostr_tag_match_title, nostr_tags_match, + }, + unit::MassUnit, +}; + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct EventClassifiedGeolocation { + pub geohash: Option<String>, + pub lat: f64, + pub lng: f64, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct EventClassifiedLocation { + pub address: String, + pub region: String, + pub country: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct EventClassifiedQuantity { + pub amount: f64, + pub unit: MassUnit, + pub label: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct EventClassifiedPrice { + pub amount: f64, + pub currency: String, + pub quantity_amount: f64, + pub quantity_unit: MassUnit, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct EventClassifiedListing { + pub key: String, + pub category: String, + pub process: Option<String>, + pub lot: Option<String>, + pub profile: Option<String>, + pub year: Option<String>, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct EventClassifiedBasis { + pub title: String, + pub summary: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct EventClassified { + pub id: EventId, + pub basis: EventClassifiedBasis, + pub listing: EventClassifiedListing, + pub prices: Vec<EventClassifiedPrice>, + pub quantities: Vec<EventClassifiedQuantity>, + pub location: Option<EventClassifiedLocation>, + pub geolocation: Option<EventClassifiedGeolocation>, +} + +impl EventClassified { + pub fn from_event(event: &Event) -> Result<Self> { + let mut prices = Vec::new(); + let mut quantities = Vec::new(); + let mut basis = EventClassifiedBasis::default(); + let mut listing = EventClassifiedListing::default(); + + let mut address: Option<String> = None; + let mut region: Option<String> = None; + let mut country: Option<String> = None; + let mut lat: Option<f64> = None; + let mut lng: Option<f64> = None; + let mut geohash: Option<String> = None; + + for tag in event.tags.iter() { + if let Some((key, values)) = nostr_tags_match(tag) { + match key { + "quantity" if values.len() >= 3 => { + let amount_str = &values[0]; + let unit_str = &values[1]; + let label = &values[2]; + + if let (Ok(amount), Ok(unit)) = + (amount_str.parse::<f64>(), unit_str.parse::<MassUnit>()) + { + quantities.push(EventClassifiedQuantity { + amount, + unit, + label: label.clone(), + }); + } + } + "price" if values.len() >= 4 => { + let amount_str = &values[0]; + let currency = &values[1]; + let quantity_amount_str = &values[2]; + let quantity_unit_str = &values[3]; + + if let (Ok(amount), Ok(quantity_amount), Ok(quantity_unit)) = ( + amount_str.parse::<f64>(), + quantity_amount_str.parse::<f64>(), + quantity_unit_str.to_lowercase().parse::<MassUnit>(), + ) { + prices.push(EventClassifiedPrice { + amount, + currency: currency.clone(), + quantity_amount, + quantity_unit, + }); + } + } + "key" if !values.is_empty() => listing.key = values[0].clone(), + "category" if !values.is_empty() => listing.category = values[0].clone(), + "process" if !values.is_empty() => listing.process = Some(values[0].clone()), + "lot" if !values.is_empty() => listing.lot = Some(values[0].clone()), + "profile" if !values.is_empty() => listing.profile = Some(values[0].clone()), + "year" if !values.is_empty() => listing.year = Some(values[0].clone()), + _ => {} + } + } + + if let Some((kind, value)) = nostr_tag_match_l(tag) { + let precision = value.to_string().split('.').nth(1).map_or(0, |s| s.len()); + + match kind { + "dd.lat" => { + let current_precision = lat + .map(|v| v.to_string().split('.').nth(1).map_or(0, |s| s.len())) + .unwrap_or(0); + if precision > current_precision { + lat = Some(value); + } + } + "dd.lon" => { + let current_precision = lng + .map(|v| v.to_string().split('.').nth(1).map_or(0, |s| s.len())) + .unwrap_or(0); + if precision > current_precision { + lng = Some(value); + } + } + _ => {} + } + } + + if let Some((addr, reg, coun)) = nostr_tag_match_location(tag) { + address = Some(addr.to_string()); + region = Some(reg.to_string()); + country = Some(coun.to_string()); + } + + if let Some(g) = nostr_tag_match_geohash(tag) { + if geohash + .as_ref() + .map_or(true, |current| g.len() > current.len()) + { + geohash = Some(g); + } + } + + if let Some(title) = nostr_tag_match_title(tag) { + basis.title = title; + } + + if let Some(summary) = nostr_tag_match_summary(tag) { + basis.summary = summary; + } + } + + let location = if address.is_some() || region.is_some() || country.is_some() { + Some(EventClassifiedLocation { + address: address.unwrap_or_default(), + region: region.unwrap_or_default(), + country: country.unwrap_or_default(), + }) + } else { + None + }; + + let geolocation = if let (Some(lat), Some(lng)) = (lat, lng) { + Some(EventClassifiedGeolocation { geohash, lat, lng }) + } else { + None + }; + + Ok(Self { + id: event.id, + basis, + listing, + prices, + quantities, + location, + geolocation, + }) + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs @@ -0,0 +1 @@ +pub mod event_classified; diff --git a/src/utils/mod.rs b/src/utils/mod.rs @@ -1 +1,2 @@ pub mod nostr; +pub mod unit; diff --git a/src/utils/nostr.rs b/src/utils/nostr.rs @@ -1,6 +1,8 @@ +use std::borrow::Cow; + use anyhow::Result; use nostr::{ - event::{Event, EventBuilder, Kind, Tag, TagKind, TagStandard}, + event::{Event, EventBuilder, EventId, Kind, Tag, TagKind, TagStandard}, filter::Filter, key::{Keys, PublicKey}, nips::{ @@ -9,6 +11,8 @@ use nostr::{ }, types::{RelayUrl, Timestamp}, }; +use nostr_sdk::Client; +use nostr_sdk::RelayPoolNotification; use thiserror::Error; use crate::events::job_request::JobRequestError; @@ -48,6 +52,57 @@ pub fn nostr_tag_relays_parse(tag: &Tag) -> Option<&Vec<RelayUrl>> { } } +pub fn nostr_tags_match<'a>(tag: &'a Tag) -> Option<(&'a str, &'a [String])> { + if let TagKind::Custom(Cow::Borrowed(key)) = tag.kind() { + Some((key, &tag.as_slice()[1..])) + } else { + None + } +} + +pub fn nostr_tag_match_l(tag: &Tag) -> Option<(&str, f64)> { + let values = tag.as_slice(); + + if values.len() >= 3 && values[0].eq_ignore_ascii_case("l") { + if let Ok(value) = values[1].parse::<f64>() { + return Some((values[2].as_str(), value)); + } + } + + None +} + +pub fn nostr_tag_match_location(tag: &Tag) -> Option<(&str, &str, &str)> { + let values = tag.as_slice(); + + if values.len() >= 4 && values[0] == "location" { + Some((values[1].as_str(), values[2].as_str(), values[3].as_str())) + } else { + None + } +} + +pub fn nostr_tag_match_geohash(tag: &Tag) -> Option<String> { + match tag.as_standardized()? { + TagStandard::Geohash(geohash) => Some(geohash.clone()), + _ => None, + } +} + +pub fn nostr_tag_match_title(tag: &Tag) -> Option<String> { + match tag.as_standardized()? { + TagStandard::Title(title) => Some(title.clone()), + _ => None, + } +} + +pub fn nostr_tag_match_summary(tag: &Tag) -> Option<String> { + match tag.as_standardized()? { + TagStandard::Summary(summary) => Some(summary.clone()), + _ => None, + } +} + pub fn nostr_event_job_request_feedback( event: &Event, error: JobRequestError, @@ -62,6 +117,25 @@ pub fn nostr_event_job_request_feedback( Ok(builder) } +pub async fn nostr_fetch_event_by_id(client: Client, id: &str) -> Result<Option<Event>> { + let event_id = EventId::from_hex(id)?; + let filter = Filter::new().id(event_id); + + client.connect().await; + client.subscribe(filter, None).await?; + + let mut notifications = client.notifications(); + while let Ok(n) = notifications.recv().await { + if let RelayPoolNotification::Event { event, .. } = n { + if event.id == event_id { + return Ok(Some(*event)); + } + } + } + + Ok(None) +} + #[derive(Debug, Error)] pub enum NostrTagsResolveError { #[error("Missing public key tag in encrypted event: {0:?}")] @@ -77,7 +151,7 @@ pub enum NostrTagsResolveError { ParseError(#[from] serde_json::Error), } -pub fn nostr_tags_resolve(event: &Event, keys: &Keys) -> Result<Vec<Tag>> { +pub fn nostr_tags_resolve(event: &Event, keys: &Keys) -> Result<Vec<Tag>, NostrTagsResolveError> { if event.tags.iter().any(|t| t.kind() == TagKind::Encrypted) { let recipient = event .tags diff --git a/src/utils/unit.rs b/src/utils/unit.rs @@ -0,0 +1,60 @@ +use serde::{Deserialize, Serialize}; +use std::{fmt, str::FromStr}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum MassUnitError { + #[error("Invalid mass unit: {0}")] + InvalidUnit(String), + + #[error("Invalid mass amount: {0}")] + InvalidAmount(f64), +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "lowercase")] +pub enum MassUnit { + G, + Kg, + Lb, +} + +impl MassUnit { + pub fn to_grams(&self, amount: f64) -> Result<f64, MassUnitError> { + if !amount.is_finite() || amount.is_nan() { + return Err(MassUnitError::InvalidAmount(amount)); + } + + let grams = match self { + MassUnit::G => amount, + MassUnit::Kg => amount * 1000.0, + MassUnit::Lb => amount * 453.592, + }; + + Ok(grams) + } +} + +impl fmt::Display for MassUnit { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let unit_str = match self { + MassUnit::G => "g", + MassUnit::Kg => "kg", + MassUnit::Lb => "lb", + }; + write!(f, "{unit_str}") + } +} + +impl FromStr for MassUnit { + type Err = MassUnitError; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s { + "g" => Ok(MassUnit::G), + "kg" => Ok(MassUnit::Kg), + "lb" => Ok(MassUnit::Lb), + other => Err(MassUnitError::InvalidUnit(other.to_string())), + } + } +}