Skip to content

feat: fetch exchange rates from Nostr with HTTP/cache fallback#551

Merged
grunch merged 4 commits intomainfrom
feat/nostr-exchange-rates
Mar 27, 2026
Merged

feat: fetch exchange rates from Nostr with HTTP/cache fallback#551
grunch merged 4 commits intomainfrom
feat/nostr-exchange-rates

Conversation

@mostronatorcoder
Copy link
Copy Markdown
Contributor

@mostronatorcoder mostronatorcoder bot commented Mar 26, 2026

Implements #550

Summary

Replaces YadioExchangeService (HTTP-only) with NostrExchangeService that fetches BTC/fiat exchange rates from Nostr relays (NIP-33 kind 30078) first, falling back to the Yadio HTTP API and a SharedPreferences cache.

This solves the censorship problem: the Yadio API is blocked in Venezuela and other regions, but Nostr relays are accessible.


Changes

New: NostrExchangeService (lib/services/nostr_exchange_service.dart)

Drops in as a replacement for YadioExchangeService — same interface (ExchangeService), different behavior:

Rate request → in-memory cache hit? → return
                    │ miss
                    ▼
             Nostr (10s timeout) → cache + return
                    │ fail
                    ▼
             Yadio HTTP (30s) → cache + return
                    │ fail
                    ▼
             SharedPreferences (<1h old) → return
                    │ miss/stale
                    ▼
                 throw

Key decisions:

  • Uses SharedPreferences for cache (already a dependency, lightweight key-value store — no need for Sembast for a single cached JSON string)
  • Cache expires after 1 hour (stale data is worse than no data)
  • In-memory cache avoids repeated network calls during a session
  • getCurrencyCodes() delegates to Yadio (currency list rarely changes, already loaded from assets/data/fiat.json in the provider)

Security: Pubkey Verification

The service verifies event.pubkey == settings.mostroPublicKey before parsing rates. This is defense-in-depth:

  1. The Nostr filter already uses authors: [mostroPubkey]
  2. But relays are untrusted — they could inject events
  3. So we verify again after fetching

Attack prevented: Malicious actor publishes fake kind 30078 events to manipulate prices → rejected because wrong pubkey.

Updated: exchange_service_provider.dart

// Before
final exchangeServiceProvider = Provider<ExchangeService>((ref) {
  return YadioExchangeService();
});

// After
final exchangeServiceProvider = Provider<ExchangeService>((ref) {
  final nostrService = ref.watch(nostrServiceProvider);
  final settings = ref.watch(settingsProvider);
  return NostrExchangeService(
    nostrService: nostrService,
    mostroPubkey: settings.mostroPublicKey,
  );
});

The provider now watches settingsProvider so the service automatically updates when the user changes Mostro instance (different pubkey).

New: Tests (test/services/nostr_exchange_service_test.dart)

Unit tests covering:

  • Valid Yadio format parsing
  • BTC→BTC entry skipping
  • Integer value handling
  • Missing BTC key rejection
  • Empty rates rejection
  • Invalid JSON rejection
  • Non-numeric value filtering
  • Many currencies
  • Pubkey verification logic

New: Documentation (docs/NOSTR_EXCHANGE_RATES.md)

Overview of the fallback chain, event format, security, and references.


What This Does NOT Change

  • No new dependencies added
  • No UI changes (rates appear the same to users)
  • No pubspec.yaml changes
  • ExchangeService interface unchanged (all consumers work without modification)
  • currencyCodesProvider still loads from assets/data/fiat.json
  • exchangeRateProvider API unchanged (.family keyed by currency code)

How to Test

Verify daemon is publishing rates

nak req -k 30078 -a 82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390 \
  --tag d=mostro-rates wss://relay.mostro.network

Run unit tests

flutter test test/services/nostr_exchange_service_test.dart

Manual testing

  1. Launch app → navigate to order creation
  2. Select currency → verify rate appears
  3. Disconnect internet → verify cached rate still works
  4. Check logs for Nostr exchange rates failed (fallback to HTTP) or Using cached exchange rates

References

Closes #550

Summary by CodeRabbit

  • New Features

    • Exchange rates now retrieved using Nostr relays as the primary source, with automatic fallback to HTTP requests and local caching for improved reliability and performance when primary sources are unavailable.
  • Documentation

    • Added documentation describing the new exchange rate retrieval system, including the fallback mechanism and data validation process.

Implements #550

## Changes

### New: NostrExchangeService (replaces YadioExchangeService as default)

Fetches BTC/fiat rates from Nostr (NIP-33 kind 30078) with automatic
fallback to Yadio HTTP API and SharedPreferences cache.

Fallback chain: Nostr (10s) → Yadio HTTP (30s) → SharedPreferences cache

### Security

Events are verified against settings.mostroPublicKey to prevent
price manipulation attacks from untrusted publishers.

### Files
- lib/services/nostr_exchange_service.dart — main service
- lib/shared/providers/exchange_service_provider.dart — updated provider
- test/services/nostr_exchange_service_test.dart — unit tests
- docs/NOSTR_EXCHANGE_RATES.md — documentation

Closes #550
- Fix createdAt comparison: use DateTime.isAfter instead of
  compareTo with String default (createdAt is DateTime?, not String)
- Remove unused import in test file
Catrya
Catrya previously approved these changes Mar 27, 2026
Copy link
Copy Markdown
Member

@Catrya Catrya left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tACK

@grunch
Copy link
Copy Markdown
Member

grunch commented Mar 27, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 27, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 27, 2026

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0629daf8-e1f7-4d9f-9fbd-7b43675b24de

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

The pull request introduces Nostr-based Bitcoin/fiat exchange rate fetching with a multi-tier fallback mechanism. A new NostrExchangeService replaces YadioExchangeService, prioritizing in-memory cache, Nostr relay queries (NIP-33 kind 30078), HTTP requests, and SharedPreferences persistence. Documentation, implementation, provider configuration, and comprehensive tests are included.

Changes

Cohort / File(s) Summary
Documentation
docs/NOSTR_EXCHANGE_RATES.md
New guide describing Nostr (NIP-33 kind 30078) exchange rate retrieval flow, fallback chain (cache → Nostr → HTTP → SharedPreferences), expected event structure, pubkey verification requirements, and configuration details.
Core Exchange Service
lib/services/nostr_exchange_service.dart
New NostrExchangeService implementing multi-tier fallback: in-memory cache, Nostr relay subscription (kind 30078, d-tag "mostro-rates", 10s timeout), Yadio HTTP endpoint, and SharedPreferences cache (1-hour staleness check). Validates Mostro pubkey, parses JSON rates, and handles errors with logging.
Dependency Injection
lib/shared/providers/exchange_service_provider.dart
Updated exchangeServiceProvider to instantiate NostrExchangeService with injected nostrService and mostroPublicKey from Riverpod, replacing prior YadioExchangeService instantiation. Signature and behavior of exchangeRateProvider and currencyCodesProvider unchanged.
Test Suite
test/services/nostr_exchange_service_test.dart
New tests covering JSON rate parsing validation (requiring "BTC" object, numeric conversion, skipping self-entry, error handling), pubkey verification logic, and test cases for malformed/invalid JSON, missing currencies, and edge conditions.

Sequence Diagram

