Summary
Mostro currently has NIP-59 (Gift Wrap) deeply embedded throughout the codebase as the sole messaging transport. While NIP-59 provides excellent metadata privacy, it introduces a critical vulnerability: relays cannot distinguish legitimate gift wraps from spam, making Mostro susceptible to a "Gift Wrap Apocalypse" — a coordinated spam attack that could overwhelm relays and/or the Mostro daemon itself.
This issue proposes a Messaging Transport Abstraction Layer (MTAL) that decouples the transport mechanism from business logic, allowing Mostro to:
- Switch messaging transports via configuration (no code changes)
- Support alternative transports like NIP-04 + NIP-44 encryption as fallback
- Run in dual-mode during migration periods
- Add transport-level anti-spam defenses
Threat Model
Attack Vectors
| Vector |
Description |
Impact |
| Relay flooding |
Millions of garbage gift wraps sent to relays |
Relay storage exhaustion, relay operators drop GiftWrap support |
| User targeting |
Gift wraps sent to a specific pubkey (e.g., Mostro's) |
CPU exhaustion on decrypt attempts, message queue saturation |
| Mostro-specific |
Valid-looking gift wraps with garbage content targeting Mostro's pubkey |
Mostro wastes resources on extract_rumor() + deserialization for every event |
Current Mitigations (insufficient)
- POW requirement: Mostro checks
event.check_pow(pow) before processing — raises cost for attackers but doesn't prevent determined spam
- Timestamp filter: Events older than 10 seconds are discarded — prevents replay but not fresh spam
- Relay limits:
max_size: 6000/6500 — limits individual event size but not volume
Why This Is Critical
Unlike NIP-04/NIP-17 events where relays can see sender/receiver and apply rate limiting, NIP-59 gift wraps are opaque. Relays have no way to:
- Rate-limit by sender (random ephemeral key per gift wrap)
- Apply spam filters
- Distinguish Mostro traffic from noise
If major relay operators start rejecting Kind::GiftWrap due to abuse, Mostro becomes inoperable.
Current State Analysis
NIP-59 Coupling Points
NIP-59 is embedded at three layers:
Layer 1: Event Reception (src/app.rs — event loop)
src/app.rs:312 → if let Kind::GiftWrap = event.kind { ... }
src/app.rs:318 → nip59::extract_rumor(&my_keys, &event)
src/app.rs:330 → event.rumor.created_at (timestamp validation)
src/app.rs:335 → event.rumor.content (message parsing)
src/app.rs:353 → event.sender == event.rumor.pubkey (identity check)
src/main.rs:70 → Filter::new().kind(Kind::GiftWrap) (subscription)
Layer 2: Message Sending (src/util.rs — send_dm())
src/util.rs:511-549 → send_dm() function
- Builds rumor via EventBuilder::text_note()
- Creates gift wrap via EventBuilder::gift_wrap()
- Only send function in the entire codebase
All outbound messages flow through send_dm():
src/scheduler.rs:64 — message queue flushing (ALL queued messages)
src/app/admin_add_solver.rs:53 — admin responses
src/app/admin_cancel.rs:148,162,165 — cancel notifications
src/app/admin_take_dispute.rs:175,195,205 — dispute notifications
src/app/last_trade_index.rs:93 — trade index responses
Layer 3: Handler Signatures (UnwrappedGift as parameter type)
Every action handler receives event: &UnwrappedGift, directly accessing event.sender (identity pubkey) and event.rumor.pubkey (trade pubkey). 28 function signatures across 17 files:
| File |
Functions |
Access patterns |
src/app.rs |
manage_errors, check_trade_index, handle_message_action |
event.sender, event.rumor.pubkey, event.rumor.content, event.rumor.created_at |
src/app/order.rs |
order_action |
event.sender, event.rumor.pubkey |
src/app/take_sell.rs |
take_sell_action |
event.sender, event.rumor.pubkey |
src/app/take_buy.rs |
take_buy_action |
event.sender, event.rumor.pubkey |
src/app/cancel.rs |
8 cancel functions |
event.rumor.pubkey (extensively) |
src/app/release.rs |
release_action |
event.rumor.pubkey |
src/app/fiat_sent.rs |
fiat_sent_action |
event.rumor.pubkey |
src/app/add_invoice.rs |
add_invoice_action |
event.rumor.pubkey |
src/app/dispute.rs |
dispute_action |
event.rumor.pubkey |
src/app/rate_user.rs |
update_user_reputation_action |
event.rumor.pubkey |
src/app/admin_cancel.rs |
admin_cancel_action |
event.sender, event.rumor.pubkey |
src/app/admin_settle.rs |
admin_settle_action |
event.sender, event.rumor.pubkey |
src/app/admin_add_solver.rs |
admin_add_solver_action |
event.sender, event.rumor.pubkey |
src/app/admin_take_dispute.rs |
admin_take_dispute_action |
event.sender |
src/app/trade_pubkey.rs |
trade_pubkey_action |
event.sender, event.rumor.pubkey |
src/app/restore_session.rs |
restore_session_action |
event.sender, event.rumor.pubkey |
src/app/last_trade_index.rs |
last_trade_index |
event.sender, event.rumor.pubkey |
src/app/orders.rs |
orders_action |
event.sender, event.rumor.pubkey |
src/util.rs |
settle_seller_hold_invoice |
event.rumor.pubkey |
src/rpc/service.rs |
RPC handlers (4 functions) |
Constructs fake UnwrappedGift |
What's Already Clean
mostro-core::Message: Fully transport-agnostic. JSON struct with no NIP-59 knowledge.
- Business logic (
flow.rs, db.rs, lightning/): Zero NIP-59 coupling.
nip33.rs: Addressable events (order book, ratings) — independent of messaging transport.
scheduler.rs: Only touches NIP-59 via send_dm() in message queue flushing.
Implementation Plan
Phase 1: Transport Abstraction (no behavior change)
Goal: Replace all UnwrappedGift usage with a transport-agnostic IncomingMessage type and abstract send/receive behind a trait. Zero protocol changes, identical behavior.
Step 1.1: Define the abstraction (src/transport/mod.rs)
Create a new transport module:
// src/transport/mod.rs
pub mod gift_wrap;
// Future: pub mod direct_encrypted;
use mostro_core::error::MostroError;
use nostr_sdk::prelude::*;
/// Transport-agnostic representation of an incoming message.
/// Replaces `nostr::nips::nip59::UnwrappedGift` throughout the codebase.
#[derive(Debug, Clone)]
pub struct IncomingMessage {
/// Identity public key of the sender (master key).
/// In NIP-59: this is `UnwrappedGift.sender`
/// In NIP-04+NIP-44: this would be the event author
pub sender: PublicKey,
/// Trade public key used for this specific operation.
/// In NIP-59: this is `UnwrappedGift.rumor.pubkey`
/// In NIP-04+NIP-44: extracted from the signed message content
pub trade_pubkey: PublicKey,
/// Raw content string (JSON-serialized Message + optional Signature)
pub content: String,
/// When the message was created
pub created_at: Timestamp,
}
/// Trait for pluggable messaging transports.
#[async_trait::async_trait]
pub trait MessageTransport: Send + Sync + 'static {
/// Returns the Nostr event kind(s) this transport listens for.
fn subscription_kinds(&self) -> Vec<Kind>;
/// Attempt to extract a message from a raw Nostr event.
/// Returns `None` if the event is not for this transport.
async fn extract_message(
&self,
keys: &Keys,
event: &Event,
) -> Result<Option<IncomingMessage>, MostroError>;
/// Send an encrypted message to a recipient.
async fn send_message(
&self,
sender_keys: &Keys,
receiver: &PublicKey,
payload: &str,
expiration: Option<Timestamp>,
) -> Result<Event, MostroError>;
}
Step 1.2: Implement GiftWrapTransport (src/transport/gift_wrap.rs)
Encapsulate current NIP-59 logic:
// src/transport/gift_wrap.rs
use super::{IncomingMessage, MessageTransport};
// ... imports ...
pub struct GiftWrapTransport;
#[async_trait::async_trait]
impl MessageTransport for GiftWrapTransport {
fn subscription_kinds(&self) -> Vec<Kind> {
vec![Kind::GiftWrap]
}
async fn extract_message(
&self,
keys: &Keys,
event: &Event,
) -> Result<Option<IncomingMessage>, MostroError> {
if event.kind != Kind::GiftWrap {
return Ok(None);
}
if event.verify().is_err() {
return Err(/* ... */);
}
let unwrapped = nip59::extract_rumor(keys, event).await
.map_err(/* ... */)?;
Ok(Some(IncomingMessage {
sender: unwrapped.sender,
trade_pubkey: unwrapped.rumor.pubkey,
content: unwrapped.rumor.content,
created_at: unwrapped.rumor.created_at,
}))
}
async fn send_message(
&self,
sender_keys: &Keys,
receiver: &PublicKey,
payload: &str,
expiration: Option<Timestamp>,
) -> Result<Event, MostroError> {
// Current send_dm() logic moved here
let content = /* ... build (Message, Option<Signature>) tuple ... */;
let rumor = EventBuilder::text_note(content)
.build(sender_keys.public_key());
let mut tags = Vec::new();
if let Some(ts) = expiration {
tags.push(Tag::expiration(ts));
}
let event = EventBuilder::gift_wrap(
sender_keys, receiver, rumor, Tags::from_list(tags)
).await?;
Ok(event)
}
}
Step 1.3: Refactor send_dm() → use transport (src/util.rs)
Current send_dm() (lines 511-549) becomes a thin wrapper:
// Before:
pub async fn send_dm(receiver_pubkey, sender_keys, payload, expiration) {
// ... build rumor, gift_wrap, send ...
}
// After:
pub async fn send_dm(receiver_pubkey, sender_keys, payload, expiration) {
let transport = get_transport(); // global or injected
let event = transport.send_message(sender_keys, &receiver_pubkey, payload, expiration).await?;
if let Ok(client) = get_nostr_client() {
client.send_event(&event).await?;
}
Ok(())
}
No callers change — send_dm() signature stays identical.
Step 1.4: Refactor event loop (src/app.rs:run())
Current loop (lines 305-375) hardcodes Kind::GiftWrap and nip59::extract_rumor. Refactor to use transport:
// Before (app.rs:312-318):
if let Kind::GiftWrap = event.kind {
if event.verify().is_err() { ... }
let event = match nip59::extract_rumor(&my_keys, &event).await { ... };
// ... timestamp check, content parsing, signature verification ...
}
// After:
let transport = get_transport();
if let Some(incoming) = transport.extract_message(&my_keys, &event).await? {
// Timestamp validation
if incoming.created_at.as_u64() < since_time { continue; }
// Content parsing (identical logic, just reads from incoming.content)
let (message, sig) = serde_json::from_str::<(Message, Option<Signature>)>(&incoming.content)?;
// Signature verification using incoming.sender and incoming.trade_pubkey
// ... rest of the logic identical but using incoming instead of event ...
}
Step 1.5: Replace UnwrappedGift with IncomingMessage in all handlers
This is the largest mechanical change. For each of the 28 functions:
- Change parameter type:
event: &UnwrappedGift → event: &IncomingMessage
- Replace field access:
event.sender → event.sender (same field name, no change needed)
event.rumor.pubkey → event.trade_pubkey
event.rumor.content → event.content
event.rumor.created_at → event.created_at
- Update imports: remove
use nostr::nips::nip59::UnwrappedGift, add use crate::transport::IncomingMessage
Detailed file-by-file changes:
| File |
Lines to change |
Key replacements |
src/app.rs |
~30 lines |
Refactor run(), update manage_errors, check_trade_index, handle_message_action signatures |
src/app/order.rs |
~8 lines |
event.rumor.pubkey → event.trade_pubkey, event.sender stays |
src/app/take_sell.rs |
~12 lines |
Same pattern + test mock updates |
src/app/take_buy.rs |
~10 lines |
Same pattern |
src/app/cancel.rs |
~20 lines |
8 functions, all use event.rumor.pubkey → event.trade_pubkey |
src/app/release.rs |
~4 lines |
Single check event.rumor.pubkey |
src/app/fiat_sent.rs |
~4 lines |
event.rumor.pubkey in check and payload |
src/app/add_invoice.rs |
~3 lines |
Single event.rumor.pubkey check |
src/app/dispute.rs |
~3 lines |
event.rumor.pubkey for sender check |
src/app/rate_user.rs |
~4 lines |
event.rumor.pubkey for rating |
src/app/admin_cancel.rs |
~6 lines |
Both event.sender and event.rumor.pubkey |
src/app/admin_settle.rs |
~4 lines |
Both fields |
src/app/admin_add_solver.rs |
~4 lines |
event.sender check + event.rumor.pubkey for response |
src/app/admin_take_dispute.rs |
~8 lines |
event.sender extensively |
src/app/trade_pubkey.rs |
~6 lines |
Both fields for pubkey rotation |
src/app/restore_session.rs |
~3 lines |
Both fields |
src/app/last_trade_index.rs |
~6 lines |
Both fields + test mock |
src/app/orders.rs |
~3 lines |
event.sender for user ID |
src/util.rs |
~3 lines |
settle_seller_hold_invoice |
src/rpc/service.rs |
~20 lines |
4 functions that construct mock UnwrappedGift → construct IncomingMessage |
Step 1.6: Update subscription in main.rs
// Before (main.rs:68-71):
let subscription = Filter::new()
.pubkey(mostro_keys.public_key())
.kind(Kind::GiftWrap)
.limit(0);
// After:
let transport = get_transport();
let mut filter = Filter::new()
.pubkey(mostro_keys.public_key())
.limit(0);
for kind in transport.subscription_kinds() {
filter = filter.kind(kind);
}
let subscription = filter;
Step 1.7: Transport initialization
Add to config (settings.toml):
[mostro]
# Transport options: "gift-wrap" (default), "direct-encrypted", "dual"
transport = "gift-wrap"
Initialize in main.rs:
let transport: Arc<dyn MessageTransport> = match config.transport.as_str() {
"gift-wrap" => Arc::new(GiftWrapTransport),
"direct-encrypted" => Arc::new(DirectEncryptedTransport),
"dual" => Arc::new(DualTransport::new(
GiftWrapTransport,
DirectEncryptedTransport,
)),
_ => Arc::new(GiftWrapTransport), // default fallback
};
// Store globally (similar to NOSTR_CLIENT)
TRANSPORT.set(transport).expect("Transport already initialized");
Estimated scope: ~200-300 lines of new code (transport module), ~150-200 lines of mechanical replacements across handlers. Total: ~400-500 lines changed.
Phase 2: Alternative Transport — NIP-04 + NIP-44
Goal: Implement DirectEncryptedTransport as a ready-to-use fallback.
Design: NIP-04 structure + NIP-44 encryption
- Event kind:
Kind::Custom(4) (NIP-04 structure — sender/receiver visible to relays)
- Encryption: NIP-44 (modern, authenticated encryption — replaces NIP-04's weak CBC scheme)
- Content: Same
(Message, Option<Signature>) JSON tuple, encrypted with NIP-44
- Metadata tradeoff: Sender/receiver pubkeys visible → relays CAN rate-limit. Since Mostro users rotate trade keys per operation, metadata leakage is bounded to a single trade.
// src/transport/direct_encrypted.rs
pub struct DirectEncryptedTransport;
#[async_trait::async_trait]
impl MessageTransport for DirectEncryptedTransport {
fn subscription_kinds(&self) -> Vec<Kind> {
vec![Kind::Custom(4)]
}
async fn extract_message(
&self,
keys: &Keys,
event: &Event,
) -> Result<Option<IncomingMessage>, MostroError> {
if event.kind != Kind::Custom(4) {
return Ok(None);
}
// Decrypt content using NIP-44
let decrypted = nip44::decrypt(
keys.secret_key()?,
&event.pubkey,
&event.content,
)?;
// Parse the inner message structure
// For direct encrypted, sender == event author
// trade_pubkey is extracted from the signed message content
let (message, sig): (Message, Option<Signature>) =
serde_json::from_str(&decrypted)?;
// Trade pubkey: if signature present, it was signed by trade key
// otherwise trade_pubkey == sender
let trade_pubkey = if let Some(sig) = &sig {
// Extract trade pubkey from message verification
// (trade key signs the message, identity key wraps the event)
extract_trade_pubkey_from_sig(&message, sig)?
} else {
event.pubkey // Full privacy mode: identity == trade
};
Ok(Some(IncomingMessage {
sender: event.pubkey,
trade_pubkey,
content: decrypted,
created_at: event.created_at,
}))
}
async fn send_message(
&self,
sender_keys: &Keys,
receiver: &PublicKey,
payload: &str,
expiration: Option<Timestamp>,
) -> Result<Event, MostroError> {
// Encrypt with NIP-44
let encrypted = nip44::encrypt(
sender_keys.secret_key()?,
receiver,
payload,
nip44::Version::V2,
)?;
let mut tags = vec![Tag::public_key(*receiver)];
if let Some(ts) = expiration {
tags.push(Tag::expiration(ts));
}
let event = EventBuilder::new(Kind::Custom(4), encrypted)
.tags(Tags::from_list(tags))
.sign_with_keys(sender_keys)?;
Ok(event)
}
}
Step 2.2: Dual-Mode Transport
For migration periods, Mostro can accept both formats:
// src/transport/dual.rs
pub struct DualTransport {
gift_wrap: GiftWrapTransport,
direct: DirectEncryptedTransport,
}
#[async_trait::async_trait]
impl MessageTransport for DualTransport {
fn subscription_kinds(&self) -> Vec<Kind> {
let mut kinds = self.gift_wrap.subscription_kinds();
kinds.extend(self.direct.subscription_kinds());
kinds
}
async fn extract_message(&self, keys: &Keys, event: &Event)
-> Result<Option<IncomingMessage>, MostroError>
{
// Try gift wrap first, then direct encrypted
if let Some(msg) = self.gift_wrap.extract_message(keys, event).await? {
return Ok(Some(msg));
}
self.direct.extract_message(keys, event).await
}
async fn send_message(&self, /* ... */) -> Result<Event, MostroError> {
// Send using the preferred/configured outbound transport
// Could be configurable: "send as gift_wrap" or "send as direct"
self.gift_wrap.send_message(/* ... */).await
}
}
Step 2.3: Protocol Documentation Update
Update protocol/src/overview.md to document:
- Both transport options
- When to use each
- Migration guide for client developers
- Dual-mode behavior specification
Estimated scope: ~300-400 lines of new code.
Phase 3: Anti-Spam Hardening (defense in depth)
Goal: Regardless of transport, add application-level defenses against spam.
Step 3.1: Enhanced POW management
Make POW dynamically adjustable without restart:
// src/config/settings.rs — add dynamic POW adjustment
pub struct DynamicPow {
base_pow: u8,
current_pow: AtomicU8,
failed_decrypts_per_minute: AtomicU64,
last_reset: AtomicU64,
}
impl DynamicPow {
/// If failed decrypt rate exceeds threshold, auto-increase POW
pub fn check_and_adjust(&self) {
let rate = self.failed_decrypts_per_minute.load(Ordering::Relaxed);
if rate > 100 {
let new_pow = (self.current_pow.load(Ordering::Relaxed) + 2).min(32);
self.current_pow.store(new_pow, Ordering::Relaxed);
tracing::warn!("🚨 Spam detected: {} failed decrypts/min, POW raised to {}", rate, new_pow);
}
}
}
Step 3.2: Bloom filter for processed events
Prevent re-processing of events seen in the current session:
// Already partially handled by Nostr SDK, but add application-level dedup
use probabilistic_collections::bloom::BloomFilter;
pub struct EventDedup {
filter: RwLock<BloomFilter<EventId>>,
count: AtomicU64,
}
Step 3.3: Circuit breaker
If spam rate exceeds threshold, temporarily pause processing and alert:
pub struct CircuitBreaker {
state: AtomicU8, // 0=closed, 1=open, 2=half-open
failure_count: AtomicU64,
threshold: u64,
cooldown_secs: u64,
}
Step 3.4: Metrics and observability
Add counters for monitoring:
messages_received_total (by transport type)
messages_decrypted_success
messages_decrypted_failure (potential spam indicator)
messages_processed_total (by action type)
pow_current_difficulty
Estimated scope: ~200-300 lines of new code.
File Impact Summary
| File |
Phase 1 |
Phase 2 |
Phase 3 |
src/transport/mod.rs |
NEW |
— |
— |
src/transport/gift_wrap.rs |
NEW |
— |
— |
src/transport/direct_encrypted.rs |
— |
NEW |
— |
src/transport/dual.rs |
— |
NEW |
— |
src/main.rs |
Modified |
— |
— |
src/app.rs |
Modified |
— |
— |
src/util.rs |
Modified |
— |
— |
src/app/*.rs (17 files) |
Modified (type changes) |
— |
— |
src/rpc/service.rs |
Modified |
— |
— |
src/config/settings.rs |
Modified (transport config) |
— |
Modified (dynamic POW) |
src/config/types.rs |
Modified (transport enum) |
— |
— |
src/lib.rs |
Modified (module declaration) |
— |
— |
Migration Strategy
Phase 1 (transport="gift-wrap") ← current behavior, zero risk
↓
Phase 2 (transport="dual") ← accept both, send as gift-wrap
↓
If apocalypse hits: (transport="direct-encrypted") ← instant switch via config
Client developers would need to support the alternative transport, but the Message protocol layer remains identical — only the Nostr event wrapping changes.
Priority
- Phase 1: High — pure refactoring, no behavior change, enables all future flexibility
- Phase 2: Medium — implement when Phase 1 is merged, keep as "break glass" option
- Phase 3: Low-Medium — independent of transport, can be done in parallel
Acceptance Criteria
Summary
Mostro currently has NIP-59 (Gift Wrap) deeply embedded throughout the codebase as the sole messaging transport. While NIP-59 provides excellent metadata privacy, it introduces a critical vulnerability: relays cannot distinguish legitimate gift wraps from spam, making Mostro susceptible to a "Gift Wrap Apocalypse" — a coordinated spam attack that could overwhelm relays and/or the Mostro daemon itself.
This issue proposes a Messaging Transport Abstraction Layer (MTAL) that decouples the transport mechanism from business logic, allowing Mostro to:
Threat Model
Attack Vectors
extract_rumor()+ deserialization for every eventCurrent Mitigations (insufficient)
event.check_pow(pow)before processing — raises cost for attackers but doesn't prevent determined spammax_size: 6000/6500— limits individual event size but not volumeWhy This Is Critical
Unlike NIP-04/NIP-17 events where relays can see sender/receiver and apply rate limiting, NIP-59 gift wraps are opaque. Relays have no way to:
If major relay operators start rejecting
Kind::GiftWrapdue to abuse, Mostro becomes inoperable.Current State Analysis
NIP-59 Coupling Points
NIP-59 is embedded at three layers:
Layer 1: Event Reception (
src/app.rs— event loop)Layer 2: Message Sending (
src/util.rs—send_dm())All outbound messages flow through
send_dm():src/scheduler.rs:64— message queue flushing (ALL queued messages)src/app/admin_add_solver.rs:53— admin responsessrc/app/admin_cancel.rs:148,162,165— cancel notificationssrc/app/admin_take_dispute.rs:175,195,205— dispute notificationssrc/app/last_trade_index.rs:93— trade index responsesLayer 3: Handler Signatures (
UnwrappedGiftas parameter type)Every action handler receives
event: &UnwrappedGift, directly accessingevent.sender(identity pubkey) andevent.rumor.pubkey(trade pubkey). 28 function signatures across 17 files:src/app.rsmanage_errors,check_trade_index,handle_message_actionevent.sender,event.rumor.pubkey,event.rumor.content,event.rumor.created_atsrc/app/order.rsorder_actionevent.sender,event.rumor.pubkeysrc/app/take_sell.rstake_sell_actionevent.sender,event.rumor.pubkeysrc/app/take_buy.rstake_buy_actionevent.sender,event.rumor.pubkeysrc/app/cancel.rsevent.rumor.pubkey(extensively)src/app/release.rsrelease_actionevent.rumor.pubkeysrc/app/fiat_sent.rsfiat_sent_actionevent.rumor.pubkeysrc/app/add_invoice.rsadd_invoice_actionevent.rumor.pubkeysrc/app/dispute.rsdispute_actionevent.rumor.pubkeysrc/app/rate_user.rsupdate_user_reputation_actionevent.rumor.pubkeysrc/app/admin_cancel.rsadmin_cancel_actionevent.sender,event.rumor.pubkeysrc/app/admin_settle.rsadmin_settle_actionevent.sender,event.rumor.pubkeysrc/app/admin_add_solver.rsadmin_add_solver_actionevent.sender,event.rumor.pubkeysrc/app/admin_take_dispute.rsadmin_take_dispute_actionevent.sendersrc/app/trade_pubkey.rstrade_pubkey_actionevent.sender,event.rumor.pubkeysrc/app/restore_session.rsrestore_session_actionevent.sender,event.rumor.pubkeysrc/app/last_trade_index.rslast_trade_indexevent.sender,event.rumor.pubkeysrc/app/orders.rsorders_actionevent.sender,event.rumor.pubkeysrc/util.rssettle_seller_hold_invoiceevent.rumor.pubkeysrc/rpc/service.rsUnwrappedGiftWhat's Already Clean
mostro-core::Message: Fully transport-agnostic. JSON struct with no NIP-59 knowledge.flow.rs,db.rs,lightning/): Zero NIP-59 coupling.nip33.rs: Addressable events (order book, ratings) — independent of messaging transport.scheduler.rs: Only touches NIP-59 viasend_dm()in message queue flushing.Implementation Plan
Phase 1: Transport Abstraction (no behavior change)
Goal: Replace all
UnwrappedGiftusage with a transport-agnosticIncomingMessagetype and abstract send/receive behind a trait. Zero protocol changes, identical behavior.Step 1.1: Define the abstraction (
src/transport/mod.rs)Create a new
transportmodule:Step 1.2: Implement
GiftWrapTransport(src/transport/gift_wrap.rs)Encapsulate current NIP-59 logic:
Step 1.3: Refactor
send_dm()→ use transport (src/util.rs)Current
send_dm()(lines 511-549) becomes a thin wrapper:No callers change —
send_dm()signature stays identical.Step 1.4: Refactor event loop (
src/app.rs:run())Current loop (lines 305-375) hardcodes
Kind::GiftWrapandnip59::extract_rumor. Refactor to use transport:Step 1.5: Replace
UnwrappedGiftwithIncomingMessagein all handlersThis is the largest mechanical change. For each of the 28 functions:
event: &UnwrappedGift→event: &IncomingMessageevent.sender→event.sender(same field name, no change needed)event.rumor.pubkey→event.trade_pubkeyevent.rumor.content→event.contentevent.rumor.created_at→event.created_atuse nostr::nips::nip59::UnwrappedGift, adduse crate::transport::IncomingMessageDetailed file-by-file changes:
src/app.rsrun(), updatemanage_errors,check_trade_index,handle_message_actionsignaturessrc/app/order.rsevent.rumor.pubkey→event.trade_pubkey,event.senderstayssrc/app/take_sell.rssrc/app/take_buy.rssrc/app/cancel.rsevent.rumor.pubkey→event.trade_pubkeysrc/app/release.rsevent.rumor.pubkeysrc/app/fiat_sent.rsevent.rumor.pubkeyin check and payloadsrc/app/add_invoice.rsevent.rumor.pubkeychecksrc/app/dispute.rsevent.rumor.pubkeyfor sender checksrc/app/rate_user.rsevent.rumor.pubkeyfor ratingsrc/app/admin_cancel.rsevent.senderandevent.rumor.pubkeysrc/app/admin_settle.rssrc/app/admin_add_solver.rsevent.sendercheck +event.rumor.pubkeyfor responsesrc/app/admin_take_dispute.rsevent.senderextensivelysrc/app/trade_pubkey.rssrc/app/restore_session.rssrc/app/last_trade_index.rssrc/app/orders.rsevent.senderfor user IDsrc/util.rssettle_seller_hold_invoicesrc/rpc/service.rsUnwrappedGift→ constructIncomingMessageStep 1.6: Update subscription in
main.rsStep 1.7: Transport initialization
Add to config (
settings.toml):Initialize in
main.rs:Estimated scope: ~200-300 lines of new code (transport module), ~150-200 lines of mechanical replacements across handlers. Total: ~400-500 lines changed.
Phase 2: Alternative Transport — NIP-04 + NIP-44
Goal: Implement
DirectEncryptedTransportas a ready-to-use fallback.Design: NIP-04 structure + NIP-44 encryption
Kind::Custom(4)(NIP-04 structure — sender/receiver visible to relays)(Message, Option<Signature>)JSON tuple, encrypted with NIP-44Step 2.2: Dual-Mode Transport
For migration periods, Mostro can accept both formats:
Step 2.3: Protocol Documentation Update
Update
protocol/src/overview.mdto document:Estimated scope: ~300-400 lines of new code.
Phase 3: Anti-Spam Hardening (defense in depth)
Goal: Regardless of transport, add application-level defenses against spam.
Step 3.1: Enhanced POW management
Make POW dynamically adjustable without restart:
Step 3.2: Bloom filter for processed events
Prevent re-processing of events seen in the current session:
Step 3.3: Circuit breaker
If spam rate exceeds threshold, temporarily pause processing and alert:
Step 3.4: Metrics and observability
Add counters for monitoring:
messages_received_total(by transport type)messages_decrypted_successmessages_decrypted_failure(potential spam indicator)messages_processed_total(by action type)pow_current_difficultyEstimated scope: ~200-300 lines of new code.
File Impact Summary
src/transport/mod.rssrc/transport/gift_wrap.rssrc/transport/direct_encrypted.rssrc/transport/dual.rssrc/main.rssrc/app.rssrc/util.rssrc/app/*.rs(17 files)src/rpc/service.rssrc/config/settings.rssrc/config/types.rssrc/lib.rsMigration Strategy
Client developers would need to support the alternative transport, but the
Messageprotocol layer remains identical — only the Nostr event wrapping changes.Priority
Acceptance Criteria
UnwrappedGifttype no longer appears outsidesrc/transport/gift_wrap.rs.DirectEncryptedTransportpasses same test suite.DualTransportcorrectly handles mixed traffic.protocol/repository reflecting both transport options.