basic nostr actors
This commit is contained in:
parent
c14de9c3e9
commit
f1e54f2722
13 changed files with 1081 additions and 2 deletions
102
lib/src/nostr_fetch_actor.dart
Normal file
102
lib/src/nostr_fetch_actor.dart
Normal file
|
|
@ -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<String> events;
|
||||||
|
const EventsFetched(this.events);
|
||||||
|
}
|
||||||
|
|
||||||
|
class NostrFetchActor with Handler<FetchMessage, FetchResult> {
|
||||||
|
final List<String> _relayUrls;
|
||||||
|
final Duration _eoseDeadline;
|
||||||
|
final RelayConnection Function(String url) _connectionFactory;
|
||||||
|
|
||||||
|
NostrFetchActor({
|
||||||
|
required List<String> relayUrls,
|
||||||
|
Duration eoseDeadline = const Duration(seconds: 5),
|
||||||
|
RelayConnection Function(String url) connectionFactory =
|
||||||
|
WebSocketRelayConnection.new,
|
||||||
|
}) : _relayUrls = relayUrls,
|
||||||
|
_eoseDeadline = eoseDeadline,
|
||||||
|
_connectionFactory = connectionFactory;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FetchResult> handle(FetchMessage message) => switch (message) {
|
||||||
|
FetchEvents(:final filter) => _fetchEvents(filter),
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<FetchResult> _fetchEvents(String filter) async {
|
||||||
|
final results = await Future.wait(
|
||||||
|
_relayUrls.map(
|
||||||
|
(url) => _fetchFromRelay(url, filter).catchError((_) => <String>[]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final seen = <String>{};
|
||||||
|
final merged = <String>[];
|
||||||
|
for (final batch in results) {
|
||||||
|
for (final eventJson in batch) {
|
||||||
|
final id =
|
||||||
|
(jsonDecode(eventJson) as Map<String, dynamic>)['id'] as String;
|
||||||
|
if (seen.add(id)) {
|
||||||
|
merged.add(eventJson);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return EventsFetched(merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<String>> _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 = <String>[];
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
144
lib/src/nostr_publish_actor.dart
Normal file
144
lib/src/nostr_publish_actor.dart
Normal file
|
|
@ -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<String> events;
|
||||||
|
const PublishBatch(this.events);
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class PublishResult {
|
||||||
|
const PublishResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
final class BatchPublished extends PublishResult {
|
||||||
|
final Map<String, RelayOutcome> outcomes;
|
||||||
|
const BatchPublished(this.outcomes);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _RelayRejectedException implements Exception {
|
||||||
|
final String reason;
|
||||||
|
const _RelayRejectedException(this.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
class NostrPublishActor with Handler<PublishMessage, PublishResult> {
|
||||||
|
final List<String> _relayUrls;
|
||||||
|
final RetryConfig _retryConfig;
|
||||||
|
final Duration _batchTimeout;
|
||||||
|
final RelayConnection Function(String url) _connectionFactory;
|
||||||
|
|
||||||
|
NostrPublishActor({
|
||||||
|
required List<String> 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<PublishResult> handle(PublishMessage message) => switch (message) {
|
||||||
|
PublishBatch(:final events) => _publishBatch(events),
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<PublishResult> _publishBatch(List<String> events) async {
|
||||||
|
final ids = events
|
||||||
|
.map((e) => (jsonDecode(e) as Map<String, dynamic>)['id'] as String)
|
||||||
|
.toList();
|
||||||
|
final outcomes = await Future.wait(
|
||||||
|
_relayUrls.map((url) => _publishToRelay(url, events, ids)),
|
||||||
|
);
|
||||||
|
return BatchPublished(Map.fromIterables(_relayUrls, outcomes));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<RelayOutcome> _publishToRelay(
|
||||||
|
String url,
|
||||||
|
List<String> events,
|
||||||
|
List<String> 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<void> _attemptPublish(
|
||||||
|
String url,
|
||||||
|
List<String> events,
|
||||||
|
List<String> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
85
lib/src/nostr_signer_actor.dart
Normal file
85
lib/src/nostr_signer_actor.dart
Normal file
|
|
@ -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<List<String>> 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<SignerMessage, SignerResult> {
|
||||||
|
final String _privateKey;
|
||||||
|
final String _publicKey;
|
||||||
|
|
||||||
|
NostrSignerActor(String privateKey)
|
||||||
|
: _privateKey = privateKey,
|
||||||
|
_publicKey = bip340.getPublicKey(privateKey);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SignerResult> 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<List<String>> 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
42
lib/src/relay_connection.dart
Normal file
42
lib/src/relay_connection.dart
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
|
||||||
|
abstract interface class RelayConnection {
|
||||||
|
Future<void> connect();
|
||||||
|
Future<void> send(String message);
|
||||||
|
Stream<String> get messages;
|
||||||
|
Future<void> close();
|
||||||
|
}
|
||||||
|
|
||||||
|
final class WebSocketRelayConnection implements RelayConnection {
|
||||||
|
final String _url;
|
||||||
|
WebSocketChannel? _channel;
|
||||||
|
StreamController<String>? _controller;
|
||||||
|
|
||||||
|
WebSocketRelayConnection(String url) : _url = url;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> connect() async {
|
||||||
|
_channel = WebSocketChannel.connect(Uri.parse(_url));
|
||||||
|
await _channel!.ready;
|
||||||
|
_controller = StreamController<String>();
|
||||||
|
_channel!.stream.cast<String>().listen(
|
||||||
|
_controller!.add,
|
||||||
|
onError: _controller!.addError,
|
||||||
|
onDone: _controller!.close,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> send(String message) async => _channel!.sink.add(message);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> get messages => _controller!.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() async {
|
||||||
|
await _channel?.sink.close();
|
||||||
|
_channel = null;
|
||||||
|
_controller = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +1,5 @@
|
||||||
export 'src/file_actor.dart';
|
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';
|
||||||
|
|
|
||||||
28
pubspec.lock
28
pubspec.lock
|
|
@ -41,6 +41,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.1"
|
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:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -82,7 +90,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.15.0"
|
version: "1.15.0"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: crypto
|
name: crypto
|
||||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
|
|
@ -201,6 +209,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
|
pointycastle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pointycastle
|
||||||
|
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.0"
|
||||||
pool:
|
pool:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -217,6 +233,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
retry:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: retry
|
||||||
|
sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.2"
|
||||||
shelf:
|
shelf:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -370,7 +394,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.1"
|
version: "1.0.1"
|
||||||
web_socket_channel:
|
web_socket_channel:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: web_socket_channel
|
name: web_socket_channel
|
||||||
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ environment:
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
actors: ^0.11.0
|
actors: ^0.11.0
|
||||||
|
bip340: ^0.3.1
|
||||||
|
crypto: ^3.0.7
|
||||||
|
retry: ^3.1.2
|
||||||
|
web_socket_channel: ^3.0.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
lints: ^6.0.0
|
lints: ^6.0.0
|
||||||
|
|
|
||||||
57
test/fake_relay_connection.dart
Normal file
57
test/fake_relay_connection.dart
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:swarm/swarm.dart';
|
||||||
|
|
||||||
|
final class FakeRelayConnection implements RelayConnection {
|
||||||
|
final _controller = StreamController<String>.broadcast();
|
||||||
|
final sentMessages = <String>[];
|
||||||
|
final queuedResponses = <String>[];
|
||||||
|
bool isConnected = false;
|
||||||
|
bool autoRespond = true;
|
||||||
|
bool throwOnConnect = false;
|
||||||
|
void Function(String subId)? onReq;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> connect() async {
|
||||||
|
if (throwOnConnect) throw Exception('connection failed');
|
||||||
|
isConnected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> send(String message) async {
|
||||||
|
sentMessages.add(message);
|
||||||
|
if (queuedResponses.isNotEmpty) {
|
||||||
|
final response = queuedResponses.removeAt(0);
|
||||||
|
Future(() => _controller.add(response));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_tryAutoRespond(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> get messages => _controller.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() async => isConnected = false;
|
||||||
|
|
||||||
|
void push(String message) => _controller.add(message);
|
||||||
|
|
||||||
|
Future<void> 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<String, dynamic>;
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
152
test/nostr_fetch_actor_test.dart
Normal file
152
test/nostr_fetch_actor_test.dart
Normal file
|
|
@ -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<String, dynamic> _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<EventsFetched>());
|
||||||
|
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<EventsFetched>());
|
||||||
|
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<EventsFetched>());
|
||||||
|
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<EventsFetched>());
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
99
test/nostr_fetch_integration_test.dart
Normal file
99
test/nostr_fetch_integration_test.dart
Normal file
|
|
@ -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<String, dynamic> 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<String, dynamic>;
|
||||||
|
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<String, dynamic>;
|
||||||
|
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<String, dynamic>;
|
||||||
|
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<String, dynamic>;
|
||||||
|
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)));
|
||||||
|
});
|
||||||
|
}
|
||||||
150
test/nostr_publish_actor_test.dart
Normal file
150
test/nostr_publish_actor_test.dart
Normal file
|
|
@ -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<Map>());
|
||||||
|
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<BatchPublished>());
|
||||||
|
final outcomes = (result as BatchPublished).outcomes;
|
||||||
|
expect(outcomes['ws://relay1'], isA<RelayConfirmed>());
|
||||||
|
});
|
||||||
|
|
||||||
|
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<BatchPublished>());
|
||||||
|
final outcomes = (result as BatchPublished).outcomes;
|
||||||
|
expect(outcomes['ws://relay1'], isA<RelayRejected>());
|
||||||
|
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<BatchPublished>());
|
||||||
|
final outcomes = (result as BatchPublished).outcomes;
|
||||||
|
expect(outcomes['ws://relay1'], isA<RelayFailed>());
|
||||||
|
|
||||||
|
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<BatchPublished>());
|
||||||
|
final outcomes = (result as BatchPublished).outcomes;
|
||||||
|
expect(outcomes['ws://relay1'], isA<RelayFailed>());
|
||||||
|
});
|
||||||
|
|
||||||
|
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<BatchPublished>());
|
||||||
|
final outcomes = (result as BatchPublished).outcomes;
|
||||||
|
expect(outcomes.keys, containsAll(['ws://relay1', 'ws://relay2']));
|
||||||
|
expect(outcomes['ws://relay1'], isA<RelayConfirmed>());
|
||||||
|
expect(outcomes['ws://relay2'], isA<RelayConfirmed>());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
109
test/nostr_relay_integration_test.dart
Normal file
109
test/nostr_relay_integration_test.dart
Normal file
|
|
@ -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<RelayOutcome> 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<RelayConfirmed>());
|
||||||
|
}, 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<RelayConfirmed>());
|
||||||
|
}, 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<RelayConfirmed>());
|
||||||
|
}, 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<RelayConfirmed>());
|
||||||
|
}, 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<RelayRejected>());
|
||||||
|
}, timeout: const Timeout(Duration(seconds: 15)));
|
||||||
|
});
|
||||||
|
}
|
||||||
107
test/nostr_signer_actor_test.dart
Normal file
107
test/nostr_signer_actor_test.dart
Normal file
|
|
@ -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<PublicKeyResult>());
|
||||||
|
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<EventSigned>());
|
||||||
|
});
|
||||||
|
|
||||||
|
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<String, dynamic>;
|
||||||
|
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 = <List<String>>[];
|
||||||
|
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<String, dynamic>;
|
||||||
|
|
||||||
|
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<String, dynamic>;
|
||||||
|
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<String, dynamic>;
|
||||||
|
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<String, dynamic>;
|
||||||
|
final ts = event['created_at'] as int;
|
||||||
|
expect(ts, greaterThanOrEqualTo(before));
|
||||||
|
expect(ts, lessThanOrEqualTo(after + 2));
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue