diff --git a/docs/NOSTR_EXCHANGE_RATES.md b/docs/NOSTR_EXCHANGE_RATES.md new file mode 100644 index 00000000..93a20312 --- /dev/null +++ b/docs/NOSTR_EXCHANGE_RATES.md @@ -0,0 +1,255 @@ +# Nostr-Based Exchange Rates + +## Overview + +Mostro daemon publishes Bitcoin/fiat exchange rates to Nostr relays as NIP-33 addressable events (kind `30078`). This enables: + +- **Censorship resistance** — Mobile clients in censored regions (Venezuela, Cuba, etc.) can fetch rates via Nostr +- **Zero scaling cost** — Relays distribute events; no per-request infrastructure needed +- **Backward compatibility** — HTTP API remains available as fallback + +--- + +## Event Structure + +### Kind 30078 (NIP-33 Addressable Event) + +```json +{ + "kind": 30078, + "pubkey": "82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390", + "created_at": 1732546800, + "tags": [ + ["d", "mostro-rates"], + ["published_at", "1732546800"], + ["source", "yadio"], + ["expiration", "1732550400"] + ], + "content": "{\"BTC\": {\"USD\": 50000.0, \"EUR\": 45000.0, \"ARS\": 105000000.0, ...}}", + "sig": "..." +} +``` + +### Fields + +- **kind:** `30078` (application-specific data, NIP-33 replaceable) +- **pubkey:** Mostro daemon's public key (same key that signs orders) +- **d tag:** `"mostro-rates"` (NIP-33 identifier — replaces previous rate events) +- **published_at tag:** Unix timestamp when daemon published the event (not source timestamp) +- **source tag:** `"yadio"` (indicates rate source) +- **expiration tag:** Unix timestamp for event expiration (NIP-40) — prevents stale rates +- **content:** JSON-encoded rates in format `{"CURRENCY": price, ...}` + +### Content Format + +The `content` field contains the full Yadio API response structure: + +```json +{ + "BTC": { + "BTC": 1, + "USD": 50000.0, + "EUR": 45000.0, + "VES": 850000000.0, + "ARS": 105000000.0, + "AED": 260491.35, + "..." + } +} +``` + +**Format:** Identical to Yadio API response (`/exrates/BTC`). + +**Rate semantics:** Each value under `"BTC"` represents the price of 1 BTC in that currency. + +**Example:** `"BTC": {"USD": 50000.0}` means 1 BTC = 50,000 USD. + +--- + +## Configuration + +### Enable/Disable Publishing + +Add to `settings.toml`: + +```toml +[mostro] +# ... existing config ... + +# Publish exchange rates to Nostr (default: true) +publish_exchange_rates_to_nostr = true + +# Exchange rates update interval in seconds (default: 300 = 5 minutes) +exchange_rates_update_interval_seconds = 300 +``` + +**Defaults:** +- `publish_exchange_rates_to_nostr`: `true` (enabled for censorship resistance) +- `exchange_rates_update_interval_seconds`: `300` (5 minutes) + +### Update Frequency + +Exchange rates are fetched from Yadio API and published to Nostr based on the configured `exchange_rates_update_interval_seconds` value. + +**Recommended values:** +- **Production:** `300` (5 minutes) — balances freshness with API rate limits +- **Development:** `60` (1 minute) — faster testing +- **Low-volume instances:** `600` (10 minutes) — reduces API calls + +**Note:** Very short intervals (<60s) may hit Yadio API rate limits. + +--- + +## Implementation Details + +### Code Flow + +1. **Scheduler** (`scheduler.rs`): `job_update_bitcoin_prices()` runs at intervals configured by `exchange_rates_update_interval_seconds` (default: 300 seconds) +2. **BitcoinPriceManager** (`bitcoin_price.rs`): + - Fetches rates from Yadio HTTP API + - Updates in-memory cache + - If `publish_exchange_rates_to_nostr == true`: + - Transforms rates to expected JSON format + - Creates NIP-33 event (kind `30078`) + - Publishes to configured Nostr relays + +3. **Event Creation** (`nip33.rs`): `new_exchange_rates_event()` creates the signed event + +### Error Handling + +- **Yadio API failure** → Logs warning, skips update (keeps previous rates valid) +- **Nostr publish failure** → Logs error but doesn't fail the update job +- **Event creation failure** → Logs error but doesn't crash daemon + +**Philosophy:** Nostr publishing is best-effort; HTTP API remains the source of truth. + +--- + +## Security Considerations + +### Event Verification (Client-Side) + +Mobile clients **MUST** verify the event `pubkey` matches the connected Mostro instance's pubkey to prevent price manipulation attacks. + +**Attack scenario:** Malicious actor publishes fake rates to influence order creation. + +**Mitigation:** Clients only accept rate events signed by their connected Mostro instance. + +See: [Mobile client spec](https://github.com/MostroP2P/app/blob/main/.specify/NOSTR_EXCHANGE_RATES.md) + +### Relay Security + +- Events are signed with Mostro's private key (standard NIP-01 signature verification) +- NIP-33 addressable events: newer events replace older ones (prevents stale data) +- **NIP-40 expiration:** Events expire after 1 hour (relays should delete them) +- No sensitive data in events (all rates are public information) + +--- + +## Testing + +### Unit Tests + +```bash +cargo test bitcoin_price +``` + +**Coverage:** +- Yadio API response deserialization +- Rate format transformation (`{"USD": 0.024}` → `{"USD": {"BTC": 0.024}}`) +- JSON serialization for Nostr event content + +### Integration Testing + +1. Start Mostro daemon with `publish_exchange_rates_to_nostr = true` +2. Wait 5 minutes (or trigger update manually) +3. Query relay for kind `30078` events from Mostro pubkey: + +```bash +# Using nak CLI +nak req -k 30078 -a --tag d=mostro-rates wss://relay.mostro.network +``` + +**Expected output:** JSON event with current exchange rates + +### Manual Testing + +```bash +# Subscribe to rate updates +nostcat -sub -k 30078 -a wss://relay.mostro.network + +# Verify content format +echo '' | jq . +# Should output: {"BTC": {"USD": 50000.0, "EUR": 45000.0, ...}} +``` + +--- + +## Deployment + +### Production Checklist + +- [ ] Verify `publish_exchange_rates_to_nostr` config in `settings.toml` +- [ ] Set `exchange_rates_update_interval_seconds` (default: 300) +- [ ] Confirm Nostr relays are reachable from daemon +- [ ] Monitor logs for "Starting Bitcoin price update job (interval: Xs)" on startup +- [ ] Monitor logs for "Exchange rates published to Nostr" messages +- [ ] Test client-side rate fetching from Nostr +- [ ] Verify fallback to HTTP API works if Nostr unavailable + +### Monitoring + +**Success indicators:** +```text +INFO Exchange rates published to Nostr. Event ID: ( currencies) +``` + +**Error indicators:** +```text +ERROR Failed to publish exchange rates to Nostr: +ERROR Failed to send exchange rates event to relays: +``` + +--- + +## Related Documentation + +- [NIP-33: Parameterized Replaceable Events](https://github.com/nostr-protocol/nips/blob/master/33.md) +- [Mobile Client Spec](https://github.com/MostroP2P/app/blob/main/.specify/NOSTR_EXCHANGE_RATES.md) +- [Issue #684: Feature Proposal](https://github.com/MostroP2P/mostro/issues/684) + +--- + +## Future Enhancements + +### Multi-Source Aggregation + +Aggregate rates from multiple sources (Yadio, CoinGecko, Binance): + +```toml +[mostro] +exchange_rate_sources = ["yadio", "coingecko", "binance"] +``` + +Publish average or median rates to reduce single-source dependency. + +### Rate History + +Store historical rates in database: + +```sql +CREATE TABLE exchange_rate_history ( + timestamp INTEGER PRIMARY KEY, + currency TEXT NOT NULL, + btc_rate REAL NOT NULL, + source TEXT NOT NULL +); +``` + +Publish daily/weekly summaries as separate NIP-33 events. + +### Custom Event Kinds + +Propose standardized Nostr event kind for exchange rates (currently using generic `30078`). + +**Draft NIP:** "Exchange Rate Events" (kind TBD, e.g., `30400`) diff --git a/src/bitcoin_price.rs b/src/bitcoin_price.rs index 91dcd7b8..f686b28c 100644 --- a/src/bitcoin_price.rs +++ b/src/bitcoin_price.rs @@ -1,11 +1,15 @@ use crate::config::settings::Settings; use crate::lnurl::HTTP_CLIENT; +use crate::nip33::new_exchange_rates_event; +use crate::util::{get_keys, get_nostr_client}; +use chrono::Utc; use mostro_core::prelude::*; +use nostr_sdk::prelude::*; use once_cell::sync::Lazy; use serde::Deserialize; use std::collections::HashMap; use std::sync::RwLock; -use tracing::info; +use tracing::{error, info}; #[derive(Debug, Deserialize)] struct YadioResponse { @@ -36,10 +40,93 @@ impl BitcoinPriceManager { yadio_response.btc.keys().collect::>().len() ); - let mut prices_write = BITCOIN_PRICES - .write() - .map_err(|e| MostroInternalErr(ServiceError::IOError(e.to_string())))?; - *prices_write = yadio_response.btc; + // Clone rates before acquiring lock to avoid holding it across await + let rates_clone = yadio_response.btc.clone(); + + { + let mut prices_write = BITCOIN_PRICES + .write() + .map_err(|e| MostroInternalErr(ServiceError::IOError(e.to_string())))?; + *prices_write = rates_clone.clone(); + } // Lock is dropped here + + // Publish rates to Nostr if enabled (after releasing the lock) + if mostro_settings.publish_exchange_rates_to_nostr { + if let Err(e) = Self::publish_rates_to_nostr(&rates_clone).await { + error!("Failed to publish exchange rates to Nostr: {}", e); + // Don't fail the entire update if Nostr publishing fails + } + } + + Ok(()) + } + + /// Publishes exchange rates to Nostr as a NIP-33 addressable event (kind 30078) + async fn publish_rates_to_nostr(rates: &HashMap) -> Result<(), MostroError> { + let keys = get_keys().map_err(|e| { + error!("Failed to get Mostro keys: {}", e); + MostroInternalErr(ServiceError::IOError(e.to_string())) + })?; + + // Publish in Yadio's exact format: {"BTC": {"USD": 50000.0, "EUR": 45000.0, ...}} + // This matches their API response structure + let mut wrapper = HashMap::new(); + wrapper.insert("BTC".to_string(), rates.clone()); + let formatted_rates = wrapper; + + let content = serde_json::to_string(&formatted_rates) + .map_err(|_| MostroInternalErr(ServiceError::MessageSerializationError))?; + + let timestamp = Utc::now().timestamp(); + + // Expiration should be at least 2x the update interval to allow for delays + // Cap at 1 hour to prevent stale data + // Note: We read settings here (instead of passing from scheduler) to ensure + // expiration stays aligned with interval if config is reloaded at runtime + let mostro_settings = Settings::get_mostro(); + let update_interval = mostro_settings.exchange_rates_update_interval_seconds; + let expiration_seconds = std::cmp::min(update_interval * 2, 3600); + let expiration = timestamp + expiration_seconds as i64; + + let tags = Tags::from_list(vec![ + Tag::custom( + TagKind::Custom("published_at".into()), + vec![timestamp.to_string()], + ), + Tag::custom(TagKind::Custom("source".into()), vec!["yadio".to_string()]), + Tag::expiration(Timestamp::from(expiration as u64)), + ]); + + let event = new_exchange_rates_event(&keys, &content, tags).map_err(|e| { + error!("Failed to create exchange rates event: {}", e); + MostroInternalErr(ServiceError::MessageSerializationError) + })?; + + let client = get_nostr_client().map_err(|e| { + error!("Failed to get Nostr client: {}", e); + e + })?; + + // Publish with timeout to avoid blocking the scheduler + // Best-effort: log errors but don't fail the update job + let timeout_duration = std::time::Duration::from_secs(30); + match tokio::time::timeout(timeout_duration, client.send_event(&event)).await { + Ok(Ok(output)) => { + info!( + "Exchange rates published to Nostr ({} currencies). Output: {:?}", + rates.len(), + output + ); + } + Ok(Err(e)) => { + error!("Failed to send exchange rates event to relays: {}", e); + } + Err(_) => { + error!("Timeout publishing exchange rates to Nostr (30s exceeded)"); + } + } + + // Always return Ok - publishing is best-effort Ok(()) } @@ -59,6 +146,44 @@ mod tests { use super::*; use std::collections::HashMap; + #[test] + fn test_rates_structure() { + // Test that Yadio rates are wrapped correctly + let mut input_rates = HashMap::new(); + input_rates.insert("USD".to_string(), 50000.0); + input_rates.insert("EUR".to_string(), 45000.0); + + // Wrap in Yadio format: {"BTC": {...}} + let mut wrapper = HashMap::new(); + wrapper.insert("BTC".to_string(), input_rates.clone()); + + assert_eq!(wrapper.len(), 1); + assert!(wrapper.contains_key("BTC")); + assert_eq!(wrapper.get("BTC").unwrap().get("USD"), Some(&50000.0)); + assert_eq!(wrapper.get("BTC").unwrap().get("EUR"), Some(&45000.0)); + } + + #[test] + fn test_rates_json_serialization() { + // Test that rates can be serialized to Yadio format + // Use only fiat currencies (Yadio includes BTC in the wrapper, not in the rates map) + let mut input_rates = HashMap::new(); + input_rates.insert("USD".to_string(), 50000.0); + input_rates.insert("EUR".to_string(), 45000.0); + + let mut wrapper = HashMap::new(); + wrapper.insert("BTC".to_string(), input_rates); + + let json = serde_json::to_string(&wrapper).unwrap(); + assert!(json.contains("\"BTC\"")); + assert!(json.contains("\"USD\"")); + assert!(json.contains("50000")); + assert!(json.contains("\"EUR\"")); + assert!(json.contains("45000")); + // Ensure we don't have nested BTC key (would be invalid) + assert!(!json.contains("\"BTC\":1")); + } + #[test] fn test_yadio_response_deserialization() { // Test that we can deserialize the expected API response format diff --git a/src/config/constants.rs b/src/config/constants.rs index e6a5f28c..2b4ad5a3 100644 --- a/src/config/constants.rs +++ b/src/config/constants.rs @@ -12,3 +12,8 @@ pub const DEV_FEE_LIGHTNING_ADDRESS: &str = "pivotaldeborah52@walletofsatoshi.co /// Kind 8383 is in the regular events range (1000-9999) per NIP-01 /// This ensures events are NOT replaceable, maintaining complete audit history pub const DEV_FEE_AUDIT_EVENT_KIND: u16 = 8383; + +/// Nostr event kind for exchange rates (NIP-33 addressable event) +/// Kind 30078 is in the replaceable events range (30000-39999) per NIP-33 +/// This allows the same Mostro instance to publish updated rates that replace previous events +pub const NOSTR_EXCHANGE_RATES_EVENT_KIND: u16 = 30078; diff --git a/src/config/types.rs b/src/config/types.rs index 748e38a6..e45b653d 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -202,6 +202,20 @@ pub struct MostroSettings { pub picture: Option, /// NIP-01 kind 0 metadata: operator website URL pub website: Option, + /// Publish exchange rates to Nostr (kind 30078, NIP-33) + #[serde(default = "default_publish_exchange_rates")] + pub publish_exchange_rates_to_nostr: bool, + /// Exchange rates update interval in seconds (default: 300 = 5 minutes) + #[serde(default = "default_exchange_rates_update_interval")] + pub exchange_rates_update_interval_seconds: u64, +} + +fn default_publish_exchange_rates() -> bool { + true // Enable by default for censorship resistance +} + +fn default_exchange_rates_update_interval() -> u64 { + 300 // 5 minutes } impl Default for MostroSettings { @@ -231,6 +245,8 @@ impl Default for MostroSettings { about: None, picture: None, website: None, + publish_exchange_rates_to_nostr: default_publish_exchange_rates(), + exchange_rates_update_interval_seconds: default_exchange_rates_update_interval(), } } } diff --git a/src/nip33.rs b/src/nip33.rs index e8e2ce7d..eadc6b00 100644 --- a/src/nip33.rs +++ b/src/nip33.rs @@ -1,3 +1,4 @@ +use crate::config::constants::NOSTR_EXCHANGE_RATES_EVENT_KIND; use crate::config::settings::Settings; use crate::lightning::LnStatus; use crate::util::{get_expiration_timestamp_for_kind, get_keys}; @@ -139,6 +140,49 @@ pub fn new_dispute_event( ) } +/// Creates a new exchange rates event (kind 30078, NIP-33) +/// +/// This event publishes Bitcoin/fiat exchange rates to Nostr relays, +/// enabling censorship-resistant rate fetching for mobile clients. +/// +/// # Arguments +/// +/// * `keys` - The keys used to sign the event (Mostro's keypair) +/// * `content` - JSON-encoded exchange rates in Yadio format (e.g., `{"BTC": {"USD": 50000.0, ...}}`) +/// * `extra_tags` - Additional tags for the event (e.g., `updated_at`, `source`) +/// +/// # Returns +/// Returns a new exchange rates event or an error +/// +/// # Example +/// +/// ```ignore +/// use std::collections::HashMap; +/// // Wrap rates in Yadio format: {"BTC": {"USD": 50000.0, ...}} +/// let mut wrapper = HashMap::new(); +/// wrapper.insert("BTC".to_string(), bitcoin_prices.clone()); +/// let content = serde_json::to_string(&wrapper)?; +/// let tags = Tags::from_list(vec![ +/// Tag::custom(TagKind::Custom("published_at".into()), vec![timestamp.to_string()]), +/// Tag::custom(TagKind::Custom("source".into()), vec!["yadio".to_string()]), +/// Tag::expiration(Timestamp::from(expiration)), +/// ]); +/// let event = new_exchange_rates_event(&keys, &content, tags)?; +/// ``` +pub fn new_exchange_rates_event( + keys: &Keys, + content: &str, + extra_tags: Tags, +) -> Result { + create_event( + keys, + content, + "mostro-rates".to_string(), // NIP-33 d tag identifier + extra_tags, + NOSTR_EXCHANGE_RATES_EVENT_KIND, + ) +} + /// Create a rating tag /// /// # Arguments diff --git a/src/scheduler.rs b/src/scheduler.rs index 8179732a..ef972136 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -468,12 +468,32 @@ async fn job_expire_pending_older_orders(ctx: AppContext) { async fn job_update_bitcoin_prices() { tokio::spawn(async { + let mostro_settings = Settings::get_mostro(); + let configured_interval = mostro_settings.exchange_rates_update_interval_seconds; + + // Validate interval: minimum 60 seconds to avoid API rate limits + const MIN_INTERVAL: u64 = 60; + let update_interval = if configured_interval < MIN_INTERVAL { + error!( + "exchange_rates_update_interval_seconds too low: {}s (minimum: {}s). Using minimum.", + configured_interval, MIN_INTERVAL + ); + MIN_INTERVAL + } else { + configured_interval + }; + + info!( + "Starting Bitcoin price update job (interval: {}s)", + update_interval + ); + loop { info!("Updating Bitcoin prices"); if let Err(e) = BitcoinPriceManager::update_prices().await { error!("Failed to update Bitcoin prices: {}", e); } - tokio::time::sleep(tokio::time::Duration::from_secs(300)).await; + tokio::time::sleep(tokio::time::Duration::from_secs(update_interval)).await; } }); }