# 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 — and the "one connection, three interfaces" shape is now demonstrated in code.** | | 3 | Does publish→discover-by-geohash actually work on Nostr NIP-99? | **Yes mechanically; NIP *maturity* is the real risk, not the flow.** | | 3b | Does NIP-17 private, metadata-hiding messaging actually work? | **Yes — prototyped end to end. Risk drops from "unbuilt unknown" to "production hardening".** | All of it runs with tests (20 passing). 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 — now demonstrated in code, not just asserted:** [`NostrConnection`](../../spike/block2_spike/lib/src/nostr_connection.dart) is **one shared** socket + key + sign + REQ/EOSE lifecycle, and **two thin interfaces already sit on top of the same instance** — [`OfferTransport`](../../spike/block2_spike/lib/src/nostr_offer_transport.dart) (NIP-99) and [`MessageTransport`](../../spike/block2_spike/lib/src/nostr_message_transport.dart) (NIP-17). A third, `TrustTransport` (WoT), would slot in the same way. This is the shape to lift into `commons_core`: shared connection, per-concern contract. Trying to make `OfferTransport` also carry DMs would have overloaded it — the split is real. **Residual risk: medium → low-medium.** The abstraction is right *and* two of the three contracts are now built. The remaining risk is **scope, not feasibility**: "the social layer" is offers + messaging + trust + relays together (§D.1 "indivisible"), and trust/WoT plus production hardening of messaging (below) are still ahead. --- ## 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). **The spike DID build it** — see §4 below. Verdict moves from "biggest unbuilt risk" to **medium (production hardening), not high (feasibility)**. - **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. --- ## Q3b — NIP-17 private messaging (the piece that was flagged highest-risk) **Result: the metadata-private DM flow works end to end. Built, tested, and it fits the shared-connection architecture.** The plan ([network-trust.md](network-trust.md) §4) calls messaging "grande" and [open-decisions.md](open-decisions.md) §D.3 singled it out as the tightest coupling to Nostr. So the spike built it rather than hand-waving: - **NIP-44 v2 encryption** ([`Nip44`](../../spike/block2_spike/lib/src/nip44.dart)): secp256k1 ECDH → HKDF conversation key → ChaCha20 + HMAC-SHA256 with length-hiding padding. Conversation key is symmetric (A→B == B→A, tested); a wrong key cannot decrypt (throws, tested); short messages pad to the same bucket so ciphertext length doesn't leak plaintext length (tested). - **NIP-17 / NIP-59 gift-wrap onion** ([`NostrMessageTransport`](../../spike/block2_spike/lib/src/nostr_message_transport.dart)): rumor (kind 14, unsigned) → seal (kind 13, signed by sender, NIP-44 to recipient) → gift wrap (kind 1059, signed by a **throwaway ephemeral key**, NIP-44 to recipient). - **End-to-end over the relay (tested):** - Alice → Bob: the message arrives and Bob **authenticates the sender as Alice** — even though the wrap was signed by an ephemeral key. - **The relay/eavesdropper sees neither the plaintext nor the real sender:** snooping stored kind-1059 events yields only ciphertext, an ephemeral author, and a `p` tag for the recipient. Alice's identity is hidden. - A third party cannot read a message not addressed to them. **What this de-risks:** the hard, novel part (metadata-private messaging on the *same derived key and relay* as offers) is feasible and architecturally clean. **What remains (why it's still medium, not solved):** - The NIP-44 implementation here is **structurally faithful but NOT verified against the reference test vectors** — cross-client interop must be proven before shipping (a real client library should be used, not this spike code). - **Offline delivery, retries, and multi-device** (a recipient who's offline when the wrap is published) are untouched — this is the real "messaging is large" weight. - **Spam/abuse** on DMs leans on the (unbuilt) web of trust. ## 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, and now two of three contracts are proven.** Offers (NIP-99) and private messaging (NIP-17) both work on the shared connection. The remaining unproven weight is **WoT/trust (NIP-85/custom)** plus **production hardening of messaging** (interop-exact NIP-44, offline delivery, multi-device) — that, not the happy-path 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 de-risking target is the **web of trust** (Duniter-style certifications mapped to Nostr events) — the last large unknown — followed by hardening messaging for real-world delivery. ## 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** | Flow + metadata-privacy prototyped & tested. Left: interop-exact NIP-44, offline delivery, multi-device. | | Web of trust (NIP-85/custom) | **High** | No settled standard; genuine design work. The last big unknown. | | Overall scope (§D.1) | **Medium-High** | Social layer is indivisible & large; 2 of 3 transport contracts now proven, WoT + hardening remain. | ## How to run ```sh cd spike/block2_spike dart pub get dart test # derivation · privacy · roundtrip · messaging (20 tests) ``` Nothing here is wired into `app_seeds` or `commons_core`'s production graph; the Block 1 suite is untouched.