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.
118 lines
4.1 KiB
Dart
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,
|
|
);
|
|
}
|