Adds TrustTransport as the third interface on the shared NostrConnection, completing the social-layer happy path in the spike: - WebOfTrust: pure, Flutter-free Duniter membership rule (N certs from members + within distance D of bootstrap referents), iterated to a fixpoint. The commons_core-worthy piece; TDD'd (threshold, distance cutoff, transitive growth, expired/revoked/self exclusion). - NostrTrustTransport: certifications as a custom addressable kind (30777, keyed by issuer+subject) — certify renews, revoke replaces, certs expire. - Trust filters spam (network-trust §2): seeds certify Alice; Eve stays uncertified; Bob discovers both offers and annotates authors — Alice known, Eve unknown — all over ONE shared connection. Findings updated: WoT risk High -> Medium (policy/bootstrap, not feasibility); overall scope Medium-High -> Medium — all three transport contracts now proven. 30 tests green, analyzer clean, Block 1 untouched.
17 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 — 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". |
| 4 | Does a Duniter-style web of trust work over Nostr, and can it filter spam? | Yes — certify/discover + threshold-and-distance membership + offer filtering all prototyped. |
All of it runs with tests (30 passing). All three transport contracts — offers, messaging, trust — plus the pure trust-graph logic are exercised. 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 — now demonstrated in code, not just asserted:
NostrConnectionis one shared socket + key + sign + REQ/EOSE lifecycle, and all three thin interfaces sit on top of the same instance —OfferTransport(NIP-99),MessageTransport(NIP-17) andTrustTransport(custom WoT). The trust test even runs offer discovery and trust annotation over one connection. This is the shape to lift intocommons_core: shared connection, per-concern contract. Trying to makeOfferTransportalso carry DMs and certifications would have overloaded it — the split is real.
Residual risk: low. The abstraction is right and all three contracts are built. The remaining risk is scope/hardening, not feasibility: "the social layer" is offers + messaging + trust + relays together (§D.1 "indivisible"), and that whole happy path now runs — what's left is production hardening (below).
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). 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
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.
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 §4) calls messaging "grande" and 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): 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): 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
ptag 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.
Q4 — Web of trust (the last big unknown)
Result: a Duniter-style WoT maps cleanly onto Nostr, computes correctly from discovered events, and filters spam. Built and tested.
There is no settled NIP for a web of trust, so — exactly as g1-integration.md §"WoT propia pero compatible" anticipates — the spike models its own WoT, Duniter-compatible, over Nostr:
- Certifications as events
(
NostrTrustTransport): a custom addressable kind (30777) keyed by (issuer,d=subject), so one live "A vouches for B" per pair; re-certifying renews, revoking replaces (tested). Certifications expire (Ğ1 semantics) and are public (like the on-chain Ğ1 WoT). - The membership rule is pure and separately tested
(
WebOfTrust) — thecommons_core-worthy piece: Duniter's two rules, N certifications from existing members (sigQty) and within distance D of the bootstrap referents (stepMax), iterated to a fixpoint (becoming a member can push others over the line). Tests cover threshold, distance cut-off, transitive growth, and exclusion of expired/revoked/self certs. - It filters spam (the payoff, network-trust.md §2): in a test, three seed members certify Alice; Eve stays uncertified. Bob discovers both their offers, then annotates authors — Alice is "known", Eve stays "unknown" and gets deprioritised. Trust and offers run on the same shared connection, closing the loop between Q2's architecture and this filter.
Residual risk: high → medium. Feasibility is proven; what's left is policy and bootstrap, not code: choosing sigQty/stepMax/expiry (network-trust.md §2 "a decidir"), the cold-start referent set (fairs, Ğ1 seed groups — g1-integration.md), whether a collective can certify as an entity, and optionally importing the on-chain Ğ1 WoT (level 3) as a trust source.
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/Q4: Nostr-first is the right bet, and all three contracts are now proven. Offers (NIP-99), private messaging (NIP-17) and the web of trust (custom, Duniter-compatible) all work on the same shared connection — the happy path of the entire social layer runs end to end in this spike. What remains is not feasibility but hardening and policy: interop-exact NIP-44, offline/multi-device delivery, and the WoT parameters + cold-start (open-decisions.md §D.1, §D.6). That is what should drive the Phase-3 estimate and the funding ask.
- Do not start Block 2 from this spike. It is throwaway research code (not
vector-verified, no offline delivery, no persistence, single relay). Delete
spike/block2_spike/once these findings are absorbed. The funded social round should rebuild on vetted client libraries incommons_core, using the shapes proven here — oneNostrConnection, three interfaces, the pureWebOfTrustrule — and spend its risk budget on hardening and bootstrap, not on re-proving the happy path.
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 (custom, Ğ1-compatible) | Medium | Feasibility proven (certs + membership rule + spam filter tested). Left: parameters (sigQty/stepMax/expiry) + cold-start, not code. |
| Overall scope (§D.1) | Medium | Social layer is indivisible & large, but the whole happy path now runs end to end; what remains is hardening + policy, not unknowns. |
How to run
cd spike/block2_spike
dart pub get
dart test # derivation · privacy · roundtrip · messaging · trust · web_of_trust (30 tests)
Nothing here is wired into app_seeds or commons_core's production graph; the
Block 1 suite is untouched.