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.
This commit is contained in:
vjrj 2026-07-10 01:57:25 +02:00
parent 6eb1517ffb
commit 49b872b405
16 changed files with 1284 additions and 0 deletions

View file

@ -0,0 +1,152 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'nip99.dart';
import 'nostr_event.dart';
import 'offer.dart';
import 'offer_transport.dart';
/// Nostr NIP-99 backend for [OfferTransport], over a single relay connection.
///
/// Signs every offer with the caller's derived Nostr key (Q1) and publishes it
/// as a kind-30402 classified listing (Q2/Q3). Discovery is a REQ filtered by
/// kind + `#g` geohash tag. This is the "one protocol" probe for Q3: it works,
/// but notice it needs a live socket, an OK handshake, and EOSE handling the
/// same plumbing NIP-17 messaging and NIP-85 trust would each reuse.
class NostrOfferTransport implements OfferTransport {
NostrOfferTransport._(this._socket, this._privateKeyHex, this._pubkeyHex) {
_socket.listen(_onData, onDone: _onDone);
}
final WebSocket _socket;
final String _privateKeyHex;
final String _pubkeyHex;
final Nip99Codec _codec = Nip99Codec();
final _incoming = StreamController<List<dynamic>>.broadcast();
int _subCounter = 0;
/// Connects to [relayUrl] and returns a transport signing as [pubkeyHex]
/// (BIP340 x-only) with [privateKeyHex]. Both come from `NostrKey`.
static Future<NostrOfferTransport> connect(
String relayUrl, {
required String privateKeyHex,
required String pubkeyHex,
}) async {
final socket = await WebSocket.connect(relayUrl);
return NostrOfferTransport._(socket, privateKeyHex, pubkeyHex);
}
void _onData(dynamic data) => _incoming.add(jsonDecode(data as String) as List);
void _onDone() {
if (!_incoming.isClosed) _incoming.close();
}
@override
Future<PublishResult> publish(Offer offer) async {
final event = _codec.encode(
offer,
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
event.signWith(_privateKeyHex);
final ok = _incoming.stream.firstWhere(
(m) => m[0] == 'OK' && m[1] == event.id,
);
_socket.add(jsonEncode(['EVENT', event.toJson()]));
final response = await ok.timeout(const Duration(seconds: 5));
return PublishResult(
accepted: response[2] as bool,
transportRef: event.id,
message: response.length > 3 ? response[3] as String : '',
);
}
@override
Stream<Offer> discover(DiscoveryQuery query) {
final subId = 'sub${_subCounter++}';
final filter = <String, dynamic>{
'kinds': [Nip99Codec.kindActive],
'#g': [query.geohashPrefix],
'limit': query.limit,
};
late StreamController<Offer> controller;
controller = StreamController<Offer>(
onListen: () => _socket.add(jsonEncode(['REQ', subId, filter])),
onCancel: () {
_socket.add(jsonEncode(['CLOSE', subId]));
},
);
final sub = _incoming.stream.listen((m) {
if (m[0] == 'EVENT' && m[1] == subId) {
final offer = _codec.decode(
NostrEvent.fromJson(m[2] as Map<String, dynamic>),
);
if (query.types.isEmpty || query.types.contains(offer.type)) {
controller.add(offer);
}
}
// EOSE arrives here too; we keep the stream open for live offers.
});
controller.onCancel = () {
_socket.add(jsonEncode(['CLOSE', subId]));
sub.cancel();
};
return controller.stream;
}
/// Convenience for tests/metrics: collect matches up to EOSE, then stop.
Future<List<Offer>> discoverUntilEose(DiscoveryQuery query) async {
final subId = 'once${_subCounter++}';
final filter = <String, dynamic>{
'kinds': [Nip99Codec.kindActive],
'#g': [query.geohashPrefix],
'limit': query.limit,
};
final results = <Offer>[];
final done = Completer<void>();
final sub = _incoming.stream.listen((m) {
if (m[1] != subId) return;
if (m[0] == 'EVENT') {
final offer = _codec.decode(
NostrEvent.fromJson(m[2] as Map<String, dynamic>),
);
if (query.types.isEmpty || query.types.contains(offer.type)) {
results.add(offer);
}
} else if (m[0] == 'EOSE' && !done.isCompleted) {
done.complete();
}
});
_socket.add(jsonEncode(['REQ', subId, filter]));
await done.future.timeout(const Duration(seconds: 5));
_socket.add(jsonEncode(['CLOSE', subId]));
await sub.cancel();
return results;
}
@override
Future<void> retract(String offerId) async {
// NIP-09 deletion request referencing the addressable coordinate.
final event = NostrEvent(
pubkey: _pubkeyHex,
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
kind: 5,
tags: [
['a', '${Nip99Codec.kindActive}:$_pubkeyHex:$offerId'],
],
content: 'offer retracted',
);
event.signWith(_privateKeyHex);
_socket.add(jsonEncode(['EVENT', event.toJson()]));
}
@override
Future<void> close() async {
await _socket.close();
if (!_incoming.isClosed) await _incoming.close();
}
}