Throwaway research spike (spike/ branch, outside pub workspace, no prod deps touched). Validates the open Block 2 decisions before committing a funded round: 1. Deterministic one-way secp256k1 (Nostr) key derived from the Ğ1 root seed via HKDF — user still backs up ONE thing. Reproducible + signs (BIP340). 2. OfferTransport abstraction over Nostr NIP-99 (kind 30402); Offer stays inventory/location-agnostic, geohash coarsened on the wire (tested). 3. publish->discover-by-geohash proven end-to-end against an in-process hermetic mini-relay (~34ms round-trip). Findings + risks + recommendation in docs/design/spike-block2-findings.md. Block 1 suite untouched. 14 tests green, analyzer clean.
124 lines
4 KiB
Dart
124 lines
4 KiB
Dart
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<NostrEvent> _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<MiniRelay> 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<String, dynamic>));
|
||
case 'REQ':
|
||
final subId = msg[1] as String;
|
||
final filters = msg.sublist(2).cast<Map<String, dynamic>>();
|
||
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<void> stop() => _server.close(force: true);
|
||
}
|
||
|
||
class _Sub {
|
||
_Sub(this.ws, this.id, this.filters);
|
||
|
||
final WebSocket ws;
|
||
final String id;
|
||
final List<Map<String, dynamic>> filters;
|
||
|
||
bool matches(NostrEvent e) => filters.any((f) => _matchesFilter(e, f));
|
||
|
||
bool _matchesFilter(NostrEvent e, Map<String, dynamic> 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<String>();
|
||
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;
|
||
}
|
||
}
|