tane/docs/design/spike-block2-findings.md
vjrj 32ddaf1714 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.
2026-07-10 01:57:25 +02:00

11 KiB

Spike — Block 2 (social layer) de-risking findings

Throwaway research spike. Language: English (findings), per code convention. Code lives in 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 §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 §D.3 and 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: 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 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 §"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 §"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 = publish(Offer) / discover(DiscoveryQuery) / retract(id). The Nostr NIP-99 backend (NostrOfferTransport
    • Nip99Codec) sits entirely behind it. A second backend (ActivityPub/FEP-0837) could replace it without the domain noticing.
  • Offer 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 §2 and 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 §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 topOfferTransport, 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 (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 §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

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.