-
Notifications
You must be signed in to change notification settings - Fork 24
feat: fetch exchange rates from Nostr (NIP-33, kind 30078) with HTTP fallback #550
Description
Context
Mostro daemon now publishes Bitcoin/fiat exchange rates to Nostr relays as NIP-33 addressable events (kind 30078), implemented in MostroP2P/mostro#685.
Currently, Mostro mobile fetches exchange rates directly from the Yadio HTTP API. This works in most regions, but the API is blocked in Venezuela and other censored regions — exactly the users Mostro is designed to serve.
By fetching rates from Nostr instead, we:
- ✅ Solve censorship — Nostr relays work where HTTP APIs are blocked
- ✅ Eliminate scaling costs — Relays distribute to all clients at no per-request cost
- ✅ Align with architecture — We already have a Nostr connection open; no new infrastructure needed
- ✅ Keep backward compatibility — HTTP API remains as fallback
The full client spec is documented in app/.specify/NOSTR_EXCHANGE_RATES.md.
Event Structure
The daemon publishes events with this structure:
{
"kind": 30078,
"pubkey": "<mostro_pubkey>",
"created_at": 1732546800,
"tags": [
["d", "mostro-rates"],
["published_at", "1732546800"],
["source", "yadio"],
["expiration", "1732550400"]
],
"content": "{\"BTC\": {\"USD\": 50000.0, \"EUR\": 45000.0, \"VES\": 850000000.0, ...}}",
"sig": "..."
}Key properties:
- kind:
30078(NIP-33 replaceable — newer events replace older ones automatically) - d tag:
"mostro-rates"(unique identifier for this event type) - pubkey: Same pubkey that signs all Mostro orders
- content: Full Yadio API response format —
{"BTC": {"CURRENCY": price_in_that_currency, ...}} - expiration tag: NIP-40, expires after ~10 minutes — relays delete stale events
Rate semantics: "BTC": {"USD": 50000.0} means 1 BTC = 50,000 USD.
Security: Pubkey Verification (CRITICAL)
Anyone can publish a kind 30078 event. The client MUST verify event.pubkey == mostro_instance_pubkey before using any rate data.
Attack vector: Malicious actor publishes fake rates → user creates order at a manipulated price → loses sats.
Verification logic:
bool isValidRateEvent(NostrEvent event, String mostroPubkey) {
// 1. Correct kind
if (event.kind != 30078) return false;
// 2. Has d:mostro-rates tag
final dTag = event.tags?.firstWhere(
(t) => t.isNotEmpty && t[0] == "d",
orElse: () => [],
);
if (dTag == null || dTag.length < 2 || dTag[1] != "mostro-rates") return false;
// 3. CRITICAL: signed by our connected Mostro instance
if (event.pubkey != mostroPubkey) return false;
return true;
}Implementation
1. Model
// lib/data/models/exchange_rates.dart
class ExchangeRates {
final Map<String, double> rates; // {"USD": 50000.0, "EUR": 45000.0, ...}
final DateTime fetchedAt;
final ExchangeRateSource source;
const ExchangeRates({
required this.rates,
required this.fetchedAt,
required this.source,
});
bool get isStale =>
DateTime.now().difference(fetchedAt) > const Duration(minutes: 15);
double? rateFor(String currency) => rates[currency.toUpperCase()];
Map<String, dynamic> toJson() => {
"rates": rates,
"fetched_at": fetchedAt.millisecondsSinceEpoch,
"source": source.name,
};
factory ExchangeRates.fromJson(Map<String, dynamic> json) => ExchangeRates(
rates: Map<String, double>.from(json["rates"]),
fetchedAt: DateTime.fromMillisecondsSinceEpoch(json["fetched_at"]),
source: ExchangeRateSource.values.byName(json["source"]),
);
}
enum ExchangeRateSource { nostr, http, cache }2. Sembast Storage (following existing BaseStorage pattern)
// lib/data/repositories/exchange_rates_storage.dart
import "package:sembast/sembast.dart";
import "package:mostro_mobile/data/repositories/base_storage.dart";
import "package:mostro_mobile/data/models/exchange_rates.dart";
class ExchangeRatesStorage extends BaseStorage<ExchangeRates> {
static const _singletonKey = "current";
ExchangeRatesStorage({required Database db})
: super(db, stringMapStoreFactory.store("exchange_rates"));
@override
Map<String, dynamic> toDbMap(ExchangeRates item) => item.toJson();
@override
ExchangeRates fromDbMap(String key, Map<String, dynamic> json) =>
ExchangeRates.fromJson(json);
/// Save current rates (singleton — always overwrites)
Future<void> saveRates(ExchangeRates rates) =>
putItem(_singletonKey, rates);
/// Load last saved rates (null if never fetched)
Future<ExchangeRates?> loadRates() => getItem(_singletonKey);
}The database instance should be obtained from the existing mostroDatabaseProvider.
3. Nostr Subscription Filter
// Use the same relay list already configured for Mostro orders
final filter = NostrFilter(
kinds: [30078],
authors: [mostroInstance.pubkey], // ONLY from our Mostro
additionalFilters: {"#d": ["mostro-rates"]},
);4. Provider (Riverpod)
// lib/features/exchange_rates/exchange_rates_provider.dart
@riverpod
Future<ExchangeRates> exchangeRates(ExchangeRatesRef ref) async {
final mostroPubkey = ref.watch(mostroInstanceProvider)!.pubkey;
final nostrService = ref.watch(nostrServiceProvider);
final storage = ref.watch(exchangeRatesStorageProvider);
// Try Nostr first (10s timeout)
try {
final rates = await nostrService
.fetchLatestRates(mostroPubkey)
.timeout(const Duration(seconds: 10));
await storage.saveRates(rates);
return rates;
} catch (e) {
logger.w("Nostr rates failed: $e");
}
// Fallback: Yadio HTTP API
try {
final rates = await YadioService.fetchRates();
await storage.saveRates(rates);
return rates;
} catch (e) {
logger.e("HTTP rates failed: $e");
}
// Last resort: Sembast cache
final cached = await storage.loadRates();
if (cached != null) return cached;
throw ExchangeRatesFetchException("No rates available");
}5. Fetching the event from Nostr
Future<ExchangeRates> fetchLatestRates(String mostroPubkey) async {
final completer = Completer<ExchangeRates>();
final sub = nostrPool.subscribe(
[filter],
onEvent: (event) {
if (!isValidRateEvent(event, mostroPubkey)) return;
try {
final content = jsonDecode(event.content) as Map<String, dynamic>;
final btcRates = content["BTC"] as Map<String, dynamic>;
final rates = btcRates.map((k, v) => MapEntry(k, (v as num).toDouble()));
completer.complete(ExchangeRates(
rates: rates,
fetchedAt: DateTime.now(),
source: ExchangeRateSource.nostr,
));
} catch (e) {
logger.e("Failed to parse rate event content: $e");
// Do NOT complete with error — keep waiting or fall back
}
},
);
return completer.future.whenComplete(() => sub.close());
}6. UI: Load cache on launch + staleness warning
On app launch, load from Sembast immediately to show rates without waiting for network:
// In initialization flow
final cached = await exchangeRatesStorage.loadRates();
if (cached != null) {
// Show cached rates immediately; refresh in background
ref.read(exchangeRatesProvider.notifier).setCached(cached);
}In the order creation screen, show a warning if rates are stale:
final ratesAsync = ref.watch(exchangeRatesProvider);
ratesAsync.when(
data: (rates) => Column(
children: [
if (rates.isStale)
WarningBanner(
message: "Exchange rates may be outdated",
),
OrderForm(rates: rates),
],
),
loading: () => const CircularProgressIndicator(),
error: (e, _) => ErrorView(
message: "Could not fetch exchange rates",
onRetry: () => ref.refresh(exchangeRatesProvider),
),
);Acceptance Criteria
- App subscribes to kind
30078events from the connected Mostro pubkey - Events with wrong pubkey are silently rejected
- Events with malformed JSON are logged and discarded (no crash)
- If no Nostr event arrives within 10 seconds, fallback to Yadio HTTP API
- If HTTP also fails, load from Sembast cache
- Successful rates are persisted to Sembast (
exchange_ratesstore) - Cache loads on app launch before fresh fetch completes
- Staleness warning shown if rates are older than 15 minutes
- Unit tests: pubkey verification, JSON parsing, fallback chain
- Works without internet (cache-only mode)
Files to create / modify
| File | Action | Description |
|---|---|---|
lib/data/models/exchange_rates.dart |
Create | ExchangeRates model + ExchangeRateSource enum |
lib/data/repositories/exchange_rates_storage.dart |
Create | Sembast storage extending BaseStorage<ExchangeRates> |
lib/features/exchange_rates/exchange_rates_provider.dart |
Create | Riverpod provider with Nostr→HTTP→cache fallback chain |
lib/services/exchange_rates_service.dart |
Create | Nostr subscription + Yadio HTTP fetch logic |
lib/widgets/staleness_warning.dart |
Create | Warning banner widget |
lib/screens/order_creation_screen.dart |
Modify | Use new provider, show staleness warning |
Testing
Verify the daemon is publishing
nak req -k 30078 -a 82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390 --tag d=mostro-rates wss://relay.mostro.networkUnit tests to write
test("rejects event from wrong pubkey", () { ... });
test("parses BTC rates from content correctly", () { ... });
test("falls back to HTTP when Nostr times out", () { ... });
test("loads Sembast cache when both Nostr and HTTP fail", () { ... });
test("isStale returns true after 15 minutes", () { ... });References
- Mostro daemon PR #685 — Server-side implementation
- Client spec:
.specify/NOSTR_EXCHANGE_RATES.md— Full spec - NIP-33 — Parameterized Replaceable Events
- NIP-40 — Expiration timestamp
- Yadio API — Current HTTP source (fallback)