basic nostr actors

This commit is contained in:
randogoth 2026-06-07 16:45:43 +03:00
parent c14de9c3e9
commit f1e54f2722
13 changed files with 1081 additions and 2 deletions

View 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
}
}
}

View 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));
});
});
}

View 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)));
});
}

View 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>());
});
});
}

View 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)));
});
}

View 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));
});
}