diff --git a/docs/NOSTR_EXCHANGE_RATES.md b/docs/NOSTR_EXCHANGE_RATES.md new file mode 100644 index 00000000..82d1c1fe --- /dev/null +++ b/docs/NOSTR_EXCHANGE_RATES.md @@ -0,0 +1,61 @@ +# Nostr Exchange Rates + +## Overview + +Mostro mobile fetches Bitcoin/fiat exchange rates from Nostr relays (NIP-33 kind `30078`), with automatic fallback to the Yadio HTTP API and a local SharedPreferences cache. + +This solves the censorship problem: the Yadio API is blocked in Venezuela and other regions, but Nostr relays are accessible. + +## How It Works + +```text +Request rate for USD + │ + ▼ + ┌─ In-memory cache hit? ──→ return rate + │ │ miss + │ ▼ + │ ┌─ Nostr (10s timeout) ──→ cache + return + │ │ │ fail + │ │ ▼ + │ │ ┌─ Yadio HTTP (30s) ──→ cache + return + │ │ │ │ fail + │ │ │ ▼ + │ │ │ SharedPreferences (<1h old) ──→ return + │ │ │ │ miss/stale + │ │ │ ▼ + │ │ │ throw Exception +``` + +## Nostr Event + +The daemon publishes a NIP-33 addressable event: + +- **Kind:** `30078` +- **d tag:** `"mostro-rates"` +- **Content:** `{"BTC": {"USD": 50000.0, "EUR": 45000.0, ...}}` +- **Pubkey:** Mostro instance signing key + +## Security + +The client verifies event origin by comparing `event.pubkey == settings.mostroPublicKey` before parsing rates. This prevents price manipulation attacks from malicious actors publishing fake events via untrusted relays. + +## Files + +| File | Description | +|------|-------------| +| `lib/services/nostr_exchange_service.dart` | Main service: Nostr → HTTP → cache fallback | +| `lib/shared/providers/exchange_service_provider.dart` | Provider wiring (updated) | +| `test/services/nostr_exchange_service_test.dart` | Unit tests for rate parsing | + +## Configuration + +No new configuration needed. The service uses: +- `settings.mostroPublicKey` — to verify event pubkey matches the connected Mostro instance +- The same relay list configured for Mostro orders + +## References + +- [Mostro daemon PR #685](https://github.com/MostroP2P/mostro/pull/685) +- [NIP-33: Parameterized Replaceable Events](https://github.com/nostr-protocol/nips/blob/master/33.md) +- [Issue #550](https://github.com/MostroP2P/mobile/issues/550) diff --git a/lib/services/nostr_exchange_service.dart b/lib/services/nostr_exchange_service.dart new file mode 100644 index 00000000..d748e69f --- /dev/null +++ b/lib/services/nostr_exchange_service.dart @@ -0,0 +1,299 @@ +import 'dart:convert'; +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:mostro_mobile/services/exchange_service.dart'; +import 'package:mostro_mobile/services/nostr_service.dart'; +import 'package:mostro_mobile/services/yadio_exchange_service.dart'; +import 'package:mostro_mobile/services/logger_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Exchange rate event kind (NIP-33 addressable event). +const int _exchangeRatesEventKind = 30078; + +/// NIP-33 d-tag identifier used by Mostro daemon. +const String _exchangeRatesDTag = 'mostro-rates'; + +/// Key used to persist the latest rates JSON in SharedPreferences. +const String _cacheKey = 'exchange_rates_cache'; + +/// Key used to persist the cache timestamp (milliseconds since epoch). +const String _cacheTimestampKey = 'exchange_rates_cache_ts'; + +/// Maximum age of cached rates before they are considered stale (1 hour). +const Duration _maxCacheAge = Duration(hours: 1); + +/// Exchange service that fetches rates from Nostr (NIP-33 kind 30078), +/// falling back to Yadio HTTP API, then to a local SharedPreferences cache. +/// +/// The service verifies that events originate from the connected Mostro +/// instance by comparing event.pubkey to settings.mostroPublicKey. +class NostrExchangeService extends ExchangeService { + final NostrService _nostrService; + final String _mostroPubkey; + final YadioExchangeService _yadioFallback; + + /// In-memory cache of all BTC→fiat rates from the last successful fetch. + /// Keys are uppercase currency codes ("USD", "EUR", …), values are the + /// price of 1 BTC in that currency. + Map? _cachedRates; + + /// Timestamp when [_cachedRates] was last populated. + /// Used to enforce the same 1-hour freshness as SharedPreferences. + DateTime? _cachedRatesFetchedAt; + + NostrExchangeService({ + required NostrService nostrService, + required String mostroPubkey, + }) : _nostrService = nostrService, + _mostroPubkey = mostroPubkey, + _yadioFallback = YadioExchangeService(), + super('https://api.yadio.io/'); + + // ── ExchangeService interface ────────────────────────────────────── + + @override + Future getExchangeRate(String fromCurrency, String toCurrency) async { + if (fromCurrency.isEmpty || toCurrency.isEmpty) { + throw ArgumentError('Currency codes cannot be empty'); + } + + // If we already have fresh rates in memory, return immediately. + final cached = _cachedRates; + final fetchedAt = _cachedRatesFetchedAt; + if (cached != null && + fetchedAt != null && + DateTime.now().difference(fetchedAt) < _maxCacheAge && + cached.containsKey(fromCurrency)) { + return cached[fromCurrency]!; + } + + // Otherwise fetch a full set and extract the requested pair. + await _refreshRates(); + + final rate = _cachedRates?[fromCurrency]; + if (rate == null) { + throw Exception('Rate not found for $fromCurrency'); + } + return rate; + } + + @override + Future> getCurrencyCodes() { + // Currency codes come from the bundled asset, not from rates. + // Delegate to Yadio only as a last resort; the provider already + // loads from assets/data/fiat.json (see currencyCodesProvider). + return _yadioFallback.getCurrencyCodes(); + } + + // ── Internal ─────────────────────────────────────────────────────── + + /// Try each source in order: Nostr → HTTP → SharedPreferences cache. + Future _refreshRates() async { + // 1. Nostr + try { + final rates = await _fetchFromNostr().timeout( + const Duration(seconds: 10), + ); + _cachedRates = rates; + _cachedRatesFetchedAt = DateTime.now(); + await _persistToCache(rates); + return; + } catch (e) { + logger.w('Nostr exchange rates failed: $e'); + } + + // 2. Yadio HTTP + try { + final rates = await _fetchFromYadio().timeout( + const Duration(seconds: 30), + ); + _cachedRates = rates; + _cachedRatesFetchedAt = DateTime.now(); + await _persistToCache(rates); + return; + } catch (e) { + logger.w('Yadio HTTP exchange rates failed: $e'); + } + + // 3. SharedPreferences cache + final result = await _loadFromCache(); + if (result != null) { + logger.i('Using cached exchange rates'); + _cachedRates = result.rates; + // Preserve the original persisted timestamp, not DateTime.now() + _cachedRatesFetchedAt = result.fetchedAt; + return; + } + + // All sources failed — clear stale in-memory cache + _cachedRates = null; + _cachedRatesFetchedAt = null; + + throw Exception( + 'Failed to fetch exchange rates from all sources (Nostr, HTTP, cache)', + ); + } + + /// Fetch rates from Nostr by querying for the latest kind 30078 event + /// signed by the connected Mostro instance. + Future> _fetchFromNostr() async { + final filter = NostrFilter( + kinds: [_exchangeRatesEventKind], + authors: [_mostroPubkey], + limit: 1, + additionalFilters: { + '#d': [_exchangeRatesDTag], + }, + ); + + final events = await _nostrService.fetchEvents(filter); + + if (events.isEmpty) { + throw Exception('No exchange rate event found on relays'); + } + + // Filter events to only include those with correct kind and d-tag. + // Defense-in-depth: relays may return events that don't match the filter. + final validEvents = events.where((event) { + // Verify kind + if (event.kind != _exchangeRatesEventKind) return false; + + // Verify d-tag + final tags = event.tags; + if (tags == null) return false; + final hasDTag = tags.any( + (tag) => + tag.length >= 2 && tag[0] == 'd' && tag[1] == _exchangeRatesDTag, + ); + if (!hasDTag) return false; + + // Verify pubkey + if (event.pubkey != _mostroPubkey) return false; + + return true; + }).toList(); + + if (validEvents.isEmpty) { + throw Exception( + 'No valid exchange rate event found (kind=$_exchangeRatesEventKind, ' + 'd-tag=$_exchangeRatesDTag, pubkey=$_mostroPubkey)', + ); + } + + // Take the most recent valid event. + final event = validEvents.reduce((a, b) { + final aTime = a.createdAt ?? DateTime.fromMillisecondsSinceEpoch(0); + final bTime = b.createdAt ?? DateTime.fromMillisecondsSinceEpoch(0); + return aTime.isAfter(bTime) ? a : b; + }); + + return parseRatesContent(event.content ?? ''); + } + + /// Fetch all BTC rates from Yadio HTTP API and return them as a map. + Future> _fetchFromYadio() async { + final data = await getRequest('exrates/BTC'); + + final btcRates = data['BTC']; + if (btcRates is! Map) { + throw Exception('Unexpected Yadio response format'); + } + + final rates = {}; + for (final entry in btcRates.entries) { + if (entry.key == 'BTC') continue; // skip BTC→BTC = 1 + final value = entry.value; + if (value is num) { + rates[entry.key as String] = value.toDouble(); + } + } + + if (rates.isEmpty) { + throw Exception('No usable rates from Yadio response'); + } + + return rates; + } + + /// Parse the JSON content of a Nostr exchange rates event. + /// + /// Expected format: `{"BTC": {"USD": 50000.0, "EUR": 45000.0, ...}}` + /// + /// Exposed as public static for testability. + static Map parseRatesContent(String content) { + final decoded = jsonDecode(content); + if (decoded is! Map) { + throw const FormatException('Expected JSON object'); + } + + final btcRates = decoded['BTC']; + if (btcRates is! Map) { + throw const FormatException('Missing or invalid "BTC" key'); + } + + final rates = {}; + for (final entry in btcRates.entries) { + if (entry.key == 'BTC') continue; + final value = entry.value; + if (value is num) { + rates[entry.key] = value.toDouble(); + } + } + + if (rates.isEmpty) { + throw const FormatException('No valid rates found'); + } + + return rates; + } + + // ── SharedPreferences cache ──────────────────────────────────────── + + Future _persistToCache(Map rates) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_cacheKey, jsonEncode(rates)); + await prefs.setInt( + _cacheTimestampKey, + DateTime.now().millisecondsSinceEpoch, + ); + } catch (e) { + logger.w('Failed to cache exchange rates: $e'); + } + } + + Future<_CacheResult?> _loadFromCache() async { + try { + final prefs = await SharedPreferences.getInstance(); + final json = prefs.getString(_cacheKey); + final ts = prefs.getInt(_cacheTimestampKey); + + if (json == null || ts == null) return null; + + final fetchedAt = DateTime.fromMillisecondsSinceEpoch(ts); + + // Check staleness + final age = DateTime.now().difference(fetchedAt); + if (age > _maxCacheAge) { + logger.w('Cached exchange rates too old (${age.inMinutes} min)'); + return null; + } + + final decoded = jsonDecode(json); + if (decoded is! Map) return null; + + final rates = decoded.map((k, v) => MapEntry(k, (v as num).toDouble())); + return _CacheResult(rates: rates, fetchedAt: fetchedAt); + } catch (e) { + logger.w('Failed to load cached exchange rates: $e'); + return null; + } + } +} + +/// Internal helper to bundle cached rates with their original timestamp. +class _CacheResult { + final Map rates; + final DateTime fetchedAt; + + const _CacheResult({required this.rates, required this.fetchedAt}); +} diff --git a/lib/shared/providers/exchange_service_provider.dart b/lib/shared/providers/exchange_service_provider.dart index 93514127..6d442ec2 100644 --- a/lib/shared/providers/exchange_service_provider.dart +++ b/lib/shared/providers/exchange_service_provider.dart @@ -2,27 +2,40 @@ import 'dart:convert'; import 'package:flutter/services.dart' show rootBundle; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/models/currency.dart'; +import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/services/exchange_service.dart'; -import 'package:mostro_mobile/services/yadio_exchange_service.dart'; +import 'package:mostro_mobile/services/nostr_exchange_service.dart'; +import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; final exchangeServiceProvider = Provider((ref) { - return YadioExchangeService(); + final nostrService = ref.watch(nostrServiceProvider); + final settings = ref.watch(settingsProvider); + return NostrExchangeService( + nostrService: nostrService, + mostroPubkey: settings.mostroPublicKey, + ); }); -final exchangeRateProvider = StateNotifierProvider.family, String>((ref, currency) { - final exchangeService = ref.read(exchangeServiceProvider); - final notifier = ExchangeRateNotifier(exchangeService); - notifier.fetchExchangeRate(currency); - return notifier; -}); +final exchangeRateProvider = + StateNotifierProvider.family< + ExchangeRateNotifier, + AsyncValue, + String + >((ref, currency) { + final exchangeService = ref.watch(exchangeServiceProvider); + final notifier = ExchangeRateNotifier(exchangeService); + notifier.fetchExchangeRate(currency); + return notifier; + }); -final currencyCodesProvider = - FutureProvider>((ref) async { +final currencyCodesProvider = FutureProvider>(( + ref, +) async { final raw = await rootBundle.loadString('assets/data/fiat.json'); final jsonMap = json.decode(raw) as Map; - final Map currencies = - jsonMap.map((key, value) => MapEntry(key, Currency.fromJson(value))); + final Map currencies = jsonMap.map( + (key, value) => MapEntry(key, Currency.fromJson(value)), + ); currencies.removeWhere((k, v) => !v.price); return currencies; }); diff --git a/test/services/nostr_exchange_service_test.dart b/test/services/nostr_exchange_service_test.dart new file mode 100644 index 00000000..cccaeed5 --- /dev/null +++ b/test/services/nostr_exchange_service_test.dart @@ -0,0 +1,114 @@ +import 'dart:convert'; +import 'package:test/test.dart'; +import 'package:mostro_mobile/services/nostr_exchange_service.dart'; + +void main() { + group('NostrExchangeService.parseRatesContent', () { + test('parses valid Yadio format correctly', () { + const content = + '{"BTC": {"USD": 50000.0, "EUR": 45000.0, "VES": 850000000.0}}'; + final rates = NostrExchangeService.parseRatesContent(content); + + expect(rates['USD'], 50000.0); + expect(rates['EUR'], 45000.0); + expect(rates['VES'], 850000000.0); + expect(rates.length, 3); + }); + + test('skips BTC→BTC entry', () { + const content = '{"BTC": {"BTC": 1, "USD": 50000.0}}'; + final rates = NostrExchangeService.parseRatesContent(content); + + expect(rates.containsKey('BTC'), isFalse); + expect(rates['USD'], 50000.0); + expect(rates.length, 1); + }); + + test('handles integer values', () { + const content = '{"BTC": {"ARS": 105000000}}'; + final rates = NostrExchangeService.parseRatesContent(content); + + expect(rates['ARS'], 105000000.0); + expect(rates['ARS'], isA()); + }); + + test('throws on missing BTC key', () { + const content = '{"ETH": {"USD": 3000.0}}'; + expect( + () => NostrExchangeService.parseRatesContent(content), + throwsFormatException, + ); + }); + + test('throws on empty rates (only BTC→BTC)', () { + const content = '{"BTC": {"BTC": 1}}'; + expect( + () => NostrExchangeService.parseRatesContent(content), + throwsFormatException, + ); + }); + + test('throws on invalid JSON', () { + expect( + () => NostrExchangeService.parseRatesContent('not json'), + throwsA(anything), + ); + }); + + test('throws on non-object content', () { + expect( + () => NostrExchangeService.parseRatesContent('"just a string"'), + throwsFormatException, + ); + }); + + test('ignores non-numeric values', () { + const content = '{"BTC": {"USD": 50000.0, "INVALID": "not a number"}}'; + final rates = NostrExchangeService.parseRatesContent(content); + + expect(rates['USD'], 50000.0); + expect(rates.containsKey('INVALID'), isFalse); + expect(rates.length, 1); + }); + + test('parses many currencies', () { + final btcRates = { + 'BTC': 1, + 'USD': 50000.0, + 'EUR': 45000.0, + 'GBP': 39000.0, + 'ARS': 105000000, + 'VES': 850000000.0, + 'COP': 210000000, + 'MXN': 850000.0, + 'BRL': 250000.0, + }; + final content = jsonEncode({'BTC': btcRates}); + final rates = NostrExchangeService.parseRatesContent(content); + + // BTC→BTC is skipped + expect(rates.length, btcRates.length - 1); + expect(rates['USD'], 50000.0); + expect(rates['ARS'], 105000000.0); + }); + }); + + group('pubkey verification logic', () { + test('rejects event from wrong pubkey', () { + const expectedPubkey = 'abc123'; + const eventPubkey = 'wrong_pubkey'; + + // Simulate the verification check from NostrExchangeService + expect(eventPubkey == expectedPubkey, isFalse); + }); + + test('accepts event from correct pubkey', () { + const expectedPubkey = + '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; + const eventPubkey = + '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; + + expect(eventPubkey == expectedPubkey, isTrue); + }); + }); +}