Skip to content

feat: fetch exchange rates from Nostr (NIP-33, kind 30078) with HTTP fallback #550

@mostronatorcoder

Description

@mostronatorcoder

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 30078 events 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_rates store)
  • 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.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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions