rhi

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

commit ff3b3a20957793146bb10181c824166dcc20fdbe
parent 97efeccbd63deb0ff085524ddb69cad9b9261240
Author: triesap <tyson@radroots.org>
Date:   Sat, 28 Mar 2026 22:51:38 +0000

trade_listing: dedupe replayed validation requests

Diffstat:
Msrc/features/trade_listing/handlers/dvm.rs | 151+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/features/trade_listing/state.rs | 20+++++++++++++++++++-
2 files changed, 151 insertions(+), 20 deletions(-)

diff --git a/src/features/trade_listing/handlers/dvm.rs b/src/features/trade_listing/handlers/dvm.rs @@ -452,6 +452,13 @@ async fn handle_listing_validate_request( client: &RadrootsNostrClient, state: &Arc<tokio::sync::Mutex<TradeListingState>>, ) -> Result<(), TradeListingDvmError> { + { + let state = state.lock().await; + if state.is_non_order_event_seen(&event.id.to_string()) { + return Ok(()); + } + } + let listing_event = if let Some(ptr) = payload.listing_event { match fetch_event_by_id_io(client, &ptr.id).await { Ok(evt) => Some(evt), @@ -525,7 +532,12 @@ async fn handle_listing_validate_request( } } - send_validate_result(event, client, listing_addr, errors).await + send_validate_result(event, client, listing_addr, errors).await?; + state + .lock() + .await + .mark_non_order_event_seen(&event.id.to_string()); + Ok(()) } async fn send_validate_result( @@ -1898,10 +1910,10 @@ mod tests { let client = make_client(&rhi_keys); let listing_addr = listing_addr_for_seller(&seller_keys); let state = Arc::new(AsyncMutex::new(TradeListingState::default())); - let event = make_event( + let missing_event = make_event( &seller_keys, RadrootsNostrKind::Custom(KIND_TRADE_LISTING_VALIDATE_REQ), - "content".to_string(), + "missing".to_string(), Vec::new(), ); @@ -1914,27 +1926,51 @@ mod tests { }), }; assert!( - handle_listing_validate_request(&event, payload, &listing_addr, &client, &state) - .await - .is_ok() + handle_listing_validate_request( + &missing_event, + payload, + &listing_addr, + &client, + &state + ) + .await + .is_ok() ); + let fetch_error_event = make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_VALIDATE_REQ), + "fetch-error".to_string(), + Vec::new(), + ); push_fetch_events_ok(Vec::new()); push_send_ok(); let payload = TradeListingValidateRequest { listing_event: None, }; assert!( - handle_listing_validate_request(&event, payload, &listing_addr, &client, &state) - .await - .is_ok() + handle_listing_validate_request( + &fetch_error_event, + payload, + &listing_addr, + &client, + &state, + ) + .await + .is_ok() ); + let success_event = make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_VALIDATE_REQ), + "success".to_string(), + Vec::new(), + ); dvm_test_hooks() .lock() .expect("hooks") .fetch_event_by_id_results - .push_back(Ok(event.clone())); + .push_back(Ok(success_event.clone())); push_validate_listing_ok( listing_addr.clone(), RadrootsListingFarmRef { @@ -1946,27 +1982,39 @@ mod tests { push_send_ok(); let payload = TradeListingValidateRequest { listing_event: Some(RadrootsNostrEventPtr { - id: event.id.to_hex(), + id: success_event.id.to_hex(), relays: None, }), }; assert!( - handle_listing_validate_request(&event, payload, &listing_addr, &client, &state) - .await - .is_ok() + handle_listing_validate_request( + &success_event, + payload, + &listing_addr, + &client, + &state + ) + .await + .is_ok() ); assert!(state.lock().await.is_listing_validated(&listing_addr)); assert_eq!( state.lock().await.validated_listing_event_id(&listing_addr), - Some(event.id.to_string().as_str()) + Some(success_event.id.to_string().as_str()) ); let other_listing_addr = listing_addr_for_seller(&rhi_keys); + let mismatch_event = make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_VALIDATE_REQ), + "mismatch".to_string(), + Vec::new(), + ); dvm_test_hooks() .lock() .expect("hooks") .fetch_event_by_id_results - .push_back(Ok(event.clone())); + .push_back(Ok(mismatch_event.clone())); push_validate_listing_ok( other_listing_addr, RadrootsListingFarmRef { @@ -1977,14 +2025,14 @@ mod tests { push_send_ok(); let payload = TradeListingValidateRequest { listing_event: Some(RadrootsNostrEventPtr { - id: event.id.to_hex(), + id: mismatch_event.id.to_hex(), relays: None, }), }; let mismatch_listing_addr = listing_addr_for_seller(&buyer_keys); assert!( handle_listing_validate_request( - &event, + &mismatch_event, payload, &mismatch_listing_addr, &client, @@ -2004,13 +2052,19 @@ mod tests { .lock() .await .mark_listing_validated(&listing_addr, "stale-listing-event"); + let stale_event = make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_VALIDATE_REQ), + "stale".to_string(), + Vec::new(), + ); push_fetch_events_ok(Vec::new()); push_send_ok(); let payload = TradeListingValidateRequest { listing_event: None, }; assert!( - handle_listing_validate_request(&event, payload, &listing_addr, &client, &state) + handle_listing_validate_request(&stale_event, payload, &listing_addr, &client, &state) .await .is_ok() ); @@ -2018,6 +2072,65 @@ mod tests { } #[tokio::test] + async fn handle_listing_validate_request_dedupes_replayed_request_event() { + let _guard = test_guard(); + let (rhi_keys, seller_keys, _) = make_keys(); + let client = make_client(&rhi_keys); + let listing_addr = listing_addr_for_seller(&seller_keys); + let state = Arc::new(AsyncMutex::new(TradeListingState::default())); + let event = make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_VALIDATE_REQ), + "content".to_string(), + Vec::new(), + ); + let payload = TradeListingValidateRequest { + listing_event: Some(RadrootsNostrEventPtr { + id: event.id.to_hex(), + relays: None, + }), + }; + + dvm_test_hooks() + .lock() + .expect("hooks") + .fetch_event_by_id_results + .push_back(Ok(event.clone())); + push_validate_listing_ok( + listing_addr.clone(), + RadrootsListingFarmRef { + pubkey: seller_keys.public_key().to_hex(), + d_tag: "farmtag".to_string(), + }, + ); + push_farm_validation_result(Ok(Vec::new())); + push_send_ok(); + assert!( + handle_listing_validate_request( + &event, + payload.clone(), + &listing_addr, + &client, + &state, + ) + .await + .is_ok() + ); + assert!( + state + .lock() + .await + .is_non_order_event_seen(&event.id.to_string()) + ); + + assert!( + handle_listing_validate_request(&event, payload, &listing_addr, &client, &state) + .await + .is_ok() + ); + } + + #[tokio::test] async fn handler_paths_cover_state_transitions() { let _guard = test_guard(); let (rhi_keys, seller_keys, buyer_keys) = make_keys(); diff --git a/src/features/trade_listing/state.rs b/src/features/trade_listing/state.rs @@ -35,6 +35,8 @@ pub struct TradeListingState { validated_listings: HashSet<String>, #[serde(default)] validated_listing_events: HashMap<String, ValidatedListingState>, + #[serde(default)] + seen_non_order_event_ids: HashSet<String>, orders: HashMap<String, TradeOrderState>, last_event_created_at: Option<u32>, } @@ -190,6 +192,14 @@ impl TradeListingState { .unwrap_or(false) } + pub fn mark_non_order_event_seen(&mut self, event_id: &str) -> bool { + self.seen_non_order_event_ids.insert(event_id.to_string()) + } + + pub fn is_non_order_event_seen(&self, event_id: &str) -> bool { + self.seen_non_order_event_ids.contains(event_id) + } + pub fn observe_event_created_at(&mut self, created_at: u32) { self.last_event_created_at = Some( self.last_event_created_at @@ -303,7 +313,7 @@ mod tests { ValidatedListingState, }; use radroots_trade::listing::order::TradeOrderStatus; - use std::collections::HashMap; + use std::collections::{HashMap, HashSet}; fn unique_state_path(suffix: &str) -> std::path::PathBuf { let nanos = std::time::SystemTime::now() @@ -336,6 +346,9 @@ mod tests { assert!(!state.is_event_seen("order-1", "evt")); assert!(state.mark_event_seen("order-1", "evt")); assert!(state.is_event_seen("order-1", "evt")); + assert!(!state.is_non_order_event_seen("evt-non-order")); + assert!(state.mark_non_order_event_seen("evt-non-order")); + assert!(state.is_non_order_event_seen("evt-non-order")); assert_eq!(state.replay_since(1_000, 300, 60), 700); state.observe_event_created_at(900); @@ -350,6 +363,7 @@ mod tests { assert!(state.get_order_mut("missing").is_none()); assert!(!state.mark_event_seen("missing", "evt-1")); assert!(!state.is_event_seen("missing", "evt-1")); + assert!(!state.is_non_order_event_seen("evt-2")); assert_eq!( TradeListingStateError::MissingOrder.to_string(), @@ -394,6 +408,7 @@ mod tests { let state_handle = runtime.state(); let mut state = state_handle.lock().await; state.mark_listing_validated("addr", "evt-listing-1"); + state.mark_non_order_event_seen("evt-validate-1"); state.observe_event_created_at(456); } runtime.persist().await.expect("persist"); @@ -406,6 +421,7 @@ mod tests { loaded_state.validated_listing_event_id("addr"), Some("evt-listing-1") ); + assert!(loaded_state.is_non_order_event_seen("evt-validate-1")); assert_eq!(loaded_state.last_event_created_at(), Some(456)); let _ = tokio::fs::remove_file(path).await; @@ -445,6 +461,7 @@ mod tests { state: TradeListingState { validated_listings: ["addr".to_string()].into_iter().collect(), validated_listing_events: HashMap::new(), + seen_non_order_event_ids: HashSet::new(), orders: HashMap::new(), last_event_created_at: Some(321), }, @@ -479,6 +496,7 @@ mod tests { event_id: "evt-listing-1".to_string(), }, )]), + seen_non_order_event_ids: HashSet::new(), orders: HashMap::new(), last_event_created_at: None, };