tane/spike/block2_spike/lib/src/nip99.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

118 lines
4.1 KiB
Dart

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