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:
parent
6eb1517ffb
commit
49b872b405
16 changed files with 1284 additions and 0 deletions
3
spike/block2_spike/.gitignore
vendored
Normal file
3
spike/block2_spike/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Throwaway spike — don't commit build artifacts or the resolved lockfile.
|
||||
.dart_tool/
|
||||
pubspec.lock
|
||||
12
spike/block2_spike/lib/block2_spike.dart
Normal file
12
spike/block2_spike/lib/block2_spike.dart
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/// THROWAWAY Block 2 de-risking spike. Not production; not on the pub workspace.
|
||||
library;
|
||||
|
||||
export 'src/bech32.dart';
|
||||
export 'src/hkdf.dart';
|
||||
export 'src/mini_relay.dart';
|
||||
export 'src/nip99.dart';
|
||||
export 'src/nostr_event.dart';
|
||||
export 'src/nostr_key.dart';
|
||||
export 'src/nostr_offer_transport.dart';
|
||||
export 'src/offer.dart';
|
||||
export 'src/offer_transport.dart';
|
||||
54
spike/block2_spike/lib/src/bech32.dart
Normal file
54
spike/block2_spike/lib/src/bech32.dart
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/// Self-contained bech32 encode (BIP-173 checksum), enough for NIP-19
|
||||
/// `npub`/`nsec`. Written inline to avoid a dependency's version quirks; NIP-19
|
||||
/// is plain bech32 (checksum constant 1), not bech32m.
|
||||
const _charset = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
||||
|
||||
int _polymod(List<int> values) {
|
||||
const gen = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
|
||||
var chk = 1;
|
||||
for (final v in values) {
|
||||
final top = chk >> 25;
|
||||
chk = ((chk & 0x1ffffff) << 5) ^ v;
|
||||
for (var i = 0; i < 5; i++) {
|
||||
if ((top >> i) & 1 == 1) chk ^= gen[i];
|
||||
}
|
||||
}
|
||||
return chk;
|
||||
}
|
||||
|
||||
List<int> _hrpExpand(String hrp) => [
|
||||
...hrp.codeUnits.map((c) => c >> 5),
|
||||
0,
|
||||
...hrp.codeUnits.map((c) => c & 31),
|
||||
];
|
||||
|
||||
List<int> _createChecksum(String hrp, List<int> data) {
|
||||
final values = [..._hrpExpand(hrp), ...data, 0, 0, 0, 0, 0, 0];
|
||||
final mod = _polymod(values) ^ 1;
|
||||
return List.generate(6, (i) => (mod >> (5 * (5 - i))) & 31);
|
||||
}
|
||||
|
||||
/// Converts 8-bit bytes to 5-bit groups (bech32 payload), padding with zeros.
|
||||
List<int> _convertBits8to5(List<int> data) {
|
||||
var acc = 0;
|
||||
var bits = 0;
|
||||
final out = <int>[];
|
||||
for (final value in data) {
|
||||
acc = (acc << 8) | value;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
bits -= 5;
|
||||
out.add((acc >> bits) & 31);
|
||||
}
|
||||
}
|
||||
if (bits > 0) out.add((acc << (5 - bits)) & 31);
|
||||
return out;
|
||||
}
|
||||
|
||||
/// bech32-encodes [data] (raw bytes) under [hrp] (e.g. `npub`).
|
||||
String bech32Encode(String hrp, List<int> data) {
|
||||
final five = _convertBits8to5(data);
|
||||
final checksum = _createChecksum(hrp, five);
|
||||
final combined = [...five, ...checksum];
|
||||
return '${hrp}1${combined.map((v) => _charset[v]).join()}';
|
||||
}
|
||||
32
spike/block2_spike/lib/src/hkdf.dart
Normal file
32
spike/block2_spike/lib/src/hkdf.dart
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// Minimal, self-contained HKDF-SHA256 (RFC 5869), synchronous.
|
||||
///
|
||||
/// Spike-local so the derivation stays pure Dart and easy to reason about. In
|
||||
/// production this would reuse `commons_core`'s `Hkdf` (the `cryptography`
|
||||
/// package already ships one — see backup_box.dart), keyed by a domain string.
|
||||
Uint8List hkdfSha256({
|
||||
required List<int> ikm,
|
||||
required String info,
|
||||
List<int> salt = const [],
|
||||
int length = 32,
|
||||
}) {
|
||||
// Extract.
|
||||
final actualSalt = salt.isEmpty ? Uint8List(32) : salt; // HashLen zeros
|
||||
final prk = Hmac(sha256, actualSalt).convert(ikm).bytes;
|
||||
|
||||
// Expand.
|
||||
final infoBytes = info.codeUnits;
|
||||
final out = <int>[];
|
||||
var previous = <int>[];
|
||||
var counter = 1;
|
||||
while (out.length < length) {
|
||||
final input = <int>[...previous, ...infoBytes, counter];
|
||||
previous = Hmac(sha256, prk).convert(input).bytes;
|
||||
out.addAll(previous);
|
||||
counter++;
|
||||
}
|
||||
return Uint8List.fromList(out.sublist(0, length));
|
||||
}
|
||||
124
spike/block2_spike/lib/src/mini_relay.dart
Normal file
124
spike/block2_spike/lib/src/mini_relay.dart
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
118
spike/block2_spike/lib/src/nip99.dart
Normal file
118
spike/block2_spike/lib/src/nip99.dart
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import 'nostr_event.dart';
|
||||
import 'offer.dart';
|
||||
|
||||
/// Maps [Offer] ↔ Nostr NIP-99 "Classified Listing" events (kind 30402), the
|
||||
/// first concrete `OfferTransport` backend (sharing-model.md §2).
|
||||
///
|
||||
/// This codec is where the privacy contract is *enforced on the wire*: the only
|
||||
/// location that ever leaves the device is a coarse geohash, truncated here to
|
||||
/// [maxGeohashChars]. There is no field that could carry the inventory or an
|
||||
/// exact address, because [Offer] itself has none — the seam does the work.
|
||||
class Nip99Codec {
|
||||
/// NIP-99 addressable classified listing.
|
||||
static const kindActive = 30402;
|
||||
|
||||
/// A geohash of 5 chars ≈ ±2.4 km cell — the coarse "near you" of the mocks.
|
||||
/// Anything finer is refused so a slip in the caller can't leak a precise fix.
|
||||
static const maxGeohashChars = 5;
|
||||
|
||||
/// Builds an UNSIGNED event from [offer]. Caller signs with the derived key.
|
||||
NostrEvent encode(Offer offer, {required int createdAt}) {
|
||||
final coarse = _coarsen(offer.approxGeohash);
|
||||
|
||||
final tags = <List<String>>[
|
||||
['d', offer.id],
|
||||
['title', offer.summary],
|
||||
['status', offer.status == OfferStatus.active ? 'active' : 'sold'],
|
||||
// Reciprocity mode is a Tanemaki concept NIP-99 lacks; carry it explicitly.
|
||||
['offer_type', offer.type.name],
|
||||
];
|
||||
// NIP-52 geohash ladder: one `g` tag per prefix length so an area query
|
||||
// matches by *exact* tag (how Nostr relays filter) yet still finds coarser
|
||||
// searches. Coarse only — NO "location"/address/exact-fix tag ever.
|
||||
for (var i = 1; i <= coarse.length; i++) {
|
||||
tags.add(['g', coarse.substring(0, i)]);
|
||||
}
|
||||
if (offer.category != null) tags.add(['t', offer.category!]);
|
||||
if (offer.radiusKm != null) {
|
||||
tags.add(['radius_km', '${offer.radiusKm}']);
|
||||
}
|
||||
if (offer.type == OfferType.sale && offer.priceAmount != null) {
|
||||
tags.add([
|
||||
'price',
|
||||
'${offer.priceAmount}',
|
||||
offer.priceCurrency ?? 'EUR',
|
||||
]);
|
||||
}
|
||||
if (offer.type == OfferType.exchange && offer.exchangeTerms != null) {
|
||||
tags.add(['exchange_terms', offer.exchangeTerms!]);
|
||||
}
|
||||
if (offer.imageUrl != null) tags.add(['image', offer.imageUrl!]);
|
||||
if (offer.expiresAt != null) {
|
||||
tags.add([
|
||||
'expiration',
|
||||
'${offer.expiresAt!.millisecondsSinceEpoch ~/ 1000}',
|
||||
]);
|
||||
}
|
||||
|
||||
return NostrEvent(
|
||||
pubkey: offer.authorPubkeyHex,
|
||||
createdAt: createdAt,
|
||||
kind: kindActive,
|
||||
tags: tags,
|
||||
content: offer.summary,
|
||||
);
|
||||
}
|
||||
|
||||
/// Reconstructs an [Offer] from a received event.
|
||||
Offer decode(NostrEvent event) {
|
||||
final priceTag = _fullTag(event, 'price');
|
||||
return Offer(
|
||||
id: event.tag('d') ?? event.id,
|
||||
authorPubkeyHex: event.pubkey,
|
||||
summary: event.tag('title') ?? event.content,
|
||||
type: _parseType(event.tag('offer_type')),
|
||||
status: event.tag('status') == 'sold'
|
||||
? OfferStatus.closed
|
||||
: OfferStatus.active,
|
||||
category: event.tag('t'),
|
||||
approxGeohash: _longestGeohash(event),
|
||||
radiusKm: int.tryParse(event.tag('radius_km') ?? ''),
|
||||
priceAmount: priceTag != null && priceTag.length > 1
|
||||
? num.tryParse(priceTag[1])
|
||||
: null,
|
||||
priceCurrency: priceTag != null && priceTag.length > 2
|
||||
? priceTag[2]
|
||||
: null,
|
||||
exchangeTerms: event.tag('exchange_terms'),
|
||||
imageUrl: event.tag('image'),
|
||||
);
|
||||
}
|
||||
|
||||
String _coarsen(String geohash) => geohash.length > maxGeohashChars
|
||||
? geohash.substring(0, maxGeohashChars)
|
||||
: geohash;
|
||||
|
||||
/// The finest `g` tag present (top of the prefix ladder).
|
||||
String _longestGeohash(NostrEvent event) {
|
||||
var best = '';
|
||||
for (final t in event.tags) {
|
||||
if (t.isNotEmpty && t[0] == 'g' && t.length > 1 && t[1].length > best.length) {
|
||||
best = t[1];
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
List<String>? _fullTag(NostrEvent event, String name) {
|
||||
for (final t in event.tags) {
|
||||
if (t.isNotEmpty && t[0] == name) return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
OfferType _parseType(String? name) => OfferType.values.firstWhere(
|
||||
(t) => t.name == name,
|
||||
orElse: () => OfferType.gift,
|
||||
);
|
||||
}
|
||||
77
spike/block2_spike/lib/src/nostr_event.dart
Normal file
77
spike/block2_spike/lib/src/nostr_event.dart
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:bip340/bip340.dart' as bip340;
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// A minimal Nostr event (NIP-01): enough to build, id, sign and serialise
|
||||
/// NIP-99 classified listings. Throwaway — production would use a vetted client.
|
||||
class NostrEvent {
|
||||
NostrEvent({
|
||||
required this.pubkey,
|
||||
required this.createdAt,
|
||||
required this.kind,
|
||||
required this.tags,
|
||||
required this.content,
|
||||
this.id = '',
|
||||
this.sig = '',
|
||||
});
|
||||
|
||||
final String pubkey; // x-only hex
|
||||
final int createdAt; // unix seconds
|
||||
final int kind;
|
||||
final List<List<String>> tags;
|
||||
final String content;
|
||||
String id;
|
||||
String sig;
|
||||
|
||||
/// NIP-01 id: sha256 of the compact JSON array
|
||||
/// `[0, pubkey, created_at, kind, tags, content]`.
|
||||
String computeId() {
|
||||
final serialized = jsonEncode([0, pubkey, createdAt, kind, tags, content]);
|
||||
final digest = sha256.convert(utf8.encode(serialized));
|
||||
return digest.toString(); // lower-case hex
|
||||
}
|
||||
|
||||
/// Fills [id] and [sig] (BIP340 Schnorr over the id) with [privateKeyHex].
|
||||
void signWith(String privateKeyHex, {String? auxRandHex}) {
|
||||
id = computeId();
|
||||
// Deterministic aux in the spike (derived from id) so round-trip tests are
|
||||
// reproducible; production uses fresh randomness per BIP340.
|
||||
final aux = auxRandHex ?? id;
|
||||
sig = bip340.sign(privateKeyHex, id, aux);
|
||||
}
|
||||
|
||||
/// Verifies the signature against [pubkey]. Cross-checks that a real relay /
|
||||
/// peer would accept what we produced.
|
||||
bool verify() => id == computeId() && bip340.verify(pubkey, id, sig);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'pubkey': pubkey,
|
||||
'created_at': createdAt,
|
||||
'kind': kind,
|
||||
'tags': tags,
|
||||
'content': content,
|
||||
'sig': sig,
|
||||
};
|
||||
|
||||
static NostrEvent fromJson(Map<String, dynamic> j) => NostrEvent(
|
||||
id: j['id'] as String,
|
||||
pubkey: j['pubkey'] as String,
|
||||
createdAt: j['created_at'] as int,
|
||||
kind: j['kind'] as int,
|
||||
tags: (j['tags'] as List)
|
||||
.map((t) => (t as List).map((e) => e as String).toList())
|
||||
.toList(),
|
||||
content: j['content'] as String,
|
||||
sig: j['sig'] as String,
|
||||
);
|
||||
|
||||
/// First value of the first tag named [name], or null.
|
||||
String? tag(String name) {
|
||||
for (final t in tags) {
|
||||
if (t.isNotEmpty && t[0] == name && t.length > 1) return t[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
77
spike/block2_spike/lib/src/nostr_key.dart
Normal file
77
spike/block2_spike/lib/src/nostr_key.dart
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bip340/bip340.dart' as bip340;
|
||||
|
||||
import 'bech32.dart';
|
||||
import 'hkdf.dart';
|
||||
|
||||
/// A Nostr identity (secp256k1 / BIP340 x-only) deterministically derived from
|
||||
/// the Duniter/Ğ1-style root seed. This is Spike Question 1: prove we can keep
|
||||
/// "one identity" (user backs up ONE seed) while still having a viable Nostr
|
||||
/// transport key.
|
||||
///
|
||||
/// Derivation is one-way by construction: the root seed feeds HKDF, and HKDF's
|
||||
/// PRF (HMAC-SHA256) is not invertible, so neither the private nor the public
|
||||
/// Nostr key leaks the root seed. See spike-block2-findings.md §1.
|
||||
class NostrKey {
|
||||
NostrKey._({required this.privateKeyHex, required this.publicKeyHex});
|
||||
|
||||
/// 32-byte secp256k1 scalar, lower-case hex (64 chars).
|
||||
final String privateKeyHex;
|
||||
|
||||
/// 32-byte BIP340 x-only public key, lower-case hex (64 chars). This is the
|
||||
/// Nostr identity that appears on events and in `npub`.
|
||||
final String publicKeyHex;
|
||||
|
||||
/// NIP-19 `npub…` encoding of [publicKeyHex] — what a user would share.
|
||||
String get npub => bech32Encode('npub', _hexToBytes(publicKeyHex));
|
||||
|
||||
/// NIP-19 `nsec…` encoding of [privateKeyHex] — secret, never shared.
|
||||
String get nsec => bech32Encode('nsec', _hexToBytes(privateKeyHex));
|
||||
|
||||
/// Domain-separated derivation label. Versioned so a future scheme can coexist
|
||||
/// with old backups (same discipline as BackupBox's HKDF info string).
|
||||
static const derivationInfo = 'org.comunes.tane/nostr/secp256k1/v1';
|
||||
|
||||
/// secp256k1 group order n.
|
||||
static final BigInt _n = BigInt.parse(
|
||||
'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141',
|
||||
radix: 16,
|
||||
);
|
||||
|
||||
/// Deterministically derives the Nostr key from a root [seed] (the 32 bytes
|
||||
/// [IdentityService.generateRootSeed] produces).
|
||||
///
|
||||
/// Reduces to a valid scalar in `[1, n-1]` by rejection: on the (astronomically
|
||||
/// rare) miss it bumps a counter in the HKDF info, so the result stays a pure
|
||||
/// function of the seed.
|
||||
factory NostrKey.deriveFromSeed(List<int> seed) {
|
||||
var counter = 0;
|
||||
while (true) {
|
||||
final okm = hkdfSha256(ikm: seed, info: '$derivationInfo/$counter');
|
||||
final d = _bytesToBigInt(okm);
|
||||
if (d != BigInt.zero && d < _n) {
|
||||
final privHex = d.toRadixString(16).padLeft(64, '0');
|
||||
final pubHex = bip340.getPublicKey(privHex);
|
||||
return NostrKey._(privateKeyHex: privHex, publicKeyHex: pubHex);
|
||||
}
|
||||
counter++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BigInt _bytesToBigInt(List<int> bytes) {
|
||||
var result = BigInt.zero;
|
||||
for (final b in bytes) {
|
||||
result = (result << 8) | BigInt.from(b);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Uint8List _hexToBytes(String hex) {
|
||||
final out = Uint8List(hex.length ~/ 2);
|
||||
for (var i = 0; i < out.length; i++) {
|
||||
out[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
152
spike/block2_spike/lib/src/nostr_offer_transport.dart
Normal file
152
spike/block2_spike/lib/src/nostr_offer_transport.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
90
spike/block2_spike/lib/src/offer.dart
Normal file
90
spike/block2_spike/lib/src/offer.dart
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/// The four (well, five) reciprocity modes from sharing-model.md §3, plus the
|
||||
/// generic `lend` the core reserves for the future "library of things".
|
||||
enum OfferType { gift, exchange, sale, wanted, lend }
|
||||
|
||||
enum OfferStatus { active, reserved, closed }
|
||||
|
||||
/// A publishable offer — the "shop window", deliberately DECOUPLED from Lot /
|
||||
/// inventory (sharing-model.md §2, core-domain-boundary.md §4.3).
|
||||
///
|
||||
/// It carries only a chosen, denormalised *summary* of what's offered — never a
|
||||
/// foreign key into the seed tables, never the full inventory, never an exact
|
||||
/// address. This is the privacy seam: the core can publish and sync offers
|
||||
/// WITHOUT understanding seeds. Everything here is what the person elected to
|
||||
/// reveal.
|
||||
class Offer {
|
||||
const Offer({
|
||||
required this.id,
|
||||
required this.authorPubkeyHex,
|
||||
required this.summary,
|
||||
required this.type,
|
||||
required this.approxGeohash,
|
||||
this.status = OfferStatus.active,
|
||||
this.category,
|
||||
this.priceAmount,
|
||||
this.priceCurrency,
|
||||
this.exchangeTerms,
|
||||
this.radiusKm,
|
||||
this.imageUrl,
|
||||
this.expiresAt,
|
||||
});
|
||||
|
||||
/// Stable per-offer identifier (NIP-99 `d` tag → addressable event).
|
||||
final String id;
|
||||
|
||||
/// The publishing identity (BIP340 x-only pubkey hex). Derived, per Q1.
|
||||
final String authorPubkeyHex;
|
||||
|
||||
/// Human summary the author chose to publish (e.g. "Tomate rosa de Barbastro").
|
||||
final String summary;
|
||||
|
||||
final OfferType type;
|
||||
final OfferStatus status;
|
||||
|
||||
/// Free-text category, prefilled in-app from Species.family but opaque here.
|
||||
final String? category;
|
||||
|
||||
/// LOW-PRECISION geohash only (sharing-model.md §2: "10 km near you", never
|
||||
/// exact coordinates). The spike enforces a max length in the transport.
|
||||
final String approxGeohash;
|
||||
final int? radiusKm;
|
||||
|
||||
final num? priceAmount; // only for sale
|
||||
final String? priceCurrency; // ISO 4217 or a community currency (e.g. "G1")
|
||||
final String? exchangeTerms; // only for exchange
|
||||
|
||||
final String? imageUrl;
|
||||
final DateTime? expiresAt;
|
||||
}
|
||||
|
||||
/// Result of publishing to a transport.
|
||||
class PublishResult {
|
||||
const PublishResult({
|
||||
required this.accepted,
|
||||
required this.transportRef,
|
||||
this.message = '',
|
||||
});
|
||||
|
||||
/// Whether the relay/instance accepted the event.
|
||||
final bool accepted;
|
||||
|
||||
/// The transport-side id (Nostr event id / ActivityPub object id).
|
||||
final String transportRef;
|
||||
final String message;
|
||||
}
|
||||
|
||||
/// A discovery query: coarse location + optional facets. Note there is NO way to
|
||||
/// ask for "everything from person X's inventory" — you can only browse the
|
||||
/// public shop window by area and kind.
|
||||
class DiscoveryQuery {
|
||||
const DiscoveryQuery({
|
||||
required this.geohashPrefix,
|
||||
this.types = const {},
|
||||
this.limit = 100,
|
||||
});
|
||||
|
||||
/// Coarse geohash prefix to search near (e.g. "u09" ≈ tens of km).
|
||||
final String geohashPrefix;
|
||||
final Set<OfferType> types;
|
||||
final int limit;
|
||||
}
|
||||
29
spike/block2_spike/lib/src/offer_transport.dart
Normal file
29
spike/block2_spike/lib/src/offer_transport.dart
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import 'offer.dart';
|
||||
|
||||
/// The abstraction the plan promises lives in `commons_core` (core-domain-
|
||||
/// boundary.md §2, PLAN §4): publish an [Offer], discover offers by area. The
|
||||
/// concrete Nostr NIP-99 backend sits behind this so the domain never talks to
|
||||
/// a relay directly, and a second backend (ActivityPub / FEP-0837) could slot
|
||||
/// in without the app noticing.
|
||||
///
|
||||
/// Spike Question 2 asks whether messaging (NIP-17) and trust (NIP-85) fit
|
||||
/// behind this same seam, or whether they couple more than the plan implies.
|
||||
/// The finding: they do NOT belong here — they share the *key* and the *relay
|
||||
/// connection*, but their verbs differ (send/receive a private DM; assert/read
|
||||
/// a certification). Forcing them behind `OfferTransport` would overload it.
|
||||
/// See spike-block2-findings.md §2 for the proposed 3-interface split over one
|
||||
/// shared `NostrConnection`.
|
||||
abstract interface class OfferTransport {
|
||||
/// Publishes (or replaces) [offer]. Idempotent on the offer id.
|
||||
Future<PublishResult> publish(Offer offer);
|
||||
|
||||
/// Streams offers matching [query]. Emits already-stored matches, then live
|
||||
/// ones as they arrive, until the caller cancels the subscription.
|
||||
Stream<Offer> discover(DiscoveryQuery query);
|
||||
|
||||
/// Retracts a previously published offer (NIP-99: publish a `closed` status /
|
||||
/// NIP-09 deletion). Relays that already replicated it drop it over time.
|
||||
Future<void> retract(String offerId);
|
||||
|
||||
Future<void> close();
|
||||
}
|
||||
24
spike/block2_spike/pubspec.yaml
Normal file
24
spike/block2_spike/pubspec.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# THROWAWAY de-risking spike for Block 2 (social layer). NOT production.
|
||||
# Deliberately OUTSIDE the pub workspace (no `resolution: workspace`) so it can
|
||||
# pull network/crypto deps without touching Block 1's dependency graph.
|
||||
# See docs/design/spike-block2-findings.md. Delete this whole dir after the spike.
|
||||
name: block2_spike
|
||||
description: Throwaway prototype validating Block 2 open decisions. Do not ship.
|
||||
version: 0.0.0
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.5
|
||||
|
||||
dependencies:
|
||||
# The one real dependency on production code: we consume the root identity
|
||||
# seed exactly as IdentityService produces it, to prove derivation works.
|
||||
commons_core:
|
||||
path: ../../packages/commons_core
|
||||
# Throwaway crypto/transport deps — would NOT be added like this to prod.
|
||||
crypto: ^3.0.0 # sha256 + hmac for HKDF and event ids
|
||||
bip340: ^0.2.0 # secp256k1 x-only pubkeys + BIP340 Schnorr (Nostr signatures)
|
||||
|
||||
dev_dependencies:
|
||||
test: ^1.25.6
|
||||
lints: ^6.0.0
|
||||
76
spike/block2_spike/test/derivation_test.dart
Normal file
76
spike/block2_spike/test/derivation_test.dart
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bip340/bip340.dart' as bip340;
|
||||
import 'package:block2_spike/block2_spike.dart';
|
||||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Q1 — Identity derivation: a deterministic, one-way secp256k1 (Nostr) key from
|
||||
/// the Duniter/Ğ1-style root seed, so the user still backs up ONE thing.
|
||||
void main() {
|
||||
group('NostrKey.deriveFromSeed', () {
|
||||
// A fixed seed so expectations are reproducible across runs/machines.
|
||||
final seed = Uint8List.fromList(List.generate(32, (i) => i));
|
||||
|
||||
test('is reproducible: same seed → identical key', () {
|
||||
final a = NostrKey.deriveFromSeed(seed);
|
||||
final b = NostrKey.deriveFromSeed(seed);
|
||||
expect(a.privateKeyHex, b.privateKeyHex);
|
||||
expect(a.publicKeyHex, b.publicKeyHex);
|
||||
expect(a.npub, b.npub);
|
||||
});
|
||||
|
||||
test('produces a well-formed x-only key and npub/nsec', () {
|
||||
final key = NostrKey.deriveFromSeed(seed);
|
||||
expect(key.privateKeyHex, matches(RegExp(r'^[0-9a-f]{64}$')));
|
||||
expect(key.publicKeyHex, matches(RegExp(r'^[0-9a-f]{64}$')));
|
||||
// Cross-check: bip340 derives the same pubkey from our private hex.
|
||||
expect(bip340.getPublicKey(key.privateKeyHex), key.publicKeyHex);
|
||||
expect(key.npub, startsWith('npub1'));
|
||||
expect(key.nsec, startsWith('nsec1'));
|
||||
});
|
||||
|
||||
test('the derived key actually signs & verifies (BIP340)', () {
|
||||
final key = NostrKey.deriveFromSeed(seed);
|
||||
final message = 'a' * 64; // 32-byte hex message
|
||||
final sig = bip340.sign(key.privateKeyHex, message, 'b' * 64);
|
||||
expect(bip340.verify(key.publicKeyHex, message, sig), isTrue);
|
||||
});
|
||||
|
||||
test('different seeds → different keys (avalanche on a 1-bit flip)', () {
|
||||
final flipped = Uint8List.fromList(seed)..[0] ^= 0x01;
|
||||
final a = NostrKey.deriveFromSeed(seed);
|
||||
final b = NostrKey.deriveFromSeed(flipped);
|
||||
expect(a.privateKeyHex, isNot(b.privateKeyHex));
|
||||
expect(a.publicKeyHex, isNot(b.publicKeyHex));
|
||||
});
|
||||
|
||||
test('one-wayness: neither key contains the seed, and HKDF is not '
|
||||
'invertible', () {
|
||||
final key = NostrKey.deriveFromSeed(seed);
|
||||
final seedHex = seed
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
// The seed does not appear verbatim in the derived material.
|
||||
expect(key.privateKeyHex, isNot(contains(seedHex)));
|
||||
expect(key.publicKeyHex, isNot(contains(seedHex)));
|
||||
// Structural argument (documented in findings §1): recovery of the seed
|
||||
// would require inverting HMAC-SHA256. We assert the property we CAN test:
|
||||
// two unrelated seeds never collide to the same key.
|
||||
final other = NostrKey.deriveFromSeed(
|
||||
Uint8List.fromList(List.generate(32, (i) => 255 - i)),
|
||||
);
|
||||
expect(key.privateKeyHex, isNot(other.privateKeyHex));
|
||||
});
|
||||
|
||||
test('integrates with the real IdentityService seed length', () {
|
||||
final rootSeed = IdentityService(
|
||||
random: Random(42),
|
||||
).generateRootSeed();
|
||||
expect(rootSeed.length, IdentityService.rootSeedLengthBytes);
|
||||
final key = NostrKey.deriveFromSeed(rootSeed);
|
||||
expect(key.publicKeyHex, matches(RegExp(r'^[0-9a-f]{64}$')));
|
||||
});
|
||||
});
|
||||
}
|
||||
80
spike/block2_spike/test/privacy_test.dart
Normal file
80
spike/block2_spike/test/privacy_test.dart
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:block2_spike/block2_spike.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Q2 — the `OfferTransport` seam must not leak inventory or an exact location.
|
||||
/// The offer carries only a chosen summary; the wire event is checked byte-wise.
|
||||
void main() {
|
||||
final key = NostrKey.deriveFromSeed(
|
||||
Uint8List.fromList(List.generate(32, (i) => i)),
|
||||
);
|
||||
final codec = Nip99Codec();
|
||||
|
||||
// A realistic private-ish context: the caller ACCIDENTALLY passes a very
|
||||
// precise geohash and there is secret inventory the offer must never expose.
|
||||
const secretInventoryMarker = 'SECRET-LOT-12-KG-IN-SHED';
|
||||
const preciseGeohash = 'u09tunqmabc'; // ~sub-metre precision
|
||||
|
||||
Offer sampleOffer() => Offer(
|
||||
id: 'offer-1',
|
||||
authorPubkeyHex: key.publicKeyHex,
|
||||
summary: 'Tomate rosa de Barbastro',
|
||||
type: OfferType.gift,
|
||||
category: 'Solanaceae',
|
||||
approxGeohash: preciseGeohash,
|
||||
);
|
||||
|
||||
test('exact location is coarsened on the wire (never full precision)', () {
|
||||
final event = codec.encode(sampleOffer(), createdAt: 1000);
|
||||
final gTags = event.tags.where((t) => t[0] == 'g').map((t) => t[1]);
|
||||
// No geohash tag exceeds the coarse cap...
|
||||
expect(gTags.every((g) => g.length <= Nip99Codec.maxGeohashChars), isTrue);
|
||||
// ...and the precise fix never appears anywhere in the serialised event.
|
||||
final wire = jsonEncode(event.toJson());
|
||||
expect(wire, isNot(contains(preciseGeohash)));
|
||||
expect(wire, contains('u09t')); // coarse prefix is present
|
||||
});
|
||||
|
||||
test('no inventory / address field can ride along (seam has none)', () {
|
||||
final event = codec.encode(sampleOffer(), createdAt: 1000);
|
||||
final wire = jsonEncode(event.toJson());
|
||||
expect(wire, isNot(contains(secretInventoryMarker)));
|
||||
// The event has no "location", "address" or "geo-exact" tag by construction.
|
||||
final tagNames = event.tags.map((t) => t[0]).toSet();
|
||||
expect(tagNames, isNot(contains('location')));
|
||||
expect(tagNames, isNot(contains('address')));
|
||||
});
|
||||
|
||||
test('geohash ladder lets an area query match by exact tag', () {
|
||||
final event = codec.encode(sampleOffer(), createdAt: 1000);
|
||||
final gTags = event.tags.where((t) => t[0] == 'g').map((t) => t[1]).toList();
|
||||
expect(gTags, containsAll(['u', 'u0', 'u09', 'u09t']));
|
||||
});
|
||||
|
||||
test('round-trips Offer ↔ NIP-99 event, only chosen fields survive', () {
|
||||
final original = Offer(
|
||||
id: 'offer-2',
|
||||
authorPubkeyHex: key.publicKeyHex,
|
||||
summary: 'Judía del ganxet',
|
||||
type: OfferType.sale,
|
||||
priceAmount: 3,
|
||||
priceCurrency: 'G1',
|
||||
category: 'Fabaceae',
|
||||
approxGeohash: 'sp3e9',
|
||||
);
|
||||
final event = codec.encode(original, createdAt: 1000)
|
||||
..signWith(key.privateKeyHex);
|
||||
expect(event.verify(), isTrue);
|
||||
|
||||
final decoded = codec.decode(event);
|
||||
expect(decoded.id, original.id);
|
||||
expect(decoded.summary, original.summary);
|
||||
expect(decoded.type, OfferType.sale);
|
||||
expect(decoded.priceAmount, 3);
|
||||
expect(decoded.priceCurrency, 'G1');
|
||||
expect(decoded.category, 'Fabaceae');
|
||||
expect(decoded.approxGeohash, 'sp3e9');
|
||||
});
|
||||
}
|
||||
132
spike/block2_spike/test/roundtrip_test.dart
Normal file
132
spike/block2_spike/test/roundtrip_test.dart
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:block2_spike/block2_spike.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Q3 — publish → discover a real offer by geohash against a running relay
|
||||
/// (in-process, hermetic). Proves the flow works end to end and measures it.
|
||||
void main() {
|
||||
late MiniRelay relay;
|
||||
|
||||
setUp(() async => relay = await MiniRelay.start());
|
||||
tearDown(() async => relay.stop());
|
||||
|
||||
NostrKey keyFor(int fill) =>
|
||||
NostrKey.deriveFromSeed(Uint8List(32)..fillRange(0, 32, fill));
|
||||
|
||||
test('offer published by one identity is discovered by another via geohash',
|
||||
() async {
|
||||
final alice = keyFor(1);
|
||||
final bob = keyFor(2);
|
||||
|
||||
final aliceTransport = await NostrOfferTransport.connect(
|
||||
relay.url,
|
||||
privateKeyHex: alice.privateKeyHex,
|
||||
pubkeyHex: alice.publicKeyHex,
|
||||
);
|
||||
final bobTransport = await NostrOfferTransport.connect(
|
||||
relay.url,
|
||||
privateKeyHex: bob.privateKeyHex,
|
||||
pubkeyHex: bob.publicKeyHex,
|
||||
);
|
||||
|
||||
final offer = Offer(
|
||||
id: 'tomate-1',
|
||||
authorPubkeyHex: alice.publicKeyHex,
|
||||
summary: 'Tomate rosa de Barbastro',
|
||||
type: OfferType.gift,
|
||||
category: 'Solanaceae',
|
||||
approxGeohash: 'sp3e9xyz', // will be coarsened to sp3e9
|
||||
);
|
||||
|
||||
final published = await aliceTransport.publish(offer);
|
||||
expect(published.accepted, isTrue,
|
||||
reason: 'relay OK: ${published.message}');
|
||||
|
||||
// Bob queries a coarser area and still finds it (ladder match).
|
||||
final found = await bobTransport.discoverUntilEose(
|
||||
const DiscoveryQuery(geohashPrefix: 'sp3'),
|
||||
);
|
||||
expect(found, hasLength(1));
|
||||
expect(found.single.summary, 'Tomate rosa de Barbastro');
|
||||
expect(found.single.authorPubkeyHex, alice.publicKeyHex);
|
||||
|
||||
await aliceTransport.close();
|
||||
await bobTransport.close();
|
||||
});
|
||||
|
||||
test('a query for a different area returns nothing (geohash scoping works)',
|
||||
() async {
|
||||
final alice = keyFor(1);
|
||||
final t = await NostrOfferTransport.connect(
|
||||
relay.url,
|
||||
privateKeyHex: alice.privateKeyHex,
|
||||
pubkeyHex: alice.publicKeyHex,
|
||||
);
|
||||
await t.publish(Offer(
|
||||
id: 'x',
|
||||
authorPubkeyHex: alice.publicKeyHex,
|
||||
summary: 'Pimiento',
|
||||
type: OfferType.gift,
|
||||
approxGeohash: 'sp3e9',
|
||||
));
|
||||
final elsewhere = await t.discoverUntilEose(
|
||||
const DiscoveryQuery(geohashPrefix: 'u09'),
|
||||
);
|
||||
expect(elsewhere, isEmpty);
|
||||
await t.close();
|
||||
});
|
||||
|
||||
test('re-publishing the same offer id replaces (addressable), not duplicates',
|
||||
() async {
|
||||
final alice = keyFor(3);
|
||||
final t = await NostrOfferTransport.connect(
|
||||
relay.url,
|
||||
privateKeyHex: alice.privateKeyHex,
|
||||
pubkeyHex: alice.publicKeyHex,
|
||||
);
|
||||
Offer o(String summary) => Offer(
|
||||
id: 'lot-42',
|
||||
authorPubkeyHex: alice.publicKeyHex,
|
||||
summary: summary,
|
||||
type: OfferType.exchange,
|
||||
exchangeTerms: 'a cambio de legumbre',
|
||||
approxGeohash: 'sp3e9',
|
||||
);
|
||||
await t.publish(o('v1'));
|
||||
await t.publish(o('v2'));
|
||||
final found = await t.discoverUntilEose(
|
||||
const DiscoveryQuery(geohashPrefix: 'sp3e9'),
|
||||
);
|
||||
expect(found, hasLength(1), reason: 'addressable replace by (kind,pubkey,d)');
|
||||
expect(found.single.summary, 'v2');
|
||||
await t.close();
|
||||
});
|
||||
|
||||
test('measures publish→discover latency (informational)', () async {
|
||||
final alice = keyFor(4);
|
||||
final t = await NostrOfferTransport.connect(
|
||||
relay.url,
|
||||
privateKeyHex: alice.privateKeyHex,
|
||||
pubkeyHex: alice.publicKeyHex,
|
||||
);
|
||||
final sw = Stopwatch()..start();
|
||||
await t.publish(Offer(
|
||||
id: 'latency',
|
||||
authorPubkeyHex: alice.publicKeyHex,
|
||||
summary: 'Calabaza',
|
||||
type: OfferType.gift,
|
||||
approxGeohash: 'sp3e9',
|
||||
));
|
||||
final found = await t.discoverUntilEose(
|
||||
const DiscoveryQuery(geohashPrefix: 'sp3e9'),
|
||||
);
|
||||
sw.stop();
|
||||
expect(found, hasLength(1));
|
||||
// Not an assertion on wall-time (CI is noisy); just surface the number.
|
||||
printOnFailure('publish+discover round-trip: ${sw.elapsedMilliseconds} ms');
|
||||
// ignore: avoid_print
|
||||
print('[metric] publish+discover round-trip: ${sw.elapsedMilliseconds} ms');
|
||||
await t.close();
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue