From f1e54f272266aff5e47b39ef5ec2b98ed08e9a68 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 7 Jun 2026 16:45:43 +0300 Subject: [PATCH] basic nostr actors --- lib/src/nostr_fetch_actor.dart | 102 +++++++++++++++++ lib/src/nostr_publish_actor.dart | 144 +++++++++++++++++++++++ lib/src/nostr_signer_actor.dart | 85 ++++++++++++++ lib/src/relay_connection.dart | 42 +++++++ lib/swarm.dart | 4 + pubspec.lock | 28 ++++- pubspec.yaml | 4 + test/fake_relay_connection.dart | 57 ++++++++++ test/nostr_fetch_actor_test.dart | 152 +++++++++++++++++++++++++ test/nostr_fetch_integration_test.dart | 99 ++++++++++++++++ test/nostr_publish_actor_test.dart | 150 ++++++++++++++++++++++++ test/nostr_relay_integration_test.dart | 109 ++++++++++++++++++ test/nostr_signer_actor_test.dart | 107 +++++++++++++++++ 13 files changed, 1081 insertions(+), 2 deletions(-) create mode 100644 lib/src/nostr_fetch_actor.dart create mode 100644 lib/src/nostr_publish_actor.dart create mode 100644 lib/src/nostr_signer_actor.dart create mode 100644 lib/src/relay_connection.dart create mode 100644 test/fake_relay_connection.dart create mode 100644 test/nostr_fetch_actor_test.dart create mode 100644 test/nostr_fetch_integration_test.dart create mode 100644 test/nostr_publish_actor_test.dart create mode 100644 test/nostr_relay_integration_test.dart create mode 100644 test/nostr_signer_actor_test.dart diff --git a/lib/src/nostr_fetch_actor.dart b/lib/src/nostr_fetch_actor.dart new file mode 100644 index 0000000..ae9119e --- /dev/null +++ b/lib/src/nostr_fetch_actor.dart @@ -0,0 +1,102 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; +import 'package:actors/actors.dart'; +import 'relay_connection.dart'; + +sealed class FetchMessage { + const FetchMessage(); +} + +final class FetchEvents extends FetchMessage { + final String filter; + const FetchEvents(this.filter); +} + +sealed class FetchResult { + const FetchResult(); +} + +final class EventsFetched extends FetchResult { + final List events; + const EventsFetched(this.events); +} + +class NostrFetchActor with Handler { + final List _relayUrls; + final Duration _eoseDeadline; + final RelayConnection Function(String url) _connectionFactory; + + NostrFetchActor({ + required List relayUrls, + Duration eoseDeadline = const Duration(seconds: 5), + RelayConnection Function(String url) connectionFactory = + WebSocketRelayConnection.new, + }) : _relayUrls = relayUrls, + _eoseDeadline = eoseDeadline, + _connectionFactory = connectionFactory; + + @override + Future handle(FetchMessage message) => switch (message) { + FetchEvents(:final filter) => _fetchEvents(filter), + }; + + Future _fetchEvents(String filter) async { + final results = await Future.wait( + _relayUrls.map( + (url) => _fetchFromRelay(url, filter).catchError((_) => []), + ), + ); + final seen = {}; + final merged = []; + for (final batch in results) { + for (final eventJson in batch) { + final id = + (jsonDecode(eventJson) as Map)['id'] as String; + if (seen.add(id)) { + merged.add(eventJson); + } + } + } + return EventsFetched(merged); + } + + Future> _fetchFromRelay(String url, String filter) async { + final rng = Random(); + final subId = List.generate( + 32, + (_) => rng.nextInt(16).toRadixString(16), + ).join(); + + final conn = _connectionFactory(url); + final collected = []; + + try { + await conn.connect(); + await conn.send(jsonEncode(['REQ', subId, jsonDecode(filter)])); + try { + await for (final raw in conn.messages.timeout(_eoseDeadline)) { + try { + final list = jsonDecode(raw) as List; + if (list[0] == 'EVENT') { + collected.add(jsonEncode(list[2])); + } else if (list[0] == 'EOSE') { + break; + } + } catch (_) { + // malformed message, skip + } + } + } on TimeoutException { + // return whatever was collected so far + } + } finally { + try { + await conn.send(jsonEncode(['CLOSE', subId])); + } catch (_) {} + await conn.close(); + } + + return collected; + } +} diff --git a/lib/src/nostr_publish_actor.dart b/lib/src/nostr_publish_actor.dart new file mode 100644 index 0000000..8631b67 --- /dev/null +++ b/lib/src/nostr_publish_actor.dart @@ -0,0 +1,144 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:actors/actors.dart'; +import 'package:retry/retry.dart'; +import 'relay_connection.dart'; + +final class RetryConfig { + final int maxAttempts; + final Duration baseDelay; + final Duration maxDelay; + final bool jitter; + const RetryConfig({ + this.maxAttempts = 3, + this.baseDelay = const Duration(seconds: 1), + this.maxDelay = const Duration(seconds: 32), + this.jitter = true, + }); +} + +sealed class RelayOutcome { + const RelayOutcome(); +} + +final class RelayConfirmed extends RelayOutcome { + const RelayConfirmed(); +} + +final class RelayRejected extends RelayOutcome { + final String reason; + const RelayRejected(this.reason); +} + +final class RelayFailed extends RelayOutcome { + const RelayFailed(); +} + +sealed class PublishMessage { + const PublishMessage(); +} + +final class PublishBatch extends PublishMessage { + final List events; + const PublishBatch(this.events); +} + +sealed class PublishResult { + const PublishResult(); +} + +final class BatchPublished extends PublishResult { + final Map outcomes; + const BatchPublished(this.outcomes); +} + +final class _RelayRejectedException implements Exception { + final String reason; + const _RelayRejectedException(this.reason); +} + +class NostrPublishActor with Handler { + final List _relayUrls; + final RetryConfig _retryConfig; + final Duration _batchTimeout; + final RelayConnection Function(String url) _connectionFactory; + + NostrPublishActor({ + required List relayUrls, + RetryConfig retryConfig = const RetryConfig(), + Duration batchTimeout = const Duration(seconds: 10), + RelayConnection Function(String url) connectionFactory = + WebSocketRelayConnection.new, + }) : _relayUrls = relayUrls, + _retryConfig = retryConfig, + _batchTimeout = batchTimeout, + _connectionFactory = connectionFactory; + + @override + Future handle(PublishMessage message) => switch (message) { + PublishBatch(:final events) => _publishBatch(events), + }; + + Future _publishBatch(List events) async { + final ids = events + .map((e) => (jsonDecode(e) as Map)['id'] as String) + .toList(); + final outcomes = await Future.wait( + _relayUrls.map((url) => _publishToRelay(url, events, ids)), + ); + return BatchPublished(Map.fromIterables(_relayUrls, outcomes)); + } + + Future _publishToRelay( + String url, + List events, + List ids, + ) async { + try { + await retry( + () => _attemptPublish(url, events, ids), + retryIf: (e) => e is! _RelayRejectedException, + maxAttempts: _retryConfig.maxAttempts, + delayFactor: _retryConfig.baseDelay, + maxDelay: _retryConfig.maxDelay, + randomizationFactor: _retryConfig.jitter ? 0.25 : 0.0, + ); + return const RelayConfirmed(); + } on _RelayRejectedException catch (e) { + return RelayRejected(e.reason); + } catch (_) { + return const RelayFailed(); + } + } + + Future _attemptPublish( + String url, + List events, + List ids, + ) async { + final conn = _connectionFactory(url); + final pending = {...ids}; + try { + await conn.connect(); + for (final e in events) { + await conn.send(jsonEncode(['EVENT', jsonDecode(e)])); + } + await for (final raw in conn.messages.timeout(_batchTimeout)) { + try { + final list = jsonDecode(raw) as List; + if (list[0] != 'OK') continue; + if (list[2] == false) { + throw _RelayRejectedException(list[3] as String); + } + pending.remove(list[1] as String); + if (pending.isEmpty) break; + } catch (e) { + if (e is _RelayRejectedException) rethrow; + // malformed message, skip + } + } + } finally { + await conn.close(); + } + } +} diff --git a/lib/src/nostr_signer_actor.dart b/lib/src/nostr_signer_actor.dart new file mode 100644 index 0000000..098d399 --- /dev/null +++ b/lib/src/nostr_signer_actor.dart @@ -0,0 +1,85 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:actors/actors.dart'; +import 'package:bip340/bip340.dart' as bip340; +import 'package:crypto/crypto.dart'; + +sealed class SignerMessage { + const SignerMessage(); +} + +final class SignEvent extends SignerMessage { + final int kind; + final List> tags; + final String content; + final int? createdAt; + const SignEvent({ + required this.kind, + this.tags = const [], + required this.content, + this.createdAt, + }); +} + +final class GetPublicKey extends SignerMessage { + const GetPublicKey(); +} + +sealed class SignerResult { + const SignerResult(); +} + +final class EventSigned extends SignerResult { + final String event; + const EventSigned(this.event); +} + +final class PublicKeyResult extends SignerResult { + final String publicKey; + const PublicKeyResult(this.publicKey); +} + +class NostrSignerActor with Handler { + final String _privateKey; + final String _publicKey; + + NostrSignerActor(String privateKey) + : _privateKey = privateKey, + _publicKey = bip340.getPublicKey(privateKey); + + @override + Future handle(SignerMessage message) => switch (message) { + SignEvent(:final kind, :final tags, :final content, :final createdAt) => + Future.value(_signEvent(kind, tags, content, createdAt)), + GetPublicKey() => Future.value(PublicKeyResult(_publicKey)), + }; + + EventSigned _signEvent( + int kind, + List> tags, + String content, + int? createdAt, + ) { + final ts = createdAt ?? DateTime.now().millisecondsSinceEpoch ~/ 1000; + final ser = jsonEncode([0, _publicKey, ts, kind, tags, content]); + final idBytes = sha256.convert(utf8.encode(ser)).bytes; + final id = idBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + + final rng = Random.secure(); + final auxBytes = List.generate(32, (_) => rng.nextInt(256)); + final aux = auxBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + + final sig = bip340.sign(_privateKey, id, aux); + + return EventSigned(jsonEncode({ + 'id': id, + 'pubkey': _publicKey, + 'created_at': ts, + 'kind': kind, + 'tags': tags, + 'content': content, + 'sig': sig, + })); + } +} diff --git a/lib/src/relay_connection.dart b/lib/src/relay_connection.dart new file mode 100644 index 0000000..ea78eca --- /dev/null +++ b/lib/src/relay_connection.dart @@ -0,0 +1,42 @@ +import 'dart:async'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +abstract interface class RelayConnection { + Future connect(); + Future send(String message); + Stream get messages; + Future close(); +} + +final class WebSocketRelayConnection implements RelayConnection { + final String _url; + WebSocketChannel? _channel; + StreamController? _controller; + + WebSocketRelayConnection(String url) : _url = url; + + @override + Future connect() async { + _channel = WebSocketChannel.connect(Uri.parse(_url)); + await _channel!.ready; + _controller = StreamController(); + _channel!.stream.cast().listen( + _controller!.add, + onError: _controller!.addError, + onDone: _controller!.close, + ); + } + + @override + Future send(String message) async => _channel!.sink.add(message); + + @override + Stream get messages => _controller!.stream; + + @override + Future close() async { + await _channel?.sink.close(); + _channel = null; + _controller = null; + } +} diff --git a/lib/swarm.dart b/lib/swarm.dart index aff0d0c..ce2b396 100644 --- a/lib/swarm.dart +++ b/lib/swarm.dart @@ -1 +1,5 @@ export 'src/file_actor.dart'; +export 'src/relay_connection.dart'; +export 'src/nostr_publish_actor.dart'; +export 'src/nostr_fetch_actor.dart'; +export 'src/nostr_signer_actor.dart'; diff --git a/pubspec.lock b/pubspec.lock index f0382a6..a4aa25b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + bip340: + dependency: "direct main" + description: + name: bip340 + sha256: "4c2df9fa2409d26f1d9334b2801015ebe4dc3978191f186743e60e89a90230c4" + url: "https://pub.dev" + source: hosted + version: "0.3.1" boolean_selector: dependency: transitive description: @@ -82,7 +90,7 @@ packages: source: hosted version: "1.15.0" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -201,6 +209,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" pool: dependency: transitive description: @@ -217,6 +233,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + retry: + dependency: "direct main" + description: + name: retry + sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc" + url: "https://pub.dev" + source: hosted + version: "3.1.2" shelf: dependency: transitive description: @@ -370,7 +394,7 @@ packages: source: hosted version: "1.0.1" web_socket_channel: - dependency: transitive + dependency: "direct main" description: name: web_socket_channel sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 diff --git a/pubspec.yaml b/pubspec.yaml index 2c3b0c0..c776331 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,6 +8,10 @@ environment: dependencies: actors: ^0.11.0 + bip340: ^0.3.1 + crypto: ^3.0.7 + retry: ^3.1.2 + web_socket_channel: ^3.0.3 dev_dependencies: lints: ^6.0.0 diff --git a/test/fake_relay_connection.dart b/test/fake_relay_connection.dart new file mode 100644 index 0000000..5b46c4b --- /dev/null +++ b/test/fake_relay_connection.dart @@ -0,0 +1,57 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:swarm/swarm.dart'; + +final class FakeRelayConnection implements RelayConnection { + final _controller = StreamController.broadcast(); + final sentMessages = []; + final queuedResponses = []; + bool isConnected = false; + bool autoRespond = true; + bool throwOnConnect = false; + void Function(String subId)? onReq; + + @override + Future connect() async { + if (throwOnConnect) throw Exception('connection failed'); + isConnected = true; + } + + @override + Future send(String message) async { + sentMessages.add(message); + if (queuedResponses.isNotEmpty) { + final response = queuedResponses.removeAt(0); + Future(() => _controller.add(response)); + return; + } + _tryAutoRespond(message); + } + + @override + Stream get messages => _controller.stream; + + @override + Future close() async => isConnected = false; + + void push(String message) => _controller.add(message); + + Future dispose() => _controller.close(); + + void _tryAutoRespond(String message) { + if (!autoRespond) return; + try { + final list = jsonDecode(message) as List; + if (list[0] == 'EVENT') { + final event = list[1] as Map; + final id = event['id'] as String; + Future(() => _controller.add(jsonEncode(['OK', id, true, '']))); + } else if (list[0] == 'REQ') { + final subId = list[1] as String; + Future(() => onReq?.call(subId)); + } + } catch (_) { + // malformed, ignore + } + } +} diff --git a/test/nostr_fetch_actor_test.dart b/test/nostr_fetch_actor_test.dart new file mode 100644 index 0000000..ae4ff4c --- /dev/null +++ b/test/nostr_fetch_actor_test.dart @@ -0,0 +1,152 @@ +import 'dart:convert'; +import 'package:swarm/swarm.dart'; +import 'package:test/test.dart'; +import 'fake_relay_connection.dart'; + +const _filter = '{"kinds":[1],"limit":10}'; + +Map _makeEvent(String id) => { + 'id': id, + 'pubkey': 'pub', + 'created_at': 1, + 'kind': 1, + 'tags': [], + 'content': 'hello', + 'sig': 'sig', + }; + +void main() { + group('NostrFetchActor', () { + test('returns events before EOSE', () async { + final fake = FakeRelayConnection(); + addTearDown(fake.dispose); + + final event1 = _makeEvent('id1'); + final event2 = _makeEvent('id2'); + + fake.onReq = (subId) { + fake.push(jsonEncode(['EVENT', subId, event1])); + fake.push(jsonEncode(['EVENT', subId, event2])); + fake.push(jsonEncode(['EOSE', subId])); + }; + + final actor = NostrFetchActor( + relayUrls: ['ws://relay1'], + connectionFactory: (_) => fake, + ); + + final result = await actor.handle(const FetchEvents(_filter)); + + expect(result, isA()); + expect((result as EventsFetched).events, hasLength(2)); + }); + + test('deduplicates events across relays', () async { + final fake1 = FakeRelayConnection(); + final fake2 = FakeRelayConnection(); + addTearDown(fake1.dispose); + addTearDown(fake2.dispose); + + final event = _makeEvent('shared-id'); + + fake1.onReq = (subId) { + fake1.push(jsonEncode(['EVENT', subId, event])); + fake1.push(jsonEncode(['EOSE', subId])); + }; + fake2.onReq = (subId) { + fake2.push(jsonEncode(['EVENT', subId, event])); + fake2.push(jsonEncode(['EOSE', subId])); + }; + + final fakes = {'ws://relay1': fake1, 'ws://relay2': fake2}; + + final actor = NostrFetchActor( + relayUrls: ['ws://relay1', 'ws://relay2'], + connectionFactory: (url) => fakes[url]!, + ); + + final result = await actor.handle(const FetchEvents(_filter)); + + expect(result, isA()); + expect((result as EventsFetched).events, hasLength(1)); + }); + + test('returns partial results when deadline expires', () async { + final fake = FakeRelayConnection(); + addTearDown(fake.dispose); + + final event = _makeEvent('id1'); + + fake.onReq = (subId) { + fake.push(jsonEncode(['EVENT', subId, event])); + // no EOSE — deadline will expire + }; + + final actor = NostrFetchActor( + relayUrls: ['ws://relay1'], + eoseDeadline: const Duration(milliseconds: 100), + connectionFactory: (_) => fake, + ); + + final result = await actor.handle(const FetchEvents(_filter)); + + expect(result, isA()); + expect((result as EventsFetched).events, hasLength(1)); + }); + + test('tolerates relay failure and returns events from healthy relay', + () async { + final fakeDown = FakeRelayConnection()..throwOnConnect = true; + final fakeUp = FakeRelayConnection(); + addTearDown(fakeDown.dispose); + addTearDown(fakeUp.dispose); + + final event = _makeEvent('id1'); + fakeUp.onReq = (subId) { + fakeUp.push(jsonEncode(['EVENT', subId, event])); + fakeUp.push(jsonEncode(['EOSE', subId])); + }; + + final fakes = {'ws://down': fakeDown, 'ws://up': fakeUp}; + + final actor = NostrFetchActor( + relayUrls: ['ws://down', 'ws://up'], + connectionFactory: (url) => fakes[url]!, + ); + + final result = await actor.handle(const FetchEvents(_filter)); + + expect(result, isA()); + expect((result as EventsFetched).events, hasLength(1)); + }); + + test('sends CLOSE after EOSE', () async { + final fake = FakeRelayConnection(); + addTearDown(fake.dispose); + + final event = _makeEvent('id1'); + fake.onReq = (subId) { + fake.push(jsonEncode(['EVENT', subId, event])); + fake.push(jsonEncode(['EOSE', subId])); + }; + + final actor = NostrFetchActor( + relayUrls: ['ws://relay1'], + connectionFactory: (_) => fake, + ); + + await actor.handle(const FetchEvents(_filter)); + + final reqMsg = fake.sentMessages.firstWhere( + (m) => (jsonDecode(m) as List)[0] == 'REQ', + ); + final subId = (jsonDecode(reqMsg) as List)[1] as String; + + final closeMsgs = fake.sentMessages + .where((m) => (jsonDecode(m) as List)[0] == 'CLOSE') + .toList(); + expect(closeMsgs, hasLength(1)); + expect((jsonDecode(closeMsgs[0]) as List)[1], equals(subId)); + }); + }); +} diff --git a/test/nostr_fetch_integration_test.dart b/test/nostr_fetch_integration_test.dart new file mode 100644 index 0000000..1345f5d --- /dev/null +++ b/test/nostr_fetch_integration_test.dart @@ -0,0 +1,99 @@ +@Tags(['integration']) +library; + +import 'dart:convert'; + +import 'package:bip340/bip340.dart' as bip340; +import 'package:swarm/swarm.dart'; +import 'package:test/test.dart'; + +const _privKey = + '0000000000000000000000000000000000000000000000000000000000000001'; + +const _relayUrl = 'wss://relay.otherwhere.app/'; + +void main() { + late NostrFetchActor fetcher; + late String pubkey; + + setUp(() { + fetcher = NostrFetchActor(relayUrls: [_relayUrl]); + pubkey = bip340.getPublicKey(_privKey); + }); + + String filter(Map f) => jsonEncode(f); + + group('relay.otherwhere.app fetch', () { + test('fetches kind 0 (metadata) by author', () async { + final result = await fetcher.handle(FetchEvents( + filter({'kinds': [0], 'authors': [pubkey], 'limit': 1}), + )); + final events = (result as EventsFetched).events; + expect(events, isNotEmpty); + final event = jsonDecode(events.first) as Map; + expect(event['kind'], equals(0)); + expect(event['pubkey'], equals(pubkey)); + }, timeout: const Timeout(Duration(seconds: 15))); + + test('fetches kind 37515 (place) by d tag', () async { + final result = await fetcher.handle(FetchEvents( + filter({ + 'kinds': [37515], + '#d': ['place:swarmtest0'], + 'authors': [pubkey], + 'limit': 1, + }), + )); + final events = (result as EventsFetched).events; + expect(events, isNotEmpty); + final event = jsonDecode(events.first) as Map; + expect(event['kind'], equals(37515)); + final tags = event['tags'] as List; + final dTag = tags.firstWhere((t) => (t as List).first == 'd') as List; + expect(dTag[1], equals('place:swarmtest0')); + }, timeout: const Timeout(Duration(seconds: 15))); + + test('fetches kind 37518 (check-in) by d tag', () async { + final result = await fetcher.handle(FetchEvents( + filter({ + 'kinds': [37518], + '#d': ['checkin:swarmtest0'], + 'authors': [pubkey], + 'limit': 1, + }), + )); + final events = (result as EventsFetched).events; + expect(events, isNotEmpty); + final event = jsonDecode(events.first) as Map; + expect(event['kind'], equals(37518)); + }, timeout: const Timeout(Duration(seconds: 15))); + + test('fetched events have valid BIP-340 signatures', () async { + final result = await fetcher.handle(FetchEvents( + filter({'authors': [pubkey], 'limit': 10}), + )); + final events = (result as EventsFetched).events; + expect(events, isNotEmpty); + for (final raw in events) { + final e = jsonDecode(raw) as Map; + expect( + bip340.verify( + e['pubkey'] as String, + e['id'] as String, + e['sig'] as String, + ), + isTrue, + reason: 'event ${e['id']} has invalid signature', + ); + } + }, timeout: const Timeout(Duration(seconds: 15))); + + test('returns empty list for unknown author', () async { + const unknown = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; + final result = await fetcher.handle(FetchEvents( + filter({'authors': [unknown], 'limit': 5}), + )); + expect((result as EventsFetched).events, isEmpty); + }, timeout: const Timeout(Duration(seconds: 15))); + }); +} diff --git a/test/nostr_publish_actor_test.dart b/test/nostr_publish_actor_test.dart new file mode 100644 index 0000000..042a0e3 --- /dev/null +++ b/test/nostr_publish_actor_test.dart @@ -0,0 +1,150 @@ +import 'dart:convert'; +import 'package:swarm/swarm.dart'; +import 'package:test/test.dart'; +import 'fake_relay_connection.dart'; + +const _event = '{' + '"id":"abc123",' + '"pubkey":"pub",' + '"created_at":1,' + '"kind":1,' + '"tags":[],' + '"content":"hi",' + '"sig":"sig"' + '}'; + +void main() { + group('NostrPublishActor', () { + tearDown(() async {}); + + test('sends correct EVENT format', () async { + final fake = FakeRelayConnection(); + addTearDown(fake.dispose); + + final actor = NostrPublishActor( + relayUrls: ['ws://relay1'], + retryConfig: const RetryConfig(maxAttempts: 1), + connectionFactory: (_) => fake, + ); + + await actor.handle(const PublishBatch([_event])); + + final eventMsgs = fake.sentMessages + .where((m) => (jsonDecode(m) as List)[0] == 'EVENT') + .toList(); + expect(eventMsgs, hasLength(1)); + final decoded = jsonDecode(eventMsgs[0]) as List; + expect(decoded[0], equals('EVENT')); + expect(decoded[1], isA()); + expect((decoded[1] as Map)['id'], equals('abc123')); + }); + + test('returns RelayConfirmed on success', () async { + final fake = FakeRelayConnection(); + addTearDown(fake.dispose); + + final actor = NostrPublishActor( + relayUrls: ['ws://relay1'], + retryConfig: const RetryConfig(maxAttempts: 1), + connectionFactory: (_) => fake, + ); + + final result = await actor.handle(const PublishBatch([_event])); + + expect(result, isA()); + final outcomes = (result as BatchPublished).outcomes; + expect(outcomes['ws://relay1'], isA()); + }); + + test('returns RelayRejected with no retry', () async { + final fake = FakeRelayConnection()..autoRespond = false; + addTearDown(fake.dispose); + fake.queuedResponses.add(jsonEncode(['OK', 'abc123', false, 'spam'])); + + final actor = NostrPublishActor( + relayUrls: ['ws://relay1'], + retryConfig: const RetryConfig(maxAttempts: 1), + connectionFactory: (_) => fake, + ); + + final result = await actor.handle(const PublishBatch([_event])); + + expect(result, isA()); + final outcomes = (result as BatchPublished).outcomes; + expect(outcomes['ws://relay1'], isA()); + expect((outcomes['ws://relay1'] as RelayRejected).reason, equals('spam')); + + final eventMsgs = fake.sentMessages + .where((m) => (jsonDecode(m) as List)[0] == 'EVENT') + .toList(); + expect(eventMsgs, hasLength(1)); + }); + + test('returns RelayFailed on timeout and retries', () async { + final fake = FakeRelayConnection()..autoRespond = false; + addTearDown(fake.dispose); + + final actor = NostrPublishActor( + relayUrls: ['ws://relay1'], + retryConfig: const RetryConfig( + maxAttempts: 2, + baseDelay: Duration(milliseconds: 1), + jitter: false, + ), + batchTimeout: const Duration(milliseconds: 50), + connectionFactory: (_) => fake, + ); + + final result = await actor.handle(const PublishBatch([_event])); + + expect(result, isA()); + final outcomes = (result as BatchPublished).outcomes; + expect(outcomes['ws://relay1'], isA()); + + final eventMsgs = fake.sentMessages + .where((m) => (jsonDecode(m) as List)[0] == 'EVENT') + .toList(); + expect(eventMsgs, hasLength(2)); + }); + + test('returns RelayFailed on connection error', () async { + final fake = FakeRelayConnection()..throwOnConnect = true; + addTearDown(fake.dispose); + + final actor = NostrPublishActor( + relayUrls: ['ws://relay1'], + retryConfig: const RetryConfig(maxAttempts: 1), + connectionFactory: (_) => fake, + ); + + final result = await actor.handle(const PublishBatch([_event])); + + expect(result, isA()); + final outcomes = (result as BatchPublished).outcomes; + expect(outcomes['ws://relay1'], isA()); + }); + + test('fans out to multiple relays', () async { + final fake1 = FakeRelayConnection(); + final fake2 = FakeRelayConnection(); + addTearDown(fake1.dispose); + addTearDown(fake2.dispose); + + final fakes = {'ws://relay1': fake1, 'ws://relay2': fake2}; + + final actor = NostrPublishActor( + relayUrls: ['ws://relay1', 'ws://relay2'], + retryConfig: const RetryConfig(maxAttempts: 1), + connectionFactory: (url) => fakes[url]!, + ); + + final result = await actor.handle(const PublishBatch([_event])); + + expect(result, isA()); + final outcomes = (result as BatchPublished).outcomes; + expect(outcomes.keys, containsAll(['ws://relay1', 'ws://relay2'])); + expect(outcomes['ws://relay1'], isA()); + expect(outcomes['ws://relay2'], isA()); + }); + }); +} diff --git a/test/nostr_relay_integration_test.dart b/test/nostr_relay_integration_test.dart new file mode 100644 index 0000000..671cce8 --- /dev/null +++ b/test/nostr_relay_integration_test.dart @@ -0,0 +1,109 @@ +@Tags(['integration']) +library; + +import 'dart:convert'; + +import 'package:bip340/bip340.dart' as bip340; +import 'package:swarm/swarm.dart'; +import 'package:test/test.dart'; + +const _privKey = + '0000000000000000000000000000000000000000000000000000000000000001'; + +const _relayUrl = 'wss://relay.otherwhere.app/'; + +void main() { + late NostrSignerActor signer; + late NostrPublishActor publisher; + late String pubkey; + + setUp(() { + signer = NostrSignerActor(_privKey); + publisher = NostrPublishActor(relayUrls: [_relayUrl]); + pubkey = bip340.getPublicKey(_privKey); + }); + + Future signAndPublish(SignEvent msg) async { + final signed = await signer.handle(msg); + final result = await publisher.handle( + PublishBatch([(signed as EventSigned).event]), + ); + return (result as BatchPublished).outcomes[_relayUrl]!; + } + + group('relay.otherwhere.app', () { + test('kind 0 (metadata) is confirmed', () async { + final outcome = await signAndPublish( + const SignEvent( + kind: 0, + content: '{"name":"swarm-integration-test"}', + ), + ); + expect(outcome, isA()); + }, timeout: const Timeout(Duration(seconds: 15))); + + test('kind 3 (contacts) is confirmed', () async { + final outcome = await signAndPublish( + const SignEvent(kind: 3, content: ''), + ); + expect(outcome, isA()); + }, timeout: const Timeout(Duration(seconds: 15))); + + test('kind 37515 (place) is confirmed', () async { + final expiration = + DateTime.now().add(const Duration(days: 30)).millisecondsSinceEpoch ~/ + 1000; + final outcome = await signAndPublish( + SignEvent( + kind: 37515, + tags: [ + ['d', 'place:swarmtest0'], + ['g', 'u33dbf'], + ['expiration', '$expiration'], + ], + content: jsonEncode({ + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [13.405, 52.52], + }, + 'properties': { + 'name': 'Swarm Test Place', + 'description': 'Integration test fixture', + 'type': 'test', + }, + }), + ), + ); + expect(outcome, isA()); + }, timeout: const Timeout(Duration(seconds: 15))); + + test('kind 37518 (check-in) is confirmed', () async { + final expiration = + DateTime.now().add(const Duration(days: 1)).millisecondsSinceEpoch ~/ + 1000; + final outcome = await signAndPublish( + SignEvent( + kind: 37518, + tags: [ + ['a', '37515:$pubkey:place:swarmtest0'], + ['d', 'checkin:swarmtest0'], + ['g', 'u33dbf'], + ['alt', 'Check-in at Swarm Test Place'], + ['expiration', '$expiration'], + ['t', 'test'], + ], + content: jsonEncode({'note': 'Integration test check-in'}), + ), + ); + expect(outcome, isA()); + }, timeout: const Timeout(Duration(seconds: 15))); + + test('kind 1 (text note) is rejected by allowlist', () async { + final outcome = await signAndPublish( + const SignEvent(kind: 1, content: 'this should be rejected'), + ); + expect(outcome, isA()); + }, timeout: const Timeout(Duration(seconds: 15))); + }); +} diff --git a/test/nostr_signer_actor_test.dart b/test/nostr_signer_actor_test.dart new file mode 100644 index 0000000..7a9072b --- /dev/null +++ b/test/nostr_signer_actor_test.dart @@ -0,0 +1,107 @@ +import 'dart:convert'; + +import 'package:bip340/bip340.dart' as bip340; +import 'package:crypto/crypto.dart'; +import 'package:swarm/swarm.dart'; +import 'package:test/test.dart'; + +const _privKey = + '0000000000000000000000000000000000000000000000000000000000000001'; + +void main() { + late NostrSignerActor signer; + + setUp(() { + signer = NostrSignerActor(_privKey); + }); + + test('GetPublicKey returns correct pubkey', () async { + final result = await signer.handle(const GetPublicKey()); + expect(result, isA()); + expect( + (result as PublicKeyResult).publicKey, + equals(bip340.getPublicKey(_privKey)), + ); + }); + + test('SignEvent returns EventSigned', () async { + final result = await signer.handle( + const SignEvent(kind: 1, content: 'hello'), + ); + expect(result, isA()); + }); + + test('signed event has all required fields', () async { + final result = await signer.handle( + const SignEvent(kind: 1, tags: [], content: 'test content'), + ); + final event = + jsonDecode((result as EventSigned).event) as Map; + expect(event.containsKey('id'), isTrue); + expect(event.containsKey('pubkey'), isTrue); + expect(event.containsKey('created_at'), isTrue); + expect(event.containsKey('kind'), isTrue); + expect(event.containsKey('tags'), isTrue); + expect(event.containsKey('content'), isTrue); + expect(event.containsKey('sig'), isTrue); + }); + + test('event id is correct SHA-256', () async { + const kind = 1; + const content = 'sha256 test'; + const tags = >[]; + const ts = 1700000000; + + final result = await signer.handle( + const SignEvent(kind: kind, tags: tags, content: content, createdAt: ts), + ); + final event = + jsonDecode((result as EventSigned).event) as Map; + + final pubkey = bip340.getPublicKey(_privKey); + final ser = jsonEncode([0, pubkey, ts, kind, tags, content]); + final expectedId = sha256 + .convert(utf8.encode(ser)) + .bytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + + expect(event['id'], equals(expectedId)); + }); + + test('signature verifies', () async { + final result = await signer.handle( + const SignEvent(kind: 1, content: 'verify me', createdAt: 1700000000), + ); + final event = + jsonDecode((result as EventSigned).event) as Map; + final pubkey = event['pubkey'] as String; + final id = event['id'] as String; + final sig = event['sig'] as String; + + expect(bip340.verify(pubkey, id, sig), isTrue); + }); + + test('createdAt is used when provided', () async { + const ts = 1234567890; + final result = await signer.handle( + const SignEvent(kind: 1, content: 'timestamp test', createdAt: ts), + ); + final event = + jsonDecode((result as EventSigned).event) as Map; + expect(event['created_at'], equals(ts)); + }); + + test('createdAt defaults to now', () async { + final before = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final result = await signer.handle( + const SignEvent(kind: 1, content: 'now test'), + ); + final after = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final event = + jsonDecode((result as EventSigned).event) as Map; + final ts = event['created_at'] as int; + expect(ts, greaterThanOrEqualTo(before)); + expect(ts, lessThanOrEqualTo(after + 2)); + }); +}