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.
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 scalard ∈ [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-19npub/nsec. - The root seed is exactly what
IdentityService.generateRootSeedalready produces (32 bytes). The spike test feeds a realIdentityServiceseed straight in — no shape mismatch. - Reproducibility: same seed → identical private key, pubkey, and
npub(testis 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
npubcannot 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
infostring and version it (done:…/v1), the same disciplineBackupBoxalready uses. Changing it later would silently rotate everyone's Nostr identity, so it is effectively part ofschemaVersion. - Derivation belongs in
commons_core(identity is generic). In production it should reuse thecryptographypackage'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 (NostrOfferTransportNip99Codec) sits entirely behind it. A second backend (ActivityPub/FEP-0837) could replace it without the domain noticing.
Offeris 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/addresstag. 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
NostrOfferTransportis 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/discoverwould overload it. - Recommendation: in
commons_core, model one sharedNostrConnection(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(NIP-01 subset: EVENT/REQ/EOSE/CLOSE, filter bykinds/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_typetag 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
OfferTransportbackend 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
- 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 theinfostring as part of the schema. - Q2: adopt the "one
NostrConnection, three interfaces" shape. Don't try to makeOfferTransportalso carry messaging and trust. KeepOfferagnostic and the geohash-coarsening seam exactly as prototyped. - 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).
- 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.