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
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.network
Unit 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
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:
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:
30078(NIP-33 replaceable — newer events replace older ones automatically)"mostro-rates"(unique identifier for this event type){"BTC": {"CURRENCY": price_in_that_currency, ...}}Rate semantics:
"BTC": {"USD": 50000.0}means 1 BTC = 50,000 USD.Security: Pubkey Verification (CRITICAL)
Anyone can publish a kind
30078event. The client MUST verifyevent.pubkey == mostro_instance_pubkeybefore using any rate data.Attack vector: Malicious actor publishes fake rates → user creates order at a manipulated price → loses sats.
Verification logic:
Implementation
1. Model
2. Sembast Storage (following existing
BaseStoragepattern)The database instance should be obtained from the existing
mostroDatabaseProvider.3. Nostr Subscription Filter
4. Provider (Riverpod)
5. Fetching the event from Nostr
6. UI: Load cache on launch + staleness warning
On app launch, load from Sembast immediately to show rates without waiting for network:
In the order creation screen, show a warning if rates are stale:
Acceptance Criteria
30078events from the connected Mostro pubkeyexchange_ratesstore)Files to create / modify
lib/data/models/exchange_rates.dartExchangeRatesmodel +ExchangeRateSourceenumlib/data/repositories/exchange_rates_storage.dartBaseStorage<ExchangeRates>lib/features/exchange_rates/exchange_rates_provider.dartlib/services/exchange_rates_service.dartlib/widgets/staleness_warning.dartlib/screens/order_creation_screen.dartTesting
Verify the daemon is publishing
Unit tests to write
References
.specify/NOSTR_EXCHANGE_RATES.md— Full spec