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
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,
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue