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 = >[ ['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? _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, ); }