Skip to content

[NWC] Phase 2: Wallet connection management UI#463

Merged
grunch merged 14 commits intomainfrom
feature/nwc-wallet-management
Feb 14, 2026
Merged

[NWC] Phase 2: Wallet connection management UI#463
grunch merged 14 commits intomainfrom
feature/nwc-wallet-management

Conversation

@mostronator
Copy link
Collaborator

@mostronator mostronator commented Feb 13, 2026

Summary

Implements NWC wallet connection management UI as described in #457. Builds on Phase 1 (#456 / #461).

What's included

New files (1500 lines)

  • lib/data/repositories/nwc_storage.dart — Secure storage for NWC connection URI via FlutterSecureStorage
  • lib/features/wallet/providers/nwc_provider.dart — StateNotifier for NWC state (disconnected/connecting/connected/error), auto-reconnect from storage
  • lib/features/wallet/screens/wallet_settings_screen.dart — Main wallet management screen (alias, balance, disconnect)
  • lib/features/wallet/screens/connect_wallet_screen.dart — URI paste & validation screen
  • lib/features/wallet/widgets/wallet_status_card.dart — Compact status card for Settings
  • lib/features/wallet/widgets/wallet_balance_widget.dart — Balance display in sats
  • docs/NWC_PHASE2_IMPLEMENTATION.md — Implementation documentation
  • test/data/repositories/nwc_storage_test.dart — Storage tests
  • test/features/wallet/providers/nwc_provider_test.dart — Provider tests

Modified files

  • lib/core/app_routes.dart — Added /wallet_settings and /connect_wallet routes
  • lib/data/models/enums/storage_keys.dart — Added nwcConnectionUri to SecureStorageKeys
  • lib/features/settings/settings_screen.dart — Added wallet status card
  • lib/l10n/intl_en.arb, intl_es.arb, intl_it.arb — Wallet localization strings

Design decisions

  • NWC URI stored in FlutterSecureStorage — same security level as mnemonic/master key (the URI contains a 32-byte secret that can authorize payments)
  • StateNotifier pattern — matches existing SettingsNotifier and MostroNodesNotifier
  • Auto-reconnect on init — if a connection URI exists in storage, NwcNotifier reconnects automatically
  • QR scanning deferred — button shows "coming soon" snackbar; paste-URI flow is complete
  • UI follows settings card patterns — AppTheme colors, lucide_icons, same card/border styles

UX Flow

  1. User goes to Settings → taps Wallet card → WalletSettingsScreen
  2. If disconnected: taps "Connect Wallet" → ConnectWalletScreen
  3. Pastes NWC URI → validates → connects → fetches wallet info + balance
  4. Returns to WalletSettingsScreen showing connected state
  5. Can refresh balance or disconnect at any time

Closes #457

Summary by CodeRabbit

  • New Features

    • Nostr Wallet Connect (NWC) UI: connect flow with URI entry, QR-scan placeholder, wallet settings, status card, balance widget with refresh, connect/disconnect actions and confirmation.
  • Improvements

    • More robust NWC communication with negotiated encryption, improved response handling, isolated relay handling, auto-reconnect and secure storage of connection URI.
  • Documentation

    • Phase 2 NWC implementation docs with architecture, security notes, testing, and next steps.
  • Localization

    • Added English, Spanish, and Italian wallet/NWC strings.
  • Tests

    • Unit tests for secure storage and NWC state handling.

- Add NwcStorage for secure connection persistence (FlutterSecureStorage)
- Add NwcNotifier (StateNotifier) for wallet state management
- Add WalletSettingsScreen with connected/disconnected states
- Add ConnectWalletScreen with URI paste and validation
- Add WalletStatusCard and WalletBalanceWidget
- Add wallet routes (/wallet_settings, /connect_wallet)
- Add wallet card to Settings screen
- Add localization strings (EN/ES/IT)
- Add unit tests for storage and provider
- Add NWC_PHASE2_IMPLEMENTATION.md

Closes #457
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 13, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds Nostr Wallet Connect (NWC) Phase 2: secure storage for connection URIs, a Riverpod NWC provider with auto-reconnect and lifecycle handling, connect/settings screens and widgets, routing entries, localization, client/crypto updates, documentation, and unit tests.

Changes

Cohort / File(s) Summary
Design doc
docs/NWC_PHASE2_IMPLEMENTATION.md
New Phase 2 design and implementation document describing architecture, components, storage, lifecycle, testing, and future work.
Routing
lib/core/app_routes.dart
Added /wallet_settings and /connect_wallet routes and imports for the new screens.
Storage & Keys
lib/data/models/enums/storage_keys.dart, lib/data/repositories/nwc_storage.dart
Added nwcConnectionUri secure-storage key and NwcStorage with save/read/delete/hasConnection using FlutterSecureStorage — review secure key usage and ordering.
State Management / Providers
lib/features/wallet/providers/nwc_provider.dart
New NwcState, NwcStatus, NwcNotifier with connect/disconnect/auto-reconnect/refresh flows and providers (nwcStorageProvider, nwcProvider) — attention: lifecycle, persistence semantics, and error paths.
Screens
lib/features/wallet/screens/connect_wallet_screen.dart, lib/features/wallet/screens/wallet_settings_screen.dart
New connect screen (URI input/validation, QR placeholder) and wallet settings screen (connected/connecting/disconnected UIs, disconnect confirmation).
Widgets
lib/features/wallet/widgets/wallet_status_card.dart, lib/features/wallet/widgets/wallet_balance_widget.dart
Added status card (navigates to wallet settings) and balance widget with formatting and refresh action.
Settings integration
lib/features/settings/settings_screen.dart
Inserted WalletStatusCard into Settings UI (minor layout adjustments).
NWC client & crypto
lib/services/nwc/nwc_client.dart, lib/services/nwc/nwc_crypto.dart
Client: dedicated Nostr instance for NWC, encryption mode negotiation, relaxed subscription filtering with e-tag verification, and teardown refinements. Crypto: new NwcCrypto supporting NIP-04 and NIP-44 with encrypt/decrypt/detection helpers — review cryptographic correctness and interoperability.
Localization
lib/l10n/intl_en.arb, lib/l10n/intl_es.arb, lib/l10n/intl_it.arb
Added English/Spanish/Italian translation keys for wallet/NWC flows (connect, disconnect, balance, errors, prompts).
Tests
test/data/repositories/nwc_storage_test.dart, test/features/wallet/providers/nwc_provider_test.dart
Unit tests for NwcStorage (save/read/delete/hasConnection) and NwcState behaviors (defaults, balance conversion, copyWith, equality).

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant ConnectScreen as ConnectWalletScreen
    participant Provider as nwcProvider
    participant Notifier as NwcNotifier
    participant Storage as NwcStorage
    participant Secure as FlutterSecureStorage
    participant Settings as WalletSettingsScreen

    User->>ConnectScreen: Paste URI & tap Connect
    ConnectScreen->>ConnectScreen: Validate URI (NwcConnection.fromUri)
    alt Invalid
        ConnectScreen->>User: Show validation error
    else Valid
        ConnectScreen->>Provider: notifier.connect(uri)
        Provider->>Notifier: connect(uri, persist:true)
        Notifier->>Notifier: Build NwcClient & perform get_info/get_balance
        alt Success
            Notifier->>Storage: saveConnection(uri)
            Storage->>Secure: write("nwc_connection_uri", uri)
            Notifier->>Provider: update state -> connected
            Provider->>ConnectScreen: notify listeners
            ConnectScreen->>Settings: navigate /wallet_settings
        else Error
            Notifier->>Provider: update state with error
            Provider->>ConnectScreen: notify listeners
            ConnectScreen->>User: display error
        end
    end
