diff --git a/docs/design/spike-block2-findings.md b/docs/design/spike-block2-findings.md new file mode 100644 index 0000000..4ad2d02 --- /dev/null +++ b/docs/design/spike-block2-findings.md @@ -0,0 +1,204 @@ +# Spike — Block 2 (social layer) de-risking findings + +*Throwaway research spike. Language: English (findings), per code convention. +Code lives in [`spike/block2_spike/`](../../spike/block2_spike/) and is marked +throwaway — **not production, not on the pub workspace, no deps added to Block +1**. This document is the deliverable: what we learned, the risks, and a +recommendation on whether/how to take on the social round (Phase 3).* + +> **Status: this is a spike, not a green light to build Block 2.** CLAUDE.md and +> [open-decisions.md](open-decisions.md) §D.1 are explicit: the social layer is +> big, indivisible, and needs its own funded round. This spike only lowers the +> unknowns before that round is committed. + +## What the spike answered + +The three open decisions from [open-decisions.md](open-decisions.md) §D.3 and +[g1-integration.md](g1-integration.md) "A decidir": + +| # | Open question | Verdict | +|---|---|---| +| 1 | Can we derive a Nostr (secp256k1) key from the Ğ1 root seed, one-way & reproducible? | **Yes — low risk.** | +| 2 | Does an `OfferTransport` abstraction hold, without leaking inventory/location? | **Yes — with a caveat on messaging/trust coupling.** | +| 3 | Does publish→discover-by-geohash actually work on Nostr NIP-99? | **Yes mechanically; NIP *maturity* is the real risk, not the flow.** | + +All of it runs with tests. See "How to run" at the end. + +--- + +## Q1 — Identity derivation (secp256k1 from the Ğ1 root seed) + +**Result: works, deterministic, one-way. This unblocks "one identity, one backup".** + +- Implemented [`NostrKey.deriveFromSeed`](../../spike/block2_spike/lib/src/nostr_key.dart): + `HKDF-SHA256(rootSeed, info: "org.comunes.tane/nostr/secp256k1/v1")` → reduce + to a valid secp256k1 scalar `d ∈ [1, n-1]` by rejection (bump a counter in the + HKDF info on the astronomically-rare miss, so the result stays a pure function + of the seed) → BIP340 x-only pubkey → NIP-19 `npub`/`nsec`. +- The root seed is exactly what + [`IdentityService.generateRootSeed`](../../packages/commons_core/lib/src/identity/identity_service.dart) + already produces (32 bytes). The spike test feeds a real `IdentityService` + seed straight in — no shape mismatch. +- **Reproducibility:** same seed → identical private key, pubkey, and `npub` + (test `is reproducible`). The user backs up ONE thing (the seed QR); the + secp256k1 key regenerates on demand. Confirms the [g1-integration.md](g1-integration.md) + §"Resuelto" bet. +- **One-wayness:** derivation runs the seed through HMAC-SHA256, which is not + invertible; the seed never appears in the derived bytes, and unrelated seeds + never collide (tested). So publishing an `npub` cannot expose the Ğ1 identity + or the seed. (Simultaneous *use* of both identities can still be correlated by + an observer — that residual risk is unchanged and is what the "pseudonymous + key" advanced option in [g1-integration.md](g1-integration.md) §"peor caso" + exists for.) +- **The derived key really signs:** BIP340 sign+verify round-trips (test), i.e. + a real relay/peer would accept events from this key. + +**Design decisions this firms up (for the social round):** +- Fix the derivation `info` string and version it (done: `…/v1`), the same + discipline `BackupBox` already uses. Changing it later would silently rotate + everyone's Nostr identity, so it is effectively part of `schemaVersion`. +- Derivation belongs in `commons_core` (identity is generic). In production it + should reuse the `cryptography` package's HKDF already vendored there, not the + spike's hand-rolled one. + +**Residual risk: low.** The only real decision left is cosmetic/standardisation: +whether to align the derivation path with an existing convention (e.g. a +SLIP-0010/BIP32-style path or Cesium's "derive encryption key from signing key" +precedent) so other tools could reproduce it. Not a blocker — our scheme is +self-consistent and one-way today. + +--- + +## Q2 — `OfferTransport` abstraction & the privacy seam + +**Result: the abstraction holds and the privacy seam is real. But messaging and +trust do NOT fit behind the same interface — they share a *connection*, not a +*contract*.** + +### The seam works (offers) +- [`OfferTransport`](../../spike/block2_spike/lib/src/offer_transport.dart) = + `publish(Offer)` / `discover(DiscoveryQuery)` / `retract(id)`. The Nostr + NIP-99 backend ([`NostrOfferTransport`](../../spike/block2_spike/lib/src/nostr_offer_transport.dart) + + [`Nip99Codec`](../../spike/block2_spike/lib/src/nip99.dart)) sits entirely + behind it. A second backend (ActivityPub/FEP-0837) could replace it without + the domain noticing. +- [`Offer`](../../spike/block2_spike/lib/src/offer.dart) is agnostic by + construction: a chosen *summary* + coarse geohash, **no FK into seed tables, + no full inventory, no exact address** — exactly the Offer↔Lot split + [sharing-model.md](sharing-model.md) §2 and + [core-domain-boundary.md](core-domain-boundary.md) §4.3 call for. + +### The privacy seam is enforced on the wire (tested) +- The codec **coarsens the geohash** to ≤5 chars (≈±2.4 km) before it ever hits + the wire, and emits a NIP-52 prefix *ladder* (`u`, `u0`, `u09`, …) so area + queries match by exact tag while nothing finer than the cap leaks. +- Tests assert, byte-wise on the serialised event, that a slipped-in precise + geohash and a secret inventory marker **never appear**, and that there is no + `location`/`address` tag. The seam has no field that *could* carry them — + privacy is structural, not a runtime check that can be forgotten. + +### The caveat — coupling (this was the point of Q2) +[open-decisions.md](open-decisions.md) §D.3 worried Nostr couples messaging and +trust more than the plan implies. The spike confirms the worry is **real but +manageable**: + +- Offers (NIP-99, kind 30402), **messaging** (NIP-17 DMs, kind 1059 gift-wrap), + and **trust** (NIP-85 / certifications) all reuse the *same derived key* and + the *same relay socket* (the OK-handshake, REQ/EOSE plumbing in + `NostrOfferTransport` is identical for all three). +- But their **verbs differ**: publish/browse a public listing vs. send/receive a + private encrypted DM vs. assert/read a signed certification. Forcing DMs and + certifications behind `OfferTransport.publish/discover` would overload it. +- **Recommendation:** in `commons_core`, model **one shared `NostrConnection`** + (socket, key, sign, req/sub lifecycle) with **three thin interfaces on top** — + `OfferTransport`, `MessageTransport`, `TrustTransport`. That matches how the + code actually factored in the spike and keeps each contract honest, while + admitting the reality that one protocol/key/relay underpins all three. + +**Residual risk: medium.** The abstraction is right; the *scope* is the risk — +"the social layer" is genuinely offers + messaging + trust + relays together +(the §D.1 "indivisible" point), and this spike only built the offers third end +to end. Messaging (NIP-17 gift-wrap, offline delivery, spam) and trust (Duniter- +style WoT over Nostr) remain the large, unbuilt pieces. + +--- + +## Q3 — Publish → discover by geohash against a relay + +**Result: the flow works end to end. The real risk is NIP maturity/ecosystem, +not the mechanics.** + +- Built a hermetic in-process [`MiniRelay`](../../spike/block2_spike/lib/src/mini_relay.dart) + (NIP-01 subset: EVENT/REQ/EOSE/CLOSE, filter by `kinds`/`authors`/`#g`, + addressable-event replacement for kind 30402). No network → CI-safe, no flaky + public-relay dependency. +- End-to-end tests (`roundtrip_test.dart`): + - Alice publishes a gift offer; **Bob (a different derived identity) discovers + it by a coarser geohash** and gets the summary + Alice's pubkey back. ✅ + - A query for a **different** area returns nothing (geohash scoping works). ✅ + - **Re-publishing the same offer id replaces, not duplicates** (addressable + events — how a real relay behaves). ✅ + - Latency measured: **~34 ms** publish+discover, in-process, cold (includes + the OK handshake + REQ/EOSE; a floor value — a real relay adds network RTT). + +### On NIP maturity and the ActivityPub fallback +- **NIP-99 (classified listings, 30402):** stable enough and used in the wild + (e.g. Shopstr). Carries title/summary/price+currency/status of the box, so + gift/exchange/sale/wanted all fit; we add an `offer_type` tag for the + reciprocity mode NIP-99 lacks. **Low risk.** +- **NIP-17 (private DMs, gift-wrap):** newer, more moving parts (sealed + + gift-wrapped, per-message ephemeral keys). Viable but the **biggest unbuilt + risk** — this is where Q2's "messaging is large" bites. **Medium-high risk; + spike did not implement it.** +- **NIP-85 / WoT over Nostr:** least settled. The Duniter-style certification + model maps onto signed events, but there is no dominant standard. Likely a + **custom event kind mapped to the Duniter model** (as g1-integration.md §"WoT + propia pero compatible" already anticipates). **Higher risk; design work, not + just integration.** +- **ActivityPub / FEP-0837 as fallback:** worth keeping as the *second* + `OfferTransport` backend for reach/federation, but it does **not** solve the + hard parts (private DMs, WoT) any better than Nostr, and it adds instance + hosting. Recommendation: **stay Nostr-first**, keep the transport interface so + AP can be added later for offer federation, don't invest in AP now. + +--- + +## Overall recommendation + +1. **Q1 is done enough to lock.** Adopt seed→secp256k1 derivation as specified; + move it into `commons_core` (reusing the vendored HKDF) when the social round + starts. Version the `info` string as part of the schema. +2. **Q2: adopt the "one `NostrConnection`, three interfaces" shape.** Don't try + to make `OfferTransport` also carry messaging and trust. Keep `Offer` + agnostic and the geohash-coarsening seam exactly as prototyped. +3. **Q3: Nostr-first is the right bet, but scope honestly.** Offers are the easy + third and they work. **Messaging (NIP-17) and WoT (NIP-85/custom) are the + real weight and remain unproven here** — they, not the offer flow, should + drive the Phase-3 estimate and the funding ask ([open-decisions.md](open-decisions.md) + §D.1, §D.6). +4. **Do not start Block 2 from this spike.** Delete `spike/block2_spike/` once + these findings are absorbed. The next concrete step is a *funded* social + round that starts by building NIP-17 messaging as its own de-risking target, + since that is where the residual risk concentrates. + +## Risk summary + +| Area | Risk | Why | +|---|---|---| +| Seed→Nostr derivation | **Low** | Works, one-way, reproducible, tested. Only standardisation cosmetics left. | +| Offer transport + privacy seam | **Low** | Abstraction holds; leak-proofing is structural and tested. | +| Offer publish/discover (NIP-99) | **Low** | Mechanically proven; mature-ish NIP with real ecosystem. | +| Messaging (NIP-17) | **Medium-High** | Newer, complex, unbuilt; the big Phase-3 cost. | +| Web of trust (NIP-85/custom) | **High** | No settled standard; genuine design work. | +| Overall scope (§D.1) | **High** | Social layer is indivisible & large; only 1/3 proven here. | + +## How to run + +```sh +cd spike/block2_spike +dart pub get +dart test # derivation_test.dart, privacy_test.dart, roundtrip_test.dart +``` + +Nothing here is wired into `app_seeds` or `commons_core`'s production graph; the +Block 1 suite is untouched. diff --git a/spike/block2_spike/.gitignore b/spike/block2_spike/.gitignore new file mode 100644 index 0000000..d202eb0 --- /dev/null +++ b/spike/block2_spike/.gitignore @@ -0,0 +1,3 @@ +# Throwaway spike — don't commit build artifacts or the resolved lockfile. +.dart_tool/ +pubspec.lock diff --git a/spike/block2_spike/lib/block2_spike.dart b/spike/block2_spike/lib/block2_spike.dart new file mode 100644 index 0000000..3fb663e --- /dev/null +++ b/spike/block2_spike/lib/block2_spike.dart @@ -0,0 +1,12 @@ +/// THROWAWAY Block 2 de-risking spike. Not production; not on the pub workspace. +library; + +export 'src/bech32.dart'; +export 'src/hkdf.dart'; +export 'src/mini_relay.dart'; +export 'src/nip99.dart'; +export 'src/nostr_event.dart'; +export 'src/nostr_key.dart'; +export 'src/nostr_offer_transport.dart'; +export 'src/offer.dart'; +export 'src/offer_transport.dart'; diff --git a/spike/block2_spike/lib/src/bech32.dart b/spike/block2_spike/lib/src/bech32.dart new file mode 100644 index 0000000..7d5c033 --- /dev/null +++ b/spike/block2_spike/lib/src/bech32.dart @@ -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 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 _hrpExpand(String hrp) => [ + ...hrp.codeUnits.map((c) => c >> 5), + 0, + ...hrp.codeUnits.map((c) => c & 31), +]; + +List _createChecksum(String hrp, List 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 _convertBits8to5(List data) { + var acc = 0; + var bits = 0; + final out = []; + 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 data) { + final five = _convertBits8to5(data); + final checksum = _createChecksum(hrp, five); + final combined = [...five, ...checksum]; + return '${hrp}1${combined.map((v) => _charset[v]).join()}'; +} diff --git a/spike/block2_spike/lib/src/hkdf.dart b/spike/block2_spike/lib/src/hkdf.dart new file mode 100644 index 0000000..486629b --- /dev/null +++ b/spike/block2_spike/lib/src/hkdf.dart @@ -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 ikm, + required String info, + List 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 = []; + var previous = []; + var counter = 1; + while (out.length < length) { + final input = [...previous, ...infoBytes, counter]; + previous = Hmac(sha256, prk).convert(input).bytes; + out.addAll(previous); + counter++; + } + return Uint8List.fromList(out.sublist(0, length)); +} diff --git a/spike/block2_spike/lib/src/mini_relay.dart b/spike/block2_spike/lib/src/mini_relay.dart new file mode 100644 index 0000000..7770fb1 --- /dev/null +++ b/spike/block2_spike/lib/src/mini_relay.dart @@ -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 publish→discover 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 30000–39999, 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 _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 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)); + case 'REQ': + final subId = msg[1] as String; + final filters = msg.sublist(2).cast>(); + 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 stop() => _server.close(force: true); +} + +class _Sub { + _Sub(this.ws, this.id, this.filters); + + final WebSocket ws; + final String id; + final List> filters; + + bool matches(NostrEvent e) => filters.any((f) => _matchesFilter(e, f)); + + bool _matchesFilter(NostrEvent e, Map 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(); + 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; + } +} diff --git a/spike/block2_spike/lib/src/nip99.dart b/spike/block2_spike/lib/src/nip99.dart new file mode 100644 index 0000000..7cee39e --- /dev/null +++ b/spike/block2_spike/lib/src/nip99.dart @@ -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 = >[ + ['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, + ); +} diff --git a/spike/block2_spike/lib/src/nostr_event.dart b/spike/block2_spike/lib/src/nostr_event.dart new file mode 100644 index 0000000..3323e44 --- /dev/null +++ b/spike/block2_spike/lib/src/nostr_event.dart @@ -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> 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 toJson() => { + 'id': id, + 'pubkey': pubkey, + 'created_at': createdAt, + 'kind': kind, + 'tags': tags, + 'content': content, + 'sig': sig, + }; + + static NostrEvent fromJson(Map 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; + } +} diff --git a/spike/block2_spike/lib/src/nostr_key.dart b/spike/block2_spike/lib/src/nostr_key.dart new file mode 100644 index 0000000..5e5edf7 --- /dev/null +++ b/spike/block2_spike/lib/src/nostr_key.dart @@ -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 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 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; +} diff --git a/spike/block2_spike/lib/src/nostr_offer_transport.dart b/spike/block2_spike/lib/src/nostr_offer_transport.dart new file mode 100644 index 0000000..5c96c11 --- /dev/null +++ b/spike/block2_spike/lib/src/nostr_offer_transport.dart @@ -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>.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 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 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 discover(DiscoveryQuery query) { + final subId = 'sub${_subCounter++}'; + final filter = { + 'kinds': [Nip99Codec.kindActive], + '#g': [query.geohashPrefix], + 'limit': query.limit, + }; + + late StreamController controller; + controller = StreamController( + 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), + ); + 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> discoverUntilEose(DiscoveryQuery query) async { + final subId = 'once${_subCounter++}'; + final filter = { + 'kinds': [Nip99Codec.kindActive], + '#g': [query.geohashPrefix], + 'limit': query.limit, + }; + final results = []; + final done = Completer(); + 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), + ); + 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 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 close() async { + await _socket.close(); + if (!_incoming.isClosed) await _incoming.close(); + } +} diff --git a/spike/block2_spike/lib/src/offer.dart b/spike/block2_spike/lib/src/offer.dart new file mode 100644 index 0000000..3793f07 --- /dev/null +++ b/spike/block2_spike/lib/src/offer.dart @@ -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 types; + final int limit; +} diff --git a/spike/block2_spike/lib/src/offer_transport.dart b/spike/block2_spike/lib/src/offer_transport.dart new file mode 100644 index 0000000..348057d --- /dev/null +++ b/spike/block2_spike/lib/src/offer_transport.dart @@ -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 publish(Offer offer); + + /// Streams offers matching [query]. Emits already-stored matches, then live + /// ones as they arrive, until the caller cancels the subscription. + Stream 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 retract(String offerId); + + Future close(); +} diff --git a/spike/block2_spike/pubspec.yaml b/spike/block2_spike/pubspec.yaml new file mode 100644 index 0000000..5009dc7 --- /dev/null +++ b/spike/block2_spike/pubspec.yaml @@ -0,0 +1,24 @@ +# THROWAWAY de-risking spike for Block 2 (social layer). NOT production. +# Deliberately OUTSIDE the pub workspace (no `resolution: workspace`) so it can +# pull network/crypto deps without touching Block 1's dependency graph. +# See docs/design/spike-block2-findings.md. Delete this whole dir after the spike. +name: block2_spike +description: Throwaway prototype validating Block 2 open decisions. Do not ship. +version: 0.0.0 +publish_to: none + +environment: + sdk: ^3.11.5 + +dependencies: + # The one real dependency on production code: we consume the root identity + # seed exactly as IdentityService produces it, to prove derivation works. + commons_core: + path: ../../packages/commons_core + # Throwaway crypto/transport deps — would NOT be added like this to prod. + crypto: ^3.0.0 # sha256 + hmac for HKDF and event ids + bip340: ^0.2.0 # secp256k1 x-only pubkeys + BIP340 Schnorr (Nostr signatures) + +dev_dependencies: + test: ^1.25.6 + lints: ^6.0.0 diff --git a/spike/block2_spike/test/derivation_test.dart b/spike/block2_spike/test/derivation_test.dart new file mode 100644 index 0000000..708657d --- /dev/null +++ b/spike/block2_spike/test/derivation_test.dart @@ -0,0 +1,76 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:bip340/bip340.dart' as bip340; +import 'package:block2_spike/block2_spike.dart'; +import 'package:commons_core/commons_core.dart'; +import 'package:test/test.dart'; + +/// Q1 — Identity derivation: a deterministic, one-way secp256k1 (Nostr) key from +/// the Duniter/Ğ1-style root seed, so the user still backs up ONE thing. +void main() { + group('NostrKey.deriveFromSeed', () { + // A fixed seed so expectations are reproducible across runs/machines. + final seed = Uint8List.fromList(List.generate(32, (i) => i)); + + test('is reproducible: same seed → identical key', () { + final a = NostrKey.deriveFromSeed(seed); + final b = NostrKey.deriveFromSeed(seed); + expect(a.privateKeyHex, b.privateKeyHex); + expect(a.publicKeyHex, b.publicKeyHex); + expect(a.npub, b.npub); + }); + + test('produces a well-formed x-only key and npub/nsec', () { + final key = NostrKey.deriveFromSeed(seed); + expect(key.privateKeyHex, matches(RegExp(r'^[0-9a-f]{64}$'))); + expect(key.publicKeyHex, matches(RegExp(r'^[0-9a-f]{64}$'))); + // Cross-check: bip340 derives the same pubkey from our private hex. + expect(bip340.getPublicKey(key.privateKeyHex), key.publicKeyHex); + expect(key.npub, startsWith('npub1')); + expect(key.nsec, startsWith('nsec1')); + }); + + test('the derived key actually signs & verifies (BIP340)', () { + final key = NostrKey.deriveFromSeed(seed); + final message = 'a' * 64; // 32-byte hex message + final sig = bip340.sign(key.privateKeyHex, message, 'b' * 64); + expect(bip340.verify(key.publicKeyHex, message, sig), isTrue); + }); + + test('different seeds → different keys (avalanche on a 1-bit flip)', () { + final flipped = Uint8List.fromList(seed)..[0] ^= 0x01; + final a = NostrKey.deriveFromSeed(seed); + final b = NostrKey.deriveFromSeed(flipped); + expect(a.privateKeyHex, isNot(b.privateKeyHex)); + expect(a.publicKeyHex, isNot(b.publicKeyHex)); + }); + + test('one-wayness: neither key contains the seed, and HKDF is not ' + 'invertible', () { + final key = NostrKey.deriveFromSeed(seed); + final seedHex = seed + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + // The seed does not appear verbatim in the derived material. + expect(key.privateKeyHex, isNot(contains(seedHex))); + expect(key.publicKeyHex, isNot(contains(seedHex))); + // Structural argument (documented in findings §1): recovery of the seed + // would require inverting HMAC-SHA256. We assert the property we CAN test: + // two unrelated seeds never collide to the same key. + final other = NostrKey.deriveFromSeed( + Uint8List.fromList(List.generate(32, (i) => 255 - i)), + ); + expect(key.privateKeyHex, isNot(other.privateKeyHex)); + }); + + test('integrates with the real IdentityService seed length', () { + final rootSeed = IdentityService( + random: Random(42), + ).generateRootSeed(); + expect(rootSeed.length, IdentityService.rootSeedLengthBytes); + final key = NostrKey.deriveFromSeed(rootSeed); + expect(key.publicKeyHex, matches(RegExp(r'^[0-9a-f]{64}$'))); + }); + }); +} diff --git a/spike/block2_spike/test/privacy_test.dart b/spike/block2_spike/test/privacy_test.dart new file mode 100644 index 0000000..d18154d --- /dev/null +++ b/spike/block2_spike/test/privacy_test.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:block2_spike/block2_spike.dart'; +import 'package:test/test.dart'; + +/// Q2 — the `OfferTransport` seam must not leak inventory or an exact location. +/// The offer carries only a chosen summary; the wire event is checked byte-wise. +void main() { + final key = NostrKey.deriveFromSeed( + Uint8List.fromList(List.generate(32, (i) => i)), + ); + final codec = Nip99Codec(); + + // A realistic private-ish context: the caller ACCIDENTALLY passes a very + // precise geohash and there is secret inventory the offer must never expose. + const secretInventoryMarker = 'SECRET-LOT-12-KG-IN-SHED'; + const preciseGeohash = 'u09tunqmabc'; // ~sub-metre precision + + Offer sampleOffer() => Offer( + id: 'offer-1', + authorPubkeyHex: key.publicKeyHex, + summary: 'Tomate rosa de Barbastro', + type: OfferType.gift, + category: 'Solanaceae', + approxGeohash: preciseGeohash, + ); + + test('exact location is coarsened on the wire (never full precision)', () { + final event = codec.encode(sampleOffer(), createdAt: 1000); + final gTags = event.tags.where((t) => t[0] == 'g').map((t) => t[1]); + // No geohash tag exceeds the coarse cap... + expect(gTags.every((g) => g.length <= Nip99Codec.maxGeohashChars), isTrue); + // ...and the precise fix never appears anywhere in the serialised event. + final wire = jsonEncode(event.toJson()); + expect(wire, isNot(contains(preciseGeohash))); + expect(wire, contains('u09t')); // coarse prefix is present + }); + + test('no inventory / address field can ride along (seam has none)', () { + final event = codec.encode(sampleOffer(), createdAt: 1000); + final wire = jsonEncode(event.toJson()); + expect(wire, isNot(contains(secretInventoryMarker))); + // The event has no "location", "address" or "geo-exact" tag by construction. + final tagNames = event.tags.map((t) => t[0]).toSet(); + expect(tagNames, isNot(contains('location'))); + expect(tagNames, isNot(contains('address'))); + }); + + test('geohash ladder lets an area query match by exact tag', () { + final event = codec.encode(sampleOffer(), createdAt: 1000); + final gTags = event.tags.where((t) => t[0] == 'g').map((t) => t[1]).toList(); + expect(gTags, containsAll(['u', 'u0', 'u09', 'u09t'])); + }); + + test('round-trips Offer ↔ NIP-99 event, only chosen fields survive', () { + final original = Offer( + id: 'offer-2', + authorPubkeyHex: key.publicKeyHex, + summary: 'Judía del ganxet', + type: OfferType.sale, + priceAmount: 3, + priceCurrency: 'G1', + category: 'Fabaceae', + approxGeohash: 'sp3e9', + ); + final event = codec.encode(original, createdAt: 1000) + ..signWith(key.privateKeyHex); + expect(event.verify(), isTrue); + + final decoded = codec.decode(event); + expect(decoded.id, original.id); + expect(decoded.summary, original.summary); + expect(decoded.type, OfferType.sale); + expect(decoded.priceAmount, 3); + expect(decoded.priceCurrency, 'G1'); + expect(decoded.category, 'Fabaceae'); + expect(decoded.approxGeohash, 'sp3e9'); + }); +} diff --git a/spike/block2_spike/test/roundtrip_test.dart b/spike/block2_spike/test/roundtrip_test.dart new file mode 100644 index 0000000..f71b57b --- /dev/null +++ b/spike/block2_spike/test/roundtrip_test.dart @@ -0,0 +1,132 @@ +import 'dart:typed_data'; + +import 'package:block2_spike/block2_spike.dart'; +import 'package:test/test.dart'; + +/// Q3 — publish → discover a real offer by geohash against a running relay +/// (in-process, hermetic). Proves the flow works end to end and measures it. +void main() { + late MiniRelay relay; + + setUp(() async => relay = await MiniRelay.start()); + tearDown(() async => relay.stop()); + + NostrKey keyFor(int fill) => + NostrKey.deriveFromSeed(Uint8List(32)..fillRange(0, 32, fill)); + + test('offer published by one identity is discovered by another via geohash', + () async { + final alice = keyFor(1); + final bob = keyFor(2); + + final aliceTransport = await NostrOfferTransport.connect( + relay.url, + privateKeyHex: alice.privateKeyHex, + pubkeyHex: alice.publicKeyHex, + ); + final bobTransport = await NostrOfferTransport.connect( + relay.url, + privateKeyHex: bob.privateKeyHex, + pubkeyHex: bob.publicKeyHex, + ); + + final offer = Offer( + id: 'tomate-1', + authorPubkeyHex: alice.publicKeyHex, + summary: 'Tomate rosa de Barbastro', + type: OfferType.gift, + category: 'Solanaceae', + approxGeohash: 'sp3e9xyz', // will be coarsened to sp3e9 + ); + + final published = await aliceTransport.publish(offer); + expect(published.accepted, isTrue, + reason: 'relay OK: ${published.message}'); + + // Bob queries a coarser area and still finds it (ladder match). + final found = await bobTransport.discoverUntilEose( + const DiscoveryQuery(geohashPrefix: 'sp3'), + ); + expect(found, hasLength(1)); + expect(found.single.summary, 'Tomate rosa de Barbastro'); + expect(found.single.authorPubkeyHex, alice.publicKeyHex); + + await aliceTransport.close(); + await bobTransport.close(); + }); + + test('a query for a different area returns nothing (geohash scoping works)', + () async { + final alice = keyFor(1); + final t = await NostrOfferTransport.connect( + relay.url, + privateKeyHex: alice.privateKeyHex, + pubkeyHex: alice.publicKeyHex, + ); + await t.publish(Offer( + id: 'x', + authorPubkeyHex: alice.publicKeyHex, + summary: 'Pimiento', + type: OfferType.gift, + approxGeohash: 'sp3e9', + )); + final elsewhere = await t.discoverUntilEose( + const DiscoveryQuery(geohashPrefix: 'u09'), + ); + expect(elsewhere, isEmpty); + await t.close(); + }); + + test('re-publishing the same offer id replaces (addressable), not duplicates', + () async { + final alice = keyFor(3); + final t = await NostrOfferTransport.connect( + relay.url, + privateKeyHex: alice.privateKeyHex, + pubkeyHex: alice.publicKeyHex, + ); + Offer o(String summary) => Offer( + id: 'lot-42', + authorPubkeyHex: alice.publicKeyHex, + summary: summary, + type: OfferType.exchange, + exchangeTerms: 'a cambio de legumbre', + approxGeohash: 'sp3e9', + ); + await t.publish(o('v1')); + await t.publish(o('v2')); + final found = await t.discoverUntilEose( + const DiscoveryQuery(geohashPrefix: 'sp3e9'), + ); + expect(found, hasLength(1), reason: 'addressable replace by (kind,pubkey,d)'); + expect(found.single.summary, 'v2'); + await t.close(); + }); + + test('measures publish→discover latency (informational)', () async { + final alice = keyFor(4); + final t = await NostrOfferTransport.connect( + relay.url, + privateKeyHex: alice.privateKeyHex, + pubkeyHex: alice.publicKeyHex, + ); + final sw = Stopwatch()..start(); + await t.publish(Offer( + id: 'latency', + authorPubkeyHex: alice.publicKeyHex, + summary: 'Calabaza', + type: OfferType.gift, + approxGeohash: 'sp3e9', + )); + final found = await t.discoverUntilEose( + const DiscoveryQuery(geohashPrefix: 'sp3e9'), + ); + sw.stop(); + expect(found, hasLength(1)); + // Not an assertion on wall-time (CI is noisy); just surface the number. + printOnFailure('publish+discover round-trip: ${sw.elapsedMilliseconds} ms'); + // ignore: avoid_print + print('[metric] publish+discover round-trip: ${sw.elapsedMilliseconds} ms'); + await t.close(); + }); +}