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,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()}';
}

View 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));
}

View 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 publishdiscover 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;
}
}

View 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,
);
}

View 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;
}
}

View 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;
}

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();
}
}

View 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;
}

View 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();
}