Loading
sequenceDiagram
    participant User
    participant Settings as WalletSettingsScreen
    participant Provider as nwcProvider
    participant Notifier as NwcNotifier
    participant Storage as NwcStorage
    participant Secure as FlutterSecureStorage

    User->>Settings: Open wallet settings
    Settings->>Provider: read state
    Provider->>Settings: return current state & balance
    User->>Settings: Tap Disconnect
    Settings->>User: show confirmation dialog
    User->>Settings: Confirm
    Settings->>Provider: notifier.disconnect()
    Provider->>Notifier: disconnect()
    Notifier->>Notifier: teardown NwcClient & cancel subscriptions
    Notifier->>Storage: deleteConnection()
    Storage->>Secure: delete("nwc_connection_uri")
    Notifier->>Provider: update state -> disconnected
    Provider->>Settings: notify listeners
    Settings->>User: show disconnected view
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • grunch
  • Catrya

Poem

🐇
I hopped a path of secret strings,
I stored a URI beneath my springs,
State wakes, the screens begin to gleam,
Sats dance, the wallet hums a dream — ⚡

🚥 Pre-merge checks | ✅ 4 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning All changes are scoped to NWC wallet implementation. However, lib/services/nwc/nwc_client.dart and nwc_crypto.dart modifications (encryption negotiation, mode detection) exceed the stated Phase 2 scope of UI-only changes. Clarify whether encryption mode detection in nwc_client.dart and nwc_crypto.dart are Phase 2 deliverables or Phase 1 dependencies; consider separating into distinct PR if these are Phase 1 follow-ups.
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (8 files):

⚔️ lib/core/app_routes.dart (content)
⚔️ lib/data/models/enums/storage_keys.dart (content)
⚔️ lib/features/settings/settings_screen.dart (content)
⚔️ lib/l10n/intl_en.arb (content)
⚔️ lib/l10n/intl_es.arb (content)
⚔️ lib/l10n/intl_it.arb (content)
⚔️ lib/services/nwc/nwc_client.dart (content)
⚔️ zapstore.yaml (content)

These conflicts must be resolved before merging into main.
Resolve conflicts locally and push changes to this branch.
✅ 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 '[NWC] Phase 2: Wallet connection management UI' directly aligns with the main objective of implementing wallet connection management UI for NWC.
Linked Issues check ✅ Passed The PR fully implements coding requirements from issue #457: Connect/Disconnect screens, URI validation, secure storage, balance/alias display, auto-reconnect, and routes.
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
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/nwc-wallet-management

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
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: 4

🤖 Fix all issues with AI agents
In `@lib/features/wallet/providers/nwc_provider.dart`:
- Around line 28-30: The balanceSats getter currently rounds when converting
balanceMsats to satoshis which can overstate the balance; update the balanceSats
getter to truncate using integer division (use Dart's integer division operator)
instead of (balanceMsats! / 1000).round(), handling the nullable balanceMsats
correctly so it returns null when balanceMsats is null and returns balanceMsats
~/ 1000 when present.
- Around line 117-153: The persistence calls can throw and currently can push
the provider into an error state or prevent resetting state; update connect()
and disconnect() so storage failures are caught and only logged without altering
connection state: in connect(), move or wrap the call to
_storage.saveConnection(uri) in a try/catch after you set state =
NwcState(status: NwcStatus.connected, ...), catching any exception and calling
logger.e(...) (include the error) but do not change state or rethrow; in
disconnect(), ensure _client = null and state = const NwcState(status:
NwcStatus.disconnected) always happen, and wrap await
_storage.deleteConnection() in a try/catch that logs failures via logger.e(...)
so delete errors don’t prevent clearing _client or updating state.

In `@lib/features/wallet/widgets/wallet_balance_widget.dart`:
- Around line 63-65: Replace the hardcoded balance display strings in
wallet_balance_widget.dart — the ternary that currently renders '⚡
${_formatBalance(balanceSats!)} sats' and '⚡ -- sats' — with localized strings
from S.of(context)!; use a localized suffix key (e.g.
S.of(context)!.walletBalanceSuffix) appended to _formatBalance(balanceSats!) and
a localized placeholder key (e.g. S.of(context)!.walletBalancePlaceholder) for
the null case, and add the corresponding keys to the ARB/localization files if
they don’t already exist so the UI renders correctly in all locales.

In `@lib/features/wallet/widgets/wallet_status_card.dart`:
- Around line 100-105: The balance suffix "sats" is hardcoded in
WalletStatusCard; update the user-facing string to use localization instead.
Replace the literal in the Text widget that constructs '⚡
${_formatSats(nwcState.balanceSats!)} sats' with a localized string from
S.of(context) (e.g., S.of(context)!.satsSuffix or similar), ensuring the
formatted amount still uses _formatSats and the localized suffix is appended in
WalletStatusCard where nwcState.balanceSats is rendered.
🧹 Nitpick comments (3)
test/features/wallet/providers/nwc_provider_test.dart (1)

4-82: Consider adding NwcNotifier tests for connect/disconnect/refresh flows.

The current tests thoroughly validate NwcState behavior (defaults, conversion, copyWith, equality), which is good. However, the NwcNotifier lifecycle (connect, disconnect, auto-reconnect, refreshBalance) is untested. These are the most important behavioral paths and where bugs are most likely to surface.

Would you like me to open an issue to track adding NwcNotifier unit tests with mocked NwcStorage and NwcClient?

lib/features/wallet/screens/connect_wallet_screen.dart (2)

139-141: Hardcoded hint text should be a localized string.

The hint 'nostr+walletconnect://...' is user-facing text. Even if it's a technical protocol prefix unlikely to be translated, the coding guidelines require all user-facing strings to go through ARB files via S.of(context)!.

As per coding guidelines: "Replace all hardcoded user-facing text with localized strings using S.of(context)!.keyName."


215-236: Empty URI error reuses the field label, which may confuse users.

When the URI field is empty, _validationError is set to S.of(context)!.pasteNwcUri (line 218), which is likely the same text as the field header (line 101). Consider adding a distinct localization key like nwcUriRequired for a clearer error message (e.g., "Please enter an NWC URI").

Comment on lines 63 to 65
balanceSats != null
? '⚡ ${_formatBalance(balanceSats!)} sats'
: '⚡ -- sats',
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 13, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Localize the balance text and placeholder.

Use localized keys for the suffix and the placeholder value so it renders correctly in all locales.

💬 Suggested change
-                balanceSats != null
-                    ? '⚡ ${_formatBalance(balanceSats!)} sats'
-                    : '⚡ -- sats',
+                balanceSats != null
+                    ? '⚡ ${_formatBalance(balanceSats!)}${S.of(context)!.sats}'
+                    : '⚡ ${S.of(context)!.notAvailableShort}${S.of(context)!.sats}',

As per coding guidelines, replace all hardcoded user-facing text with localized strings using S.of(context)!.keyName.

📝 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
balanceSats != null
? '⚡ ${_formatBalance(balanceSats!)} sats'
: '⚡ -- sats',
balanceSats != null
? '⚡ ${_formatBalance(balanceSats!)}${S.of(context)!.sats}'
: '⚡ ${S.of(context)!.notAvailableShort}${S.of(context)!.sats}',
🤖 Prompt for AI Agents
In `@lib/features/wallet/widgets/wallet_balance_widget.dart` around lines 63 - 65,
Replace the hardcoded balance display strings in wallet_balance_widget.dart —
the ternary that currently renders '⚡ ${_formatBalance(balanceSats!)} sats' and
'⚡ -- sats' — with localized strings from S.of(context)!; use a localized suffix
key (e.g. S.of(context)!.walletBalanceSuffix) appended to
_formatBalance(balanceSats!) and a localized placeholder key (e.g.
S.of(context)!.walletBalancePlaceholder) for the null case, and add the
corresponding keys to the ARB/localization files if they don’t already exist so
the UI renders correctly in all locales.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Valid suggestion. The sats suffix is already defined as a localization key in the .arb files. However, this is consistent with the rest of the app where sats is used as-is (it's a universal Bitcoin term, not typically translated). Will address in a dedicated i18n cleanup PR if needed.

Copy link
Contributor

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if 'sats' key exists in ARB files
echo "=== Checking for 'sats' key in ARB files ==="
rg -n '"sats"' lib/l10n/*.arb

echo -e "\n=== Checking for hardcoded 'sats' usage in Dart files ==="
rg -n "sats'" --type=dart -C2 | head -50

Length of output: 1946


@mostronator, thanks for the context! I can confirm that the sats key is indeed defined in all three ARB files (with value " sats"), and the hardcoded usage here is consistent with other parts of the codebase, such as in background_notification_service.dart.

Your approach to consolidate this in a dedicated i18n cleanup PR makes sense. Feel free to address it when you tackle the broader localization consistency work.


🧠 Learnings used
Learnt from: CR
Repo: MostroP2P/mobile PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-12T19:14:24.080Z
Learning: Applies to lib/shared/widgets/dynamic_countdown_widget.dart : Use `S.of(context)!.timeLeftLabel()` for internationalized countdown display in DynamicCountdownWidget.

Learnt from: CR
Repo: MostroP2P/mobile PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-12T19:14:24.080Z
Learning: Applies to **/*.dart : Replace all hardcoded user-facing text with localized strings using `S.of(context)!.keyName`.

Learnt from: CR
Repo: MostroP2P/mobile PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-11T16:15:59.891Z
Learning: Applies to **/*.{dart,arb} : Localize all user-facing strings via ARB files and access them with `S.of(context)` rather than hard-coded string literals

Learnt from: CR
Repo: MostroP2P/mobile PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-12T19:14:24.080Z
Learning: Applies to lib/l10n/*.arb : Always use localized strings by adding keys to all ARB files (intl_en.arb, intl_es.arb, intl_it.arb) for English, Spanish, and Italian support.

Learnt from: CR
Repo: MostroP2P/mobile PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-12T19:14:24.080Z
Learning: Maintain zero `flutter analyze` issues by resolving deprecation warnings, BuildContext async gaps, unused imports, and optimizing with const constructors.

Learnt from: grunch
Repo: MostroP2P/mobile PR: 461
File: lib/services/nwc/nwc_client.dart:112-125
Timestamp: 2026-02-13T18:26:17.063Z
Learning: In lib/services/nwc/nwc_client.dart, the disconnect() method intentionally does NOT close relay WebSocket connections because Nostr.instance is a shared app-wide singleton. Calling disconnectFromRelays() would kill all app relay connections (including Mostro trading relays), not just NWC wallet relays. The disconnect() method correctly cleans up subscriptions and sends CLOSE frames. This is documented as a known limitation until NWC gets its own dedicated Nostr instance in Phase 2.

- Use integer division (~/) instead of round() for msats→sats conversion
  to avoid overstating balance by 1 sat
- Wrap storage.saveConnection/deleteConnection in try/catch so
  persistence failures don't break the active connection state
- Localize 'sats' suffix in WalletBalanceWidget and WalletStatusCard
  using S.of(context)!.sats instead of hardcoded string
Copy link
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: 1

🤖 Fix all issues with AI agents
In `@lib/features/wallet/providers/nwc_provider.dart`:
- Around line 143-148: The catch-all block currently sets state =
NwcState(status: NwcStatus.error, errorMessage: e.toString()), which can leak
raw exception details to the UI; instead change the fallback to assign a generic
user-facing message (e.g., "An unexpected error occurred. Please try again.") to
NwcState.errorMessage and send the raw exception to the logger using logger.e(e)
(or logger.e('NWC error', e)). Keep existing NwcException and
NwcInvalidUriException handling unchanged and ensure this change is applied
inside the catch block that wraps the NwcState assignment.
🧹 Nitpick comments (1)
lib/features/wallet/widgets/wallet_status_card.dart (1)

130-137: Consider edge case: negative balance display.

_formatSats doesn't handle negative values — if balanceMsats were ever negative (e.g., due to a buggy wallet response), the formatting with / 1000000 would produce unexpected strings like "-0.00M". Low risk since balances shouldn't be negative, but worth noting.

The test expected round() behavior (1999 msats → 2 sats) but the
implementation now uses integer division (1999 msats → 1 sat) which
is the correct behavior — you shouldn't show more sats than you have.
Replace e.toString() in the generic catch with a user-friendly
message and log the raw error instead. NwcException and
NwcInvalidUriException already use curated .message strings.
- Fix back navigation from ConnectWalletScreen: use context.pop()
  instead of context.go('/wallet_settings') which replaced the
  navigation stack and caused 'nothing to pop' error
- Add debug logging to NwcClient._sendRequest for troubleshooting
  balance fetch: log received events, e-tag matching, response
  matching, and timeout events
Use context.canPop() guard before popping. If there's nothing to
pop (e.g., user arrived via context.go() which replaces the stack),
fall back to context.go('/settings') instead of crashing with
'There is nothing to pop' GoError.
Some NWC relay implementations (e.g. Primal) don't support #e/#p
tag filters in REQ subscriptions. Remove these from the NostrFilter
and rely on the existing e-tag validation in the event handler.

This fixes get_balance timeout when connecting to Primal wallets.
CI flagged invalid_null_aware_operator — pubkey is non-nullable in
the event model used by the CI's Dart version.
Remove null-aware and non-null assertion operators — pubkey is
non-nullable, so plain '.' is the correct access.
Replace Nostr.instance (shared singleton) with a dedicated Nostr()
instance for NWC. This ensures subscriptions and events are sent
only to the NWC wallet relay(s), not broadcast to all Mostro relays.

The shared singleton caused subscriptions to go to Mostro relays
instead of the NWC relay, so wallet responses were never received.

Also properly disconnects NWC relays on disconnect() since we now
own the instance.
Copy link
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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
lib/services/nwc/nwc_client.dart (2)

260-296: ⚠️ Potential issue | 🟡 Minor

Potential substring issue on short strings in diagnostic log (line 271).

If eTag[1] is somehow shorter than 8 characters, eTag[1].substring(0, 8) would throw a RangeError. While Nostr event IDs should always be 64-char hex, a malformed response could trigger this in the debug log path. A minor defensive fix:

Suggested fix
-            logger.d('NWC: Ignoring event — e tag mismatch (expected: ${requestId.substring(0, 8)}..., got: ${eTag != null && eTag.length >= 2 ? eTag[1].substring(0, 8) : "none"}...)');
+            final gotTag = (eTag != null && eTag.length >= 2) ? eTag[1] : 'none';
+            logger.d('NWC: Ignoring event — e tag mismatch (expected: ${requestId.substring(0, 8)}..., got: ${gotTag.length >= 8 ? gotTag.substring(0, 8) : gotTag}...)');

100-125: ⚠️ Potential issue | 🟠 Major

Make disconnect() async and await the disconnectFromRelays() call.

The disconnectFromRelays() method is async (returns Future<void>), but the synchronous disconnect() method calls it without await on line 118. This creates a fire-and-forget pattern where relay cleanup happens asynchronously in the background. If connect() is called immediately after disconnect() (e.g., when updating the connection URI), the reconnection may race against the ongoing disconnect, potentially causing connection failures or state inconsistencies.

Change disconnect() to return Future<void> and await the call:

Future<void> disconnect() async {
  // ... subscription cleanup ...
  try {
    await _nostr.services.relays.disconnectFromRelays();
  } catch (e) {
    logger.w('NWC: Failed to disconnect relays: $e');
  }
  // ...
}

Update all call sites to await the result.

🧹 Nitpick comments (5)
lib/services/nwc/nwc_client.dart (1)

31-32: _nostrService is injected but entirely unused — dead dependency.

With the introduction of the dedicated _nostr instance (line 48), all relay interactions now go through _nostr.services.relays directly. The _nostrService field is never referenced, and the // ignore: unused_field suppression confirms this. Consider removing it from the constructor signature as well, or documenting why it's retained (e.g., planned future use).

Also applies to: 48-48

lib/features/wallet/providers/nwc_provider.dart (3)

32-49: copyWith cannot set nullable fields to null without clearWalletInfo/clearError.

The ?? fallback means copyWith(walletAlias: null) retains the old alias rather than clearing it. This is fine for current usage, but if you ever need to null out just one field (e.g., alias becomes unavailable while balance stays), you'd need separate clear flags. Just noting as a design consideration for future extensibility.


67-69: Unawaited _autoReconnect() in constructor — safe but worth documenting.

The fire-and-forget async call is a common StateNotifier pattern. If the notifier is disposed mid-reconnect, exceptions from setting state after disposal will be caught by _autoReconnect's try-catch. This is safe, but a brief comment noting the fire-and-forget nature and its safety would aid future readers.


192-196: dispose() only disconnects — does not cancel in-flight _autoReconnect or connect.

If _autoReconnect() (which calls connect()) is still running when dispose() fires, the async operations continue after the notifier is disposed. While the try-catch in _autoReconnect catches the resulting StateError, it's somewhat unclean. Consider tracking the in-flight future and cancelling it, or at minimum guarding state = assignments with a mounted check inside connect().

#!/bin/bash
# Check if StateNotifier has a `mounted` property in flutter_riverpod 2.6.x
rg -n 'bool get mounted' --type dart
lib/features/wallet/screens/wallet_settings_screen.dart (1)

342-346: Unawaited disconnect() future in dialog callback.

ref.read(nwcProvider.notifier).disconnect() returns Future<void> but isn't awaited. Since NwcNotifier.disconnect() catches all internal errors, this is practically safe. However, the unawaited future means any unexpected throws would be silently lost. Consider making the callback async:

Suggested fix
           TextButton(
-            onPressed: () {
+            onPressed: () async {
               Navigator.of(dialogContext).pop();
-              ref.read(nwcProvider.notifier).disconnect();
+              await ref.read(nwcProvider.notifier).disconnect();
             },

Add NwcCrypto helper that supports both NIP-04 (AES-256-CBC) and
NIP-44 (ChaCha20-Poly1305) encryption modes.

On connect(), the client now fetches the wallet's info event
(kind 13194) to detect supported encryption. If the wallet
advertises nip44_v2, NIP-44 is used. Otherwise, NIP-04 is assumed
per the NIP-47 spec for backwards compatibility.

Response decryption auto-detects the format from the content
(NIP-04 has '?iv=' separator, NIP-44 does not).

This fixes communication with wallets like Coinos that use NIP-04.
Copy link
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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/services/nwc/nwc_client.dart (1)

330-370: ⚠️ Potential issue | 🟡 Minor

Debug log substring calls on event fields could throw on malformed events.

Lines 332 and 341 call .substring(0, 8) on event.pubkey and eTag[1]. While these are normally 64-char hex strings, a malformed or unexpected event would cause a RangeError. These are inside the inner try/catch so the impact is contained (completer gets an error), but a crash in a debug log statement masking the real event data is confusing.

Safer logging helper

You could use a small helper or inline a safe truncation:

// Inline approach:
event.pubkey.length >= 8 ? event.pubkey.substring(0, 8) : event.pubkey

Or extract a utility:

String _short(String s, [int len = 8]) => s.length >= len ? s.substring(0, len) : s;
🤖 Fix all issues with AI agents
In `@lib/services/nwc/nwc_client.dart`:
- Around line 113-166: In _detectEncryptionMode wrap the period between starting
the stream.listen (stream.stream.listen(...)) and awaiting
completer.future.timeout in a try/finally so that both subscription.cancel() and
_nostr.services.relays.closeEventsSubscription(subId) are always executed;
specifically ensure the created `subscription` is cancelled in the finally block
and that closeEventsSubscription(subId) is called there as well (so regardless
of exceptions from completer.future.timeout or other errors you never leave a
dangling relay subscription).

In `@lib/services/nwc/nwc_crypto.dart`:
- Around line 168-173: The _pkcs7Unpad function currently only checks padLength
bounds but does not verify that every padding byte equals padLength; update
_pkcs7Unpad to validate that the last padLength bytes all equal padLength and if
not, throw a clear error (e.g., FormatException or StateError) instead of
silently returning a truncated buffer; keep the existing bounds checks
(data.isEmpty, padLength==0, padLength>16) and then iterate or use a range check
on data.sublist(data.length - padLength) to confirm all bytes match before
returning Uint8List.fromList(data.sublist(0, data.length - padLength)).
🧹 Nitpick comments (1)
lib/services/nwc/nwc_client.dart (1)

281-414: Request/response flow looks solid overall.

The encryption negotiation, content-based detection fallback for responses, e-tag validation, timeout handling with cleanup in finally, and proper error propagation are all well-structured. The approach of filtering only by kind + author and verifying the e-tag in the handler is a pragmatic workaround for relay compatibility.

One minor note: the doc comment on Line 276 still says "Encrypts the request payload with NIP-44" but the implementation now uses the negotiated mode, which may be NIP-04.

Update stale doc comment
   /// Sends a NWC request and waits for the response.
   ///
-  /// 1. Encrypts the request payload with NIP-44
+  /// 1. Encrypts the request payload with the negotiated encryption mode
   /// 2. Creates a kind 23194 event signed with the connection secret
   /// 3. Subscribes to kind 23195 responses tagged with the request event ID
   /// 4. Publishes the request event
   /// 5. Waits for the response or times out

Verify every byte in the padding region equals padLength, not just
the last byte. Prevents silently producing garbage output when
decryption fails with a wrong key.
Copy link
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: 1

🤖 Fix all issues with AI agents
In `@lib/services/nwc/nwc_crypto.dart`:
- Around line 103-126: In _decryptNip04 validate inputs before decryption:
ensure parts parsing succeeded, check that encryptedBytes.length > 0 and
encryptedBytes.length % 16 == 0 (AES-CBC requires full 16-byte blocks) and that
iv.length == 16; if any check fails, throw a clear ArgumentError (e.g., "Invalid
NIP-04 ciphertext: ciphertext must be base64 of 16-byte blocks" or "Invalid IV
length: expected 16 bytes"). Keep the sharedSecret computation via
_computeNip04SharedSecret and retain PKCS#7 unpadding via _pkcs7Unpad, but
perform these length checks first to avoid opaque PointyCastle errors and
misleading padding behavior.

Phase 1:
- Document dedicated Nostr instance (replaces shared singleton)
- Document dual NIP-04/NIP-44 encryption support
- Document NwcCrypto helper and encryption negotiation
- Update architecture diagram (5 files now, including nwc_crypto.dart)
- Remove outdated 'known limitation' about relay disconnection

Phase 2:
- Document all bugs found during real-world testing and their fixes
- Add tested wallets table (Coinos NIP-04, Primal custom relay)
- Document encryption negotiation flow
- Document simplified relay filter rationale
- Document security improvements (PKCS#7 validation, relay isolation)
Check that IV is exactly 16 bytes and ciphertext is a non-zero
multiple of 16 before attempting decryption. Prevents cryptic
pointycastle errors on malformed NIP-04 content.
@grunch grunch merged commit 4732657 into main Feb 14, 2026
2 checks passed
@grunch grunch deleted the feature/nwc-wallet-management branch February 14, 2026 01:36
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.

[NWC] Phase 2: Wallet connection management UI

2 participants