Skip to content

Commit 47cf624

Browse files
committed
fix: harden community selector with error handling, locale formatting, and verification tracking
- Add error handling in _selectAndProceed with user-facing SnackBar - Track hasEvents only after verified events are applied, so unverified relays fall through to the next relay instead of returning empty results - Use locale-aware NumberFormat.compact for sats formatting in cards - Add language tags to fenced code blocks in COMMUNITY_DISCOVERY.md - Update doc to match current redirect and relay fallback behavior - Remove duplicated test helper in favor of CommunityRepository.verifyEvent
1 parent edb8389 commit 47cf624

5 files changed

Lines changed: 67 additions & 88 deletions

File tree

docs/COMMUNITY_DISCOVERY.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,19 @@ Existing users are never interrupted. The selector is shown only once after the
1313

1414
### New User
1515

16-
```
16+
```text
1717
App install -> Walkthrough (complete or skip) -> Community Selector -> Home
1818
```
1919

2020
### Existing User (upgrade)
2121

22-
```
22+
```text
2323
App launch -> Home (auto-migrated, no interruption)
2424
```
2525

2626
### Returning to Community Selection
2727

28-
```
28+
```text
2929
Home -> Settings -> Mostro Card -> Node Selector (existing feature)
3030
```
3131

@@ -47,12 +47,12 @@ Mirrored from [mostro.community](https://github.com/MostroP2P/community). Define
4747

4848
### Data Flow
4949

50-
```
50+
```text
5151
trustedCommunities (static config)
5252
|
5353
v
5454
CommunityRepository.fetchCommunityMetadata(pubkeys)
55-
| WebSocket -> wss://relay.mostro.network
55+
| WebSocket -> Config.nostrRelays (with fallback)
5656
| REQ kind 0 (profile: name, about, picture)
5757
| REQ kind 38385 (trade info: currencies, fee, min/max)
5858
| Timeout: 10s, partial data OK
@@ -79,7 +79,7 @@ Home Screen
7979

8080
### File Structure
8181

82-
```
82+
```text
8383
lib/
8484
core/
8585
config/
@@ -159,14 +159,14 @@ When kind 38385 event exists (`hasTradeInfo = true`) but `fiat_currencies_accept
159159

160160
In `lib/core/app_routes.dart`, the redirect logic evaluates two providers sequentially:
161161

162-
```
162+
```text
163163
1. firstRunProvider:
164164
- loading -> redirect to /walkthrough
165165
- data(isFirstRun=true) -> redirect to /walkthrough
166166
- data(isFirstRun=false) -> proceed to step 2
167167
168168
2. communitySelectedProvider:
169-
- loading -> redirect to /community_selector (prevents flash of home)
169+
- loading -> no redirect (wait for provider to resolve; router refreshes on change)
170170
- data(false) -> redirect to /community_selector
171171
- data(true) -> no redirect (proceed to requested route)
172172
- error -> no redirect (don't block on errors)
@@ -233,7 +233,7 @@ Previously hardcoded as a single entry (`Mostro P2P`). Now derived from `trusted
233233

234234
### UI Layout
235235

236-
```
236+
```text
237237
+-----------------------------+
238238
| bolt Choose your community | <- Title with bolt icon
239239
| [search icon] Search... | <- Search bar (filters by name, region, currency, about)

lib/data/repositories/community_repository.dart

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'dart:io';
44
import 'dart:math';
55
import 'package:crypto/crypto.dart';
66
import 'package:dart_nostr/dart_nostr.dart';
7+
import 'package:flutter/foundation.dart' show visibleForTesting;
78
import 'package:mostro_mobile/core/config.dart';
89
import 'package:mostro_mobile/services/logger_service.dart';
910

@@ -85,8 +86,9 @@ class CommunityRepository {
8586

8687
if (type == 'EVENT' && msg.length >= 3) {
8788
final event = msg[2] as Map<String, dynamic>;
88-
_processEvent(event, results);
89-
hasEvents = true;
89+
if (_processEvent(event, results)) {
90+
hasEvents = true;
91+
}
9092
} else if (type == 'EOSE') {
9193
eoseCount++;
9294
if (eoseCount >= 2 && !completer.isCompleted) {
@@ -139,7 +141,8 @@ class CommunityRepository {
139141
/// Verifies a raw Nostr event per NIP-01:
140142
/// 1. Recomputes the event ID from the serialized content
141143
/// 2. Verifies the Schnorr signature over the ID
142-
bool _verifyEvent(Map<String, dynamic> event) {
144+
@visibleForTesting
145+
bool verifyEvent(Map<String, dynamic> event) {
143146
try {
144147
final id = event['id'] as String?;
145148
final pubkey = event['pubkey'] as String?;
@@ -183,22 +186,24 @@ class CommunityRepository {
183186
}
184187
}
185188

186-
void _processEvent(
189+
/// Processes a raw event, applying it to [results] if it passes
190+
/// verification. Returns true if the event was verified and applied.
191+
bool _processEvent(
187192
Map<String, dynamic> event,
188193
Map<String, CommunityMetadata> results,
189194
) {
190195
final pubkey = event['pubkey'] as String?;
191196
final kind = event['kind'] as int?;
192-
if (pubkey == null || kind == null) return;
197+
if (pubkey == null || kind == null) return false;
193198

194199
final meta = results[pubkey];
195-
if (meta == null) return;
200+
if (meta == null) return false;
196201

197-
if (!_verifyEvent(event)) {
202+
if (!verifyEvent(event)) {
198203
logger.w(
199204
'Rejecting kind $kind event from $pubkey: verification failed',
200205
);
201-
return;
206+
return false;
202207
}
203208

204209
final createdAt = event['created_at'] as int? ?? 0;
@@ -210,16 +215,21 @@ class CommunityRepository {
210215
jsonDecode(event['content'] as String) as Map<String, dynamic>;
211216
meta.kind0 = content;
212217
meta.kind0CreatedAt = createdAt;
218+
return true;
213219
} catch (e) {
214220
logger.w('Failed to parse kind 0 content for $pubkey: $e');
221+
return false;
215222
}
216223
}
217224
} else if (kind == 38385) {
218225
if (createdAt >= (meta.kind38385CreatedAt ?? 0)) {
219226
meta.kind38385Tags = _extractTags(event);
220227
meta.kind38385CreatedAt = createdAt;
228+
return true;
221229
}
222230
}
231+
232+
return false;
223233
}
224234

225235
Map<String, String> _extractTags(Map<String, dynamic> event) {

lib/features/community/screens/community_selector_screen.dart

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import 'package:mostro_mobile/features/community/widgets/community_card.dart';
1010
import 'package:mostro_mobile/features/mostro/mostro_nodes_provider.dart';
1111
import 'package:mostro_mobile/features/mostro/widgets/add_custom_node_dialog.dart';
1212
import 'package:mostro_mobile/generated/l10n.dart';
13+
import 'package:mostro_mobile/services/logger_service.dart';
1314

1415
class CommunitySelectorScreen extends ConsumerStatefulWidget {
1516
const CommunitySelectorScreen({super.key});
@@ -296,22 +297,11 @@ class _CommunitySelectorScreenState
296297

297298
Future<void> _onConfirm(BuildContext context) async {
298299
if (_selectedPubkey == null) return;
299-
setState(() => _isSelecting = true);
300-
301-
try {
302-
await _selectAndProceed(_selectedPubkey!);
303-
} finally {
304-
if (mounted) setState(() => _isSelecting = false);
305-
}
300+
await _runSelection(() => _selectAndProceed(_selectedPubkey!));
306301
}
307302

308303
Future<void> _onSkip(BuildContext context) async {
309-
setState(() => _isSelecting = true);
310-
try {
311-
await _selectAndProceed(defaultMostroPubkey);
312-
} finally {
313-
if (mounted) setState(() => _isSelecting = false);
314-
}
304+
await _runSelection(() => _selectAndProceed(defaultMostroPubkey));
315305
}
316306

317307
Future<void> _onUseCustomNode(BuildContext context) async {
@@ -325,24 +315,34 @@ class _CommunitySelectorScreenState
325315
ref.read(mostroNodesProvider).map((n) => n.pubkey).toSet();
326316
final newPubkeys = pubkeysAfter.difference(pubkeysBefore);
327317
if (newPubkeys.isNotEmpty) {
328-
setState(() => _isSelecting = true);
329-
try {
330-
await _selectAndProceed(newPubkeys.first);
331-
} finally {
332-
if (mounted) setState(() => _isSelecting = false);
318+
await _runSelection(() => _selectAndProceed(newPubkeys.first));
319+
}
320+
}
321+
322+
Future<void> _runSelection(Future<void> Function() action) async {
323+
setState(() => _isSelecting = true);
324+
try {
325+
await action();
326+
} catch (e) {
327+
logger.e('Community selection failed: $e');
328+
if (mounted) {
329+
ScaffoldMessenger.of(context).showSnackBar(
330+
SnackBar(
331+
content: Text(S.of(context)!.communityLoadingError),
332+
),
333+
);
333334
}
335+
} finally {
336+
if (mounted) setState(() => _isSelecting = false);
334337
}
335338
}
336339

337340
Future<void> _selectAndProceed(String pubkey) async {
338-
// Ensure the pubkey exists as a node
339341
await _ensureNodeExists(pubkey);
340342

341-
// Select the node
342343
final nodesNotifier = ref.read(mostroNodesProvider.notifier);
343344
await nodesNotifier.selectNode(pubkey);
344345

345-
// Mark community as selected
346346
await ref
347347
.read(communitySelectedProvider.notifier)
348348
.markCommunitySelected();

lib/features/community/widgets/community_card.dart

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'package:flutter/material.dart';
2+
import 'package:intl/intl.dart';
23
import 'package:mostro_mobile/core/app_theme.dart';
34
import 'package:mostro_mobile/features/community/community.dart';
45
import 'package:mostro_mobile/generated/l10n.dart';
@@ -232,17 +233,11 @@ class CommunityCard extends StatelessWidget {
232233
}
233234

234235
String _formatSats(BuildContext context, int amount) {
235-
if (amount >= 1000000) {
236-
return S.of(context)!.communityFormatSats(
237-
'${(amount / 1000000).toStringAsFixed(1)}M',
238-
);
239-
}
240-
if (amount >= 1000) {
241-
return S.of(context)!.communityFormatSats(
242-
'${(amount / 1000).toStringAsFixed(0)}K',
243-
);
244-
}
245-
return S.of(context)!.communityFormatSats(amount.toString());
236+
final locale = Localizations.localeOf(context).toString();
237+
final formatted = amount < 1000
238+
? NumberFormat.decimalPattern(locale).format(amount)
239+
: NumberFormat.compact(locale: locale).format(amount);
240+
return '$formatted sats';
246241
}
247242

248243
IconData _socialIcon(String type) {

test/data/repositories/community_signature_test.dart

Lines changed: 13 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,36 +3,16 @@ import 'dart:convert';
33
import 'package:crypto/crypto.dart';
44
import 'package:dart_nostr/dart_nostr.dart';
55
import 'package:flutter_test/flutter_test.dart';
6+
import 'package:mostro_mobile/data/repositories/community_repository.dart';
67
import 'package:mostro_mobile/shared/utils/nostr_utils.dart';
78

8-
/// Reproduces the verification logic used in CommunityRepository.
9-
/// Returns true only if the event ID matches the content hash AND
10-
/// the Schnorr signature is valid for that ID.
11-
bool verifyRawEvent(Map<String, dynamic> event) {
12-
final id = event['id'] as String?;
13-
final pubkey = event['pubkey'] as String?;
14-
final sig = event['sig'] as String?;
15-
final createdAt = event['created_at'] as int?;
16-
final kind = event['kind'] as int?;
17-
final content = event['content'] as String? ?? '';
18-
final tags = event['tags'] as List<dynamic>?;
19-
20-
if (id == null || pubkey == null || sig == null ||
21-
createdAt == null || kind == null) {
22-
return false;
23-
}
24-
25-
// Step 1: Verify event ID matches content hash (NIP-01)
26-
final serialized =
27-
jsonEncode([0, pubkey, createdAt, kind, tags ?? [], content]);
28-
final computedId = sha256.convert(utf8.encode(serialized)).toString();
29-
if (computedId != id) return false;
30-
31-
// Step 2: Verify Schnorr signature over the event ID
32-
return NostrKeyPairs.verify(pubkey, id, sig);
33-
}
34-
359
void main() {
10+
late CommunityRepository repository;
11+
12+
setUp(() {
13+
repository = CommunityRepository();
14+
});
15+
3616
group('CommunityRepository event verification', () {
3717
test('accepts valid kind 0 event', () {
3818
final keyPair = NostrUtils.generateKeyPair();
@@ -45,7 +25,7 @@ void main() {
4525
],
4626
);
4727

48-
expect(verifyRawEvent(event.toMap()), isTrue);
28+
expect(repository.verifyEvent(event.toMap()), isTrue);
4929
});
5030

5131
test('accepts valid kind 38385 event with empty content', () {
@@ -61,7 +41,7 @@ void main() {
6141
],
6242
);
6343

64-
expect(verifyRawEvent(event.toMap()), isTrue);
44+
expect(repository.verifyEvent(event.toMap()), isTrue);
6545
});
6646

6747
test('rejects event with tampered content', () {
@@ -75,8 +55,7 @@ void main() {
7555
final tampered = Map<String, dynamic>.from(event.toMap());
7656
tampered['content'] = '{"name":"Spoofed Node"}';
7757

78-
// ID no longer matches the tampered content
79-
expect(verifyRawEvent(tampered), isFalse);
58+
expect(repository.verifyEvent(tampered), isFalse);
8059
});
8160

8261
test('rejects event with tampered pubkey', () {
@@ -91,7 +70,7 @@ void main() {
9170
final tampered = Map<String, dynamic>.from(event.toMap());
9271
tampered['pubkey'] = otherKeyPair.public;
9372

94-
expect(verifyRawEvent(tampered), isFalse);
73+
expect(repository.verifyEvent(tampered), isFalse);
9574
});
9675

9776
test('rejects event with forged id and signature', () {
@@ -104,7 +83,6 @@ void main() {
10483
keyPairs: keyPair,
10584
);
10685

107-
// Attacker creates a new event with spoofed content but original pubkey
10886
final spoofedContent = '{"name":"Spoofed"}';
10987
final originalMap = original.toMap();
11088
final serialized = jsonEncode([
@@ -116,16 +94,14 @@ void main() {
11694
spoofedContent,
11795
]);
11896
final forgedId = sha256.convert(utf8.encode(serialized)).toString();
119-
// Attacker signs with their own key
12097
final forgedSig = attackerKeyPair.sign(forgedId);
12198

12299
final tampered = Map<String, dynamic>.from(originalMap);
123100
tampered['content'] = spoofedContent;
124101
tampered['id'] = forgedId;
125102
tampered['sig'] = forgedSig;
126103

127-
// Signature was made by attacker, not by the original pubkey
128-
expect(verifyRawEvent(tampered), isFalse);
104+
expect(repository.verifyEvent(tampered), isFalse);
129105
});
130106

131107
test('handles event with null content as empty string', () {
@@ -140,11 +116,9 @@ void main() {
140116
);
141117

142118
final rawMap = event.toMap();
143-
// Simulate relay sending null content (edge case)
144119
rawMap.remove('content');
145120

146-
// Should still work — defaults to empty string
147-
expect(verifyRawEvent(rawMap), isTrue);
121+
expect(repository.verifyEvent(rawMap), isTrue);
148122
});
149123
});
150124
}

0 commit comments

Comments
 (0)