import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'nostr_event.dart'; /// A tiny, in-process Nostr relay (NIP-01 subset): EVENT / REQ / EOSE / CLOSE, /// filtering by `kinds`, `authors`, `ids` and `#g` (exact tag). Enough to prove /// the publish→discover round-trip WITHOUT touching the network, so the test is /// hermetic and CI-safe. Not a real relay — no persistence, no NIP-11, no auth. /// /// Addressable events (kind 30000–39999, NIP-99's 30402) replace by /// (kind, pubkey, `d`) like a real relay, so re-publishing an offer updates it. class MiniRelay { MiniRelay._(this._server); final HttpServer _server; final List _events = []; final Set<_Sub> _subs = {}; int get port => _server.port; String get url => 'ws://127.0.0.1:$port'; /// Number of events currently stored (for assertions/metrics). int get storedCount => _events.length; static Future start() async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); final relay = MiniRelay._(server); relay._accept(); return relay; } void _accept() { _server.listen((req) async { if (!WebSocketTransformer.isUpgradeRequest(req)) { req.response.statusCode = HttpStatus.badRequest; await req.response.close(); return; } final ws = await WebSocketTransformer.upgrade(req); final socketSubs = <_Sub>{}; ws.listen( (data) => _onMessage(ws, socketSubs, data as String), onDone: () => _subs.removeAll(socketSubs), onError: (_) => _subs.removeAll(socketSubs), ); }); } void _onMessage(WebSocket ws, Set<_Sub> socketSubs, String data) { final msg = jsonDecode(data) as List; switch (msg[0]) { case 'EVENT': _handleEvent(ws, NostrEvent.fromJson(msg[1] as Map)); case 'REQ': final subId = msg[1] as String; final filters = msg.sublist(2).cast>(); final sub = _Sub(ws, subId, filters); _subs.add(sub); socketSubs.add(sub); for (final e in _events) { if (sub.matches(e)) _send(ws, ['EVENT', subId, e.toJson()]); } _send(ws, ['EOSE', subId]); case 'CLOSE': final subId = msg[1] as String; socketSubs.removeWhere((s) => s.id == subId); _subs.removeWhere((s) => s.ws == ws && s.id == subId); } } void _handleEvent(WebSocket ws, NostrEvent event) { if (!event.verify()) { _send(ws, ['OK', event.id, false, 'invalid: bad signature']); return; } if (event.kind >= 30000 && event.kind < 40000) { final d = event.tag('d') ?? ''; _events.removeWhere( (e) => e.kind == event.kind && e.pubkey == event.pubkey && (e.tag('d') ?? '') == d, ); } _events.add(event); _send(ws, ['OK', event.id, true, '']); for (final sub in _subs) { if (sub.matches(event)) _send(sub.ws, ['EVENT', sub.id, event.toJson()]); } } void _send(WebSocket ws, Object message) => ws.add(jsonEncode(message)); Future stop() => _server.close(force: true); } class _Sub { _Sub(this.ws, this.id, this.filters); final WebSocket ws; final String id; final List> filters; bool matches(NostrEvent e) => filters.any((f) => _matchesFilter(e, f)); bool _matchesFilter(NostrEvent e, Map f) { if (f['kinds'] != null && !(f['kinds'] as List).contains(e.kind)) { return false; } if (f['authors'] != null && !(f['authors'] as List).contains(e.pubkey)) { return false; } if (f['ids'] != null && !(f['ids'] as List).contains(e.id)) return false; for (final entry in f.entries) { if (!entry.key.startsWith('#')) continue; final tagName = entry.key.substring(1); final wanted = (entry.value as List).cast(); final present = e.tags .where((t) => t.isNotEmpty && t[0] == tagName && t.length > 1) .map((t) => t[1]); if (!wanted.any(present.contains)) return false; } return true; } }