sequenceDiagram
    participant App as Mobile App
    participant Service as NostrExchangeService
    participant Mem as In-Memory Cache
    participant Nostr as Nostr Relay
    participant Http as Yadio HTTP
    participant Prefs as SharedPreferences

    App->>Service: getExchangeRate(currency)
    Service->>Mem: Check cache
    alt Cache Hit
        Mem-->>Service: Return rate
    else Cache Miss
        Service->>Service: _refreshRates()
        Service->>Nostr: Subscribe kind 30078<br/>(10s timeout)
        alt Nostr Success
            Nostr-->>Service: Validate pubkey & parse JSON
            Service->>Mem: Update cache
            Prefs->>Prefs: Persist rates + timestamp
        else Nostr Timeout/Failure
            Service->>Http: Request BTC rates
            alt HTTP Success
                Http-->>Service: Parse response
                Service->>Mem: Update cache
                Prefs->>Prefs: Persist rates + timestamp
            else HTTP Failure
                Service->>Prefs: Load cached rates
                alt Cache Valid (<1hr)
                    Prefs-->>Service: Return persisted rates
                else Cache Expired/Missing
                    Service-->>App: Throw exception
                end
            end
        end
    end
    Service-->>App: Return rate
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰 Hops through relays, swift and bright,
Rates from Nostr—what a sight!
When the relay sleeps or fails,
HTTP and cache set sail,
Three fallbacks guard the exchange trail!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Linked Issues check ❓ Inconclusive The PR implements Nostr fetching with HTTP and SharedPreferences cache fallback, pubkey verification, and JSON parsing tests, but omits critical requirements: ExchangeRates model with fetchedAt/source/isStale, Sembast persistence, cache loading on app launch, staleness warning UI, and order creation screen changes. Add ExchangeRates model, implement Sembast storage, create staleness warning widget, and update order creation UI to display warnings when rates.isStale. Add missing integration tests.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and concisely describes the main change: switching to Nostr-first exchange rate fetching with HTTP and cache fallbacks, which aligns with the core objective.
Out of Scope Changes check ✅ Passed All code changes are directly related to the Nostr exchange rate fetching functionality: new service class, provider configuration, documentation, and unit tests. No unrelated modifications detected.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/nostr-exchange-rates

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/NOSTR_EXCHANGE_RATES.md`:
- Around line 11-28: Add a language specifier to the fenced code block that
contains the ASCII flow diagram (the block starting with ``` above "Request rate
for USD") by changing the opening fence from ``` to ```text so markdownlint
MD040 is satisfied; no other content changes are needed.
- Around line 53-55: The docs currently claim "verify event signatures" but the
implementation in lib/services/nostr_exchange_service.dart only compares
event.pubkey to settings.mostroPublicKey; update the wording to accurately say
this is a pubkey/origin check (e.g., "verify event pubkey by comparing
event.pubkey to settings.mostroPublicKey" or "verify event origin via pubkey
comparison") and avoid implying full cryptographic signature verification unless
signature checking is performed elsewhere; reference settings.mostroPublicKey
and the code path that reads event.pubkey to ensure the doc matches behavior.

In `@lib/services/nostr_exchange_service.dart`:
- Around line 157-175: _fetchFromYadio currently returns an empty map when BTC
exists but contains no numeric entries, which short-circuits the fallback chain;
change _fetchFromYadio to detect when the constructed rates map is empty and
throw a descriptive exception (e.g., "No usable rates from Yadio") instead of
returning {} so that _refreshRates will treat this as a failure and continue
falling back (and thus avoid persisting an empty cache); keep the
Map<String,double> signature but raise the error early when rates.isEmpty to
ensure getExchangeRate can fall back to SharedPreferences.
- Around line 34-37: The in-memory cache _cachedRates currently short-circuits
all future requests once populated and never expires; add a companion DateTime?
field (e.g., _cachedRatesFetchedAt) and enforce the same 1-hour freshness used
for SharedPreferences: on fetch set _cachedRates and _cachedRatesFetchedAt =
DateTime.now(), when reading return cached rates only if _cachedRates != null &&
DateTime.now().difference(_cachedRatesFetchedAt!).inMinutes < 60, and when
hydrating from SharedPreferences restore both the rates and the persisted
timestamp (do not overwrite the persisted timestamp with now) so the in-memory
cache respects the original expiration; also ensure any cache-clear or error
path clears both _cachedRates and _cachedRatesFetchedAt.
- Around line 123-154: Filter the fetched events list to only include events
where event.kind == _exchangeRatesEventKind and the 'd' tag equals
_exchangeRatesDTag before selecting the newest event; use the existing
tag-parsing pattern (inspect event.tags for a tag whose first element is 'd' and
take its second element) to extract dTag for each event, then call .reduce() (or
pick the latest) on the filtered list, throw if the filtered list is empty, and
only then verify event.pubkey == _mostroPubkey and pass event.content to
_parseRatesContent.

In `@lib/shared/providers/exchange_service_provider.dart`:
- Around line 19-29: The provider currently uses
ref.read(exchangeServiceProvider) causing a non-reactive dependency; change it
to ref.watch(exchangeServiceProvider) inside the StateNotifierProvider.family
callback so ExchangeRateNotifier is recreated when the underlying service (and
its settingsProvider dependencies like the Mostro pubkey) change; ensure you
still construct ExchangeRateNotifier(exchangeService) and call
notifier.fetchExchangeRate(currency) after obtaining the watched service so the
notifier uses the up-to-date exchangeService instance.

In `@test/services/nostr_exchange_service_test.dart`:
- Around line 11-37: The test currently duplicates parsing logic in parseRates
and compares literal pubkey groups, which bypasses NostrExchangeService; update
the tests to exercise the real code path by instantiating NostrExchangeService
and driving its parsing/validation (call the public method that consumes
NostrService responses) using a Mockito-generated fake NostrService and a fake
cache seam, or alternatively move the parsing/validation logic into a new
production helper (e.g. export _parseRatesContent as parseRatesFromContent or a
RatesParser class) and write tests against that helper; reference
NostrExchangeService, NostrService, _parseRatesContent (or the new helper) and
the pubkey validation code so the assertions run through the actual
implementation instead of the local parseRates copy.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2fdbc3a8-6073-4e93-9728-057f264728b4

📥 Commits

Reviewing files that changed from the base of the PR and between 30f6985 and 1ab31fe.

📒 Files selected for processing (4)
  • docs/NOSTR_EXCHANGE_RATES.md
  • lib/services/nostr_exchange_service.dart
  • lib/shared/providers/exchange_service_provider.dart
  • test/services/nostr_exchange_service_test.dart

Comment on lines +34 to +37
/// 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<String, double>? _cachedRates;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Expire the in-memory rates cache too.

Line 57 short-circuits every future request once _cachedRates is populated. In a long-running app session that can serve hours-old rates forever, even though the SharedPreferences fallback correctly expires after 1 hour. Track a fetched timestamp alongside _cachedRates, and when hydrating from SharedPreferences preserve the persisted timestamp instead of resetting freshness.

Also applies to: 55-69, 81-118

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/services/nostr_exchange_service.dart` around lines 34 - 37, The in-memory
cache _cachedRates currently short-circuits all future requests once populated
and never expires; add a companion DateTime? field (e.g., _cachedRatesFetchedAt)
and enforce the same 1-hour freshness used for SharedPreferences: on fetch set
_cachedRates and _cachedRatesFetchedAt = DateTime.now(), when reading return
cached rates only if _cachedRates != null &&
DateTime.now().difference(_cachedRatesFetchedAt!).inMinutes < 60, and when
hydrating from SharedPreferences restore both the rates and the persisted
timestamp (do not overwrite the persisted timestamp with now) so the in-memory
cache respects the original expiration; also ensure any cache-clear or error
path clears both _cachedRates and _cachedRatesFetchedAt.

Comment on lines +157 to +175
/// Fetch all BTC rates from Yadio HTTP API and return them as a map.
Future<Map<String, double>> _fetchFromYadio() async {
final data = await getRequest('exrates/BTC');

final btcRates = data['BTC'];
if (btcRates is! Map) {
throw Exception('Unexpected Yadio response format');
}

final rates = <String, double>{};
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();
}
}
return rates;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fail fast when Yadio returns no usable rates.

If BTC exists but none of its entries are numeric, _fetchFromYadio() returns {} and _refreshRates() treats that as success. That stops the fallback chain, persists an empty cache, and getExchangeRate() later fails with Rate not found instead of falling back to SharedPreferences.

Minimal fix
     final rates = <String, double>{};
     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 const FormatException('No valid rates found');
+    }
     return rates;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Fetch all BTC rates from Yadio HTTP API and return them as a map.
Future<Map<String, double>> _fetchFromYadio() async {
final data = await getRequest('exrates/BTC');
final btcRates = data['BTC'];
if (btcRates is! Map) {
throw Exception('Unexpected Yadio response format');
}
final rates = <String, double>{};
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();
}
}
return rates;
}
/// Fetch all BTC rates from Yadio HTTP API and return them as a map.
Future<Map<String, double>> _fetchFromYadio() async {
final data = await getRequest('exrates/BTC');
final btcRates = data['BTC'];
if (btcRates is! Map) {
throw Exception('Unexpected Yadio response format');
}
final rates = <String, double>{};
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 const FormatException('No valid rates found');
}
return rates;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/services/nostr_exchange_service.dart` around lines 157 - 175,
_fetchFromYadio currently returns an empty map when BTC exists but contains no
numeric entries, which short-circuits the fallback chain; change _fetchFromYadio
to detect when the constructed rates map is empty and throw a descriptive
exception (e.g., "No usable rates from Yadio") instead of returning {} so that
_refreshRates will treat this as a failure and continue falling back (and thus
avoid persisting an empty cache); keep the Map<String,double> signature but
raise the error early when rates.isEmpty to ensure getExchangeRate can fall back
to SharedPreferences.

Comment on lines +19 to +29
final exchangeRateProvider =
StateNotifierProvider.family<
ExchangeRateNotifier,
AsyncValue<double>,
String
>((ref, currency) {
final exchangeService = ref.read(exchangeServiceProvider);
final notifier = ExchangeRateNotifier(exchangeService);
notifier.fetchExchangeRate(currency);
return notifier;
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In flutter_riverpod, when a provider factory uses ref.read(otherProvider), does that create a reactive dependency, or is ref.watch(otherProvider)required for the provider to rebuild whenotherProvider changes?

💡 Result:

In flutter_riverpod, when a provider factory (the build function of a provider) uses ref.read(otherProvider), it does NOT create a reactive dependency. The provider will NOT rebuild when otherProvider changes. ref.watch(otherProvider) is required to establish the reactive dependency and trigger a rebuild of the provider when otherProvider changes.

Citations:


🏁 Script executed:

cd /tmp && find . -name "exchange_service_provider.dart" -type f 2>/dev/null | head -5

Repository: MostroP2P/mobile

Length of output: 42


🏁 Script executed:

git ls-files "**/*exchange_service_provider.dart" 2>/dev/null

Repository: MostroP2P/mobile

Length of output: 111


🏁 Script executed:

cat -n lib/shared/providers/exchange_service_provider.dart

Repository: MostroP2P/mobile

Length of output: 2060


🏁 Script executed:

rg "exchangeRateProvider" --type dart -B 2 -A 2

Repository: MostroP2P/mobile

Length of output: 2698


Switch this dependency to watch.

Line 25 uses ref.read(exchangeServiceProvider), which does not create a reactive dependency. Since consumers watch exchangeRateProvider(currency) and exchangeServiceProvider itself depends on settingsProvider, the provider instances will not rebuild when the Mostro pubkey or other settings change. The notifiers will retain stale service references.

Minimal fix
-      final exchangeService = ref.read(exchangeServiceProvider);
+      final exchangeService = ref.watch(exchangeServiceProvider);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
final exchangeRateProvider =
StateNotifierProvider.family<
ExchangeRateNotifier,
AsyncValue<double>,
String
>((ref, currency) {
final exchangeService = ref.read(exchangeServiceProvider);
final notifier = ExchangeRateNotifier(exchangeService);
notifier.fetchExchangeRate(currency);
return notifier;
});
final exchangeRateProvider =
StateNotifierProvider.family<
ExchangeRateNotifier,
AsyncValue<double>,
String
>((ref, currency) {
final exchangeService = ref.watch(exchangeServiceProvider);
final notifier = ExchangeRateNotifier(exchangeService);
notifier.fetchExchangeRate(currency);
return notifier;
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/shared/providers/exchange_service_provider.dart` around lines 19 - 29,
The provider currently uses ref.read(exchangeServiceProvider) causing a
non-reactive dependency; change it to ref.watch(exchangeServiceProvider) inside
the StateNotifierProvider.family callback so ExchangeRateNotifier is recreated
when the underlying service (and its settingsProvider dependencies like the
Mostro pubkey) change; ensure you still construct
ExchangeRateNotifier(exchangeService) and call
notifier.fetchExchangeRate(currency) after obtaining the watched service so the
notifier uses the up-to-date exchangeService instance.

Comment on lines +11 to +37
Map<String, double> parseRates(String content) {
// Replicate the parsing logic from NostrExchangeService._parseRatesContent
final decoded = jsonDecode(content);
if (decoded is! Map<String, dynamic>) {
throw const FormatException('Expected JSON object');
}

final btcRates = decoded['BTC'];
if (btcRates is! Map<String, dynamic>) {
throw const FormatException('Missing or invalid "BTC" key');
}

final rates = <String, double>{};
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;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

These assertions never exercise NostrExchangeService.

parseRates() copies the parser, and the pubkey group only compares literals. That means the suite can stay green even if the production parser or pubkey validation regresses, so the critical Nostr path is still effectively untested. Please drive these cases through NostrExchangeService with a fake/mock NostrService and cache seam, or extract the parser/validator into a testable production helper.

As per coding guidelines, "Place unit tests beside their feature counterparts using the *_test.dart suffix and prefer Mockito fakes generated via build_runner."

Also applies to: 116-132

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/services/nostr_exchange_service_test.dart` around lines 11 - 37, The
test currently duplicates parsing logic in parseRates and compares literal
pubkey groups, which bypasses NostrExchangeService; update the tests to exercise
the real code path by instantiating NostrExchangeService and driving its
parsing/validation (call the public method that consumes NostrService responses)
using a Mockito-generated fake NostrService and a fake cache seam, or
alternatively move the parsing/validation logic into a new production helper
(e.g. export _parseRatesContent as parseRatesFromContent or a RatesParser class)
and write tests against that helper; reference NostrExchangeService,
NostrService, _parseRatesContent (or the new helper) and the pubkey validation
code so the assertions run through the actual implementation instead of the
local parseRates copy.

- Add in-memory cache expiration (_cachedRatesFetchedAt) with same
  1-hour TTL as SharedPreferences cache
- Filter fetched Nostr events by kind, d-tag, and pubkey before
  selecting newest (defense-in-depth against untrusted relays)
- Throw on empty Yadio rates instead of returning empty map
  (allows fallback chain to continue to SharedPreferences)
- Preserve original persisted timestamp when loading from cache
  (don't overwrite with DateTime.now())
- Clear stale in-memory cache on total failure
- Change ref.read to ref.watch in exchangeRateProvider for
  reactive dependency on exchangeServiceProvider
- Fix docs: add language specifier to code block, clarify pubkey
  verification is origin check not signature verification
- Rename _parseRatesContent to parseRatesContent (public static)
- Tests import and call NostrExchangeService.parseRatesContent
  directly instead of duplicating the parsing logic locally
Copy link
Copy Markdown
Member

@grunch grunch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tACK

@grunch grunch merged commit 1bde634 into main Mar 27, 2026
2 checks passed
@grunch grunch deleted the feat/nostr-exchange-rates branch March 27, 2026 20:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants