tane/spike/block2_spike/lib/src/mini_relay.dart
vjrj 49b872b405 spike(block2): de-risk social layer — key derivation, OfferTransport, NIP-99 round-trip
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.
2026-07-10 01:57:25 +02:00

124 lines
4 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 3000039999, 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;
}
}