Compare commits

..

186 commits

Author SHA1 Message Date
a0302632a3 docs(i18n): correct site Weblate format to go-i18n-json-v2
The object-form go-i18n JSON ({"key":{"other":...}}) is parsed by Weblate's
go-i18n-json-v2 format, not go-i18n-json (which expects a top-level array).
2026-07-15 19:46:16 +02:00
0d4d19658c i18n(site): convert Hugo i18n TOML->go-i18n JSON for Weblate
Weblate has no native Hugo-TOML format. The strings are already the go-i18n
structure ([key]/other=), so convert site/i18n/{en,es}.toml -> {en,es}.json
({"key":{"other":"..."}}) — Hugo 0.140 reads it identically (verified with the
pinned hugomods/hugo:exts-0.140.2 image). Enables a 'site' Weblate component.

Update TRANSLATIONS.md: site is now a component (go-i18n-json), not deferred.
2026-07-15 19:42:54 +02:00
f844d7c4f6 feat(links): surface source repo + Weblate links on site and in app
- site: set sourceURL (git.comunes.org/comunes/tane) and weblateURL
  (translate.comunes.org/projects/tane) — activates footer + collaborate links
- app About screen: add 'Source code' and 'Help translate' link tiles
- i18n: add about.sourceCode / about.translate / about.translateSubtitle
  (en base + es/pt/fr/de/ast; ja falls back to base), regenerate slang
- docs: add TRANSLATIONS.md runbook for the Weblate app component + git
  token + slang-regeneration workflow
2026-07-15 19:12:16 +02:00
ea17ced152 feat(screenshots): localize sample variety & market names per shot language 2026-07-15 17:48:44 +02:00
8ed1387792 docs(web): add deploy runbook for tane.comunes.org 2026-07-15 17:19:48 +02:00
a511db9062 chore(site): regenerate legal pages from docs masters (canonical wrapping) 2026-07-15 15:59:17 +02:00
15385a26cf feat: cover seedlings (plantón) in copy + legal, backup cadence, server picker
Beta-tester feedback triage. The data model already had LotType.seedling as
first class; the gaps were copy and UX.

- Seedlings/plantones: broaden landing hero (EN/ES), in-app legal notice, and
  all legal texts (site + docs masters) from seeds-only to "seeds and
  seedlings", with a live-plant phytosanitary/transport caveat. Seeds stay the
  hero — targeted, not a blanket rename.
- Backups: settings line now states the 7-day cadence explicitly; cadence lives
  in AutoBackupService.backupInterval as the single source for text and schedule.
- Community servers: replace the manual wss:// text box with a checklist of the
  known servers (defaults visible/toggleable) plus an "add server" affordance
  with basic validation. No jargon in the UI.

i18n across en/es/pt/fr/de/ast (ja falls back). Tests updated + a new
server-picker widget test.
2026-07-15 15:57:23 +02:00
ab027a8779 feat(screenshots): CC0/PD example photos for all visible varieties (+CREDITS) 2026-07-15 13:19:24 +02:00
416fd7153b feat(screenshots): real maize photo as hero variety, surfaced at top of inventory 2026-07-15 13:07:46 +02:00
6292c741be fix(web): fingerprint screenshot URLs (bust proxy cache) + fix header logo aspect 2026-07-15 12:47:49 +02:00
eb8c75cf44 fix(web): center unread badge on the menu icon, not the tap target 2026-07-15 12:47:27 +02:00
f10db611e8 fix(web): use red (error color) for unread badge instead of green 2026-07-15 12:45:39 +02:00
36e44fef00 docs(web): link Plantare to the original plantare.ourproject.org 2026-07-15 12:34:17 +02:00
05947e03ee fix(screenshots): precache asset images so the home logo renders; explain the Plantare name 2026-07-15 12:26:18 +02:00
67f1c0da0c feat(web): SEO/social cards, security headers, PWA icons, FAQ, status, get-involved, captions 2026-07-15 12:10:30 +02:00
485d35c916 docs(web): add a brief nod to the WAT system on the About page 2026-07-15 08:12:31 +02:00
dc20104c87 docs(web): reframe the name around trust-based printed rice notes (no debt) 2026-07-15 07:56:23 +02:00
4ecf9b9811 docs(web): ground the name in the suiko rice-seed loan, Plantare as its inverse 2026-07-15 07:47:26 +02:00
3fa50a7a46 docs(web): explain the Japanese name + yui reciprocity on About 2026-07-15 07:21:35 +02:00
8d471507be feat(web): tane.comunes.org landing + localized app screenshots
- Hugo site (site/): EN/ES intro + legal (generated from docs/legal), an
  ES/EN About page from docs/{que-es-tane,what-is-tane}.md, seed-green theme,
  responsive header, store-badge placeholders, multi-stage Hugo->nginx image.
- Golden screenshot harness (test/screenshots/): en,es,fr,de,pt,ja + RTL demo,
  real fonts via DejaVu Sans + FontManifest; skip-by-default tag so CI stays
  green. collect_screenshots.sh feeds site/ and fastlane phoneScreenshots.
- Drop editorial notes from the public explainer.
2026-07-15 02:21:42 +02:00
7030f4a0bd docs(claude): record the widget-test hang gotcha + OS timeout wrapper 2026-07-15 01:28:20 +02:00
621902612d feat(plantare): tie the propose form to a real seed
The propose sheet used a free-text seed name, unlinked from the
inventory. Now the seed is picked from your catalogued varieties so the
Plantaré is always tied to a real Variety (tap-through to its detail, and
it shows in that seed's commitments):

- Seed field is an Autocomplete over the inventory; on send the typed name
  resolves to the matching Variety, or creates a quick one if it's new —
  so varietyId is never null. Sheet takes the VarietyRepository; chat
  passes it.
- New repo one-shot `varietyLabels()` (id + label, no joins) feeds the
  picker. Deliberately a Future, not `watchInventory()` — a transient
  sheet must not hold a live Drift subscription (that hung the widget test
  ~36 min). Tests use it too and now run in ~2s with bounded pumps + a
  tall surface, no pumpAndSettle.

Tests: a new name creates & links a Variety; a matching name links the
existing one without duplicating. ui/services/data plantare suites green.
2026-07-15 01:24:50 +02:00
13a9bead15 docs(plantare): mark bilateral signed record shipped
Flip plantare-bilateral.md from "design, not yet built" to "v1 shipped",
summarize what landed (transport / schema v12 / service / UI), resolve
the open questions inline with the decisions made, and list the honest
follow-ups (durable outbox, accept-after-restart, rating anchor,
provenance DAG, paper/group/forgiveness sync).
2026-07-14 11:23:09 +02:00
f9c3dc018d feat(plantare): UI — propose from chat, review & sign on the ledger
The viral loop, made usable end-to-end:

- Propose sheet (plantare_propose_sheet.dart): from a chat with a peer
  (their key already in hand), pick your side, name the seed, choose what
  comes back (similar · non-GMO organic / work hours / other) and an
  optional return-by, then sign and send. Reached from the chat overflow
  menu; confirms "waiting for them to sign".
- Plantares screen: incoming proposals show Accept & sign / Decline right
  on the tile; every bilateral row wears a handshake badge — awaiting
  signature, signed by both, or declined. Accept falls back to a gentle
  "offline" nudge if the proposal isn't in hand yet.
- i18n: full en + es for the bilateral flow; other locales fall back to
  base per slang config.

Wire-up only touches human copy (no jargon) and stays behind the
optional PlantareService, so inventory-only builds are unaffected.

Tests: propose sheet drives a real sign+send; the ledger renders the
signed/awaiting badges. Fake SocialSession in your_people test gains the
new transport getter. Widget tests targeted + timed. All green.
2026-07-14 11:23:09 +02:00
2e25e34d11 feat(plantare): PlantareService — orchestrate the signed handshake
The app-layer driver that turns local intent into a signed proposal,
counter-signs or declines incoming ones, and reconciles every move into
the local ledger. Cryptography and wire format stay in commons_core;
this owns persistence and orchestration.

- propose(): builds + self-signs a PlantarePledge, records the local row
  (remoteState=proposed, my stub) and sends it. Direction maps to
  debtor/creditor: iReturn = I received and owe, owedToMe = I gave.
- accept(): counter-signs the exact in-hand proposal (verified) and sends
  the doubly-signed copy back, closing the row.
- decline(): notifies the proposer and marks the row declined.
- ingest(): verifies stubs before storing — a bad-signature proposal or a
  half-signed "accept" is dropped; proposals not addressed to me are
  ignored. Pending proposals held in memory keyed by pledge id (relay
  redelivers on reconnect), so accept signs the verified payload.

Wired into DI + Bootstrap alongside the inbox/sync listeners and torn
down/rebuilt on identity switch. SocialSession now exposes the transport.

Tests: full propose→accept close, decline, and the three drop paths with
a records-only transport + real repos. services/data green.
2026-07-14 11:21:46 +02:00
bec0f78fa1 feat(plantare): schema v12 — bilateral signed columns + repo
Adds the deferred bilateral columns to Plantares (plantare-bilateral.md
§"Schema delta"), all nullable/defaulted so v1 local rows coexist
untouched:

- pledgeId (the shared id both parties key their row by), debtorKey /
  creditorKey, debtorSignature / creditorSignature, movementId (the
  shared hand-over Movement for the provenance DAG), remoteState
  (proposed/accepted/declined — the handshake, distinct from status),
  returnKind (similar/workHours/other, default similar) + workHours.

Repo: createPlantare grows optional bilateral params; plantareByPledgeId
looks a row up by the shared id; applyPlantareSignatures counter-stamps
an incoming accept; setPlantareRemoteState records a decline. The
JSON codec carries every new field so they ride backups.

Versioned migration is guarded/idempotent like the others; schema v12
dumped, test helper regenerated, migration test covers v1..v11 → v12.
db + data + services 295/295 green.
2026-07-14 11:21:46 +02:00
516f1f50bc feat(plantare): PlantareTransport foundation in commons_core
The bilateral signed Plantaré's transport layer — the "formulario
bilateral firmado" from plantare-bilateral.md — as a new interface
alongside the other *Transport contracts, on the shared NostrConnection.

- PlantarePledge: the canonical, byte-stable core (debtor/creditor keys,
  seed label, return kind, dates) plus the two Schnorr stubs. Any edit
  changes the signing hash, so an accept is always over an immutable
  payload.
- PlantareCrypto: BIP-340 signing/verification over SHA-256 of the core,
  reusing the same primitive Nostr uses for events — a pubkey both
  identifies a party and verifies their stub.
- PlantareTransport + NostrPlantareTransport: propose → accept →
  counter-sign (and decline), private end-to-end. Rides the existing
  NIP-17 gift wrap carrying a tagged JSON payload, so the relay sees only
  ciphertext and an ephemeral author. Offline QR/NFC is the same payload
  without a relay.
- Message transport skips Plantaré payloads on the shared kind-1059
  channel (and vice-versa), so moves never surface as chat.

Tests: pure pledge/crypto + full handshake over a hermetic relay,
including no-leak and no-chat-bleed. commons_core 101/101 green.
2026-07-14 11:21:46 +02:00
baa8867287 feat(i18n): full RTL/CJK support — fonts, Japanese locale, directional fixes
Bundle Noto Sans Arabic + Noto Sans JP (SIL OFL 1.1) as per-glyph fallbacks
for both the UI text theme and every generated PDF, so RTL (Arabic) and CJK
render on all platforms — including desktop, where the system font may lack
them — instead of tofu. A shared pdf_fonts.dart centralizes the PDF theme and
the three label/catalog/recovery services reuse it.

Add Japanese (ja) as the CJK reference locale — fittingly, the app's own name
is Japanese (種, tane, 'seed'). Core strings translated; the rest fall back to
English via slang. Wired into supportedLocales (automatic) and the settings
picker.

Fix three physical Alignment.centerLeft -> AlignmentDirectional.centerStart in
variety_detail_screen, and make month-abbreviation and avatar-initial
truncation grapheme-safe (.characters) so CJK text is never cut mid-character.

Tests: CJK/Arabic PDF glyph render, ja locale resolution + English fallback.
The chat-bubble mirroring flagged by the audit was verified correct (already
respects Directionality) and left unchanged.
2026-07-14 11:11:19 +02:00
41ea6735d2 feat(settings): collapse language list into a picker
The flat list of 6 languages plus 'system' dominated Settings. Replace it
with a single tile showing the current language, opening a bottom-sheet
picker (all languages + 'System language'). Resolve LocaleStore defensively
so the screen renders in DI-less widget tests.
2026-07-13 18:11:42 +02:00
7ef3de04d7 feat(i18n): add French and German translations
Full fr/de translations of the app (625 keys each), wired into the
Settings language picker and the small-screen overflow test. Material
ships fr/de localizations, so chrome resolves natively.
2026-07-13 18:11:42 +02:00
dd36c3ac81 feat(plantare): surface product link + dates; spec the bilateral record
The Plantaré screen stored madeOn/dueBy/varietyId but showed none of them,
so it felt half-built. Surface what's already there (no schema change):

- watchPlantareViews() joins each commitment with its variety label
- tile shows the made-on date, a "Return by {date}" line (amber + "overdue"
  when an open promise is past due, via new seedWarning colour), the variety
  name, and taps through to /variety/:id
- add sheet gains an optional return-by date picker (createPlantare already
  took dueBy)
- variety detail shows a read-only commitments section (hidden when empty,
  no add button — honours the single hand-over door)
- i18n en/es/pt/ast; tests for the join, dueBy round-trip, tile, and section

Also specs the deferred two-sided record in docs/design/plantare-bilateral.md
(Nostr pubkey counterparty, PlantareTransport propose->accept->counter-sign,
deferred key/signature/movement columns, provenance DAG, reputation anchor),
with a faithful transcription of the paper Plantaré v0.4 (BAH-Semillero 2009,
CC-BY-SA). Cross-linked from data-model §2.7 and sharing-model §6.
2026-07-13 18:06:33 +02:00
f1e4fe377a fix(chat): collapse trust/rating strips while the keyboard is open
On short screens (e.g. landscape) the fixed-height trust and rating
strips plus the composer overflowed the body once the keyboard shrank
it. Wrap the two secondary strips in HideWhenKeyboardOpen so they yield
their vertical space while typing and reappear when the keyboard closes.
2026-07-13 17:19:47 +02:00
37761a029c fix(market): red favorite heart so it pops on the green theme 2026-07-13 17:11:31 +02:00
435b4db101 docs(release): point the store checklist at the legal/Play-compliance docs 2026-07-13 08:26:38 +02:00
7869a7163b feat(social): standard NIP-56 reporting — ReportTransport in commons_core, report sheet on offers and chats, local hide + optional block 2026-07-13 08:26:38 +02:00
b41dfd4248 feat(social): local blocklist — hide blocked authors' offers, chats and incoming messages, with unblock management 2026-07-13 08:25:49 +02:00
cd9f09048d feat(market): one-time community-rules gate before joining the market, publishing or messaging 2026-07-13 08:24:59 +02:00
668ed28246 feat(legal): in-app Privacy & rules screen with plain-words summaries and links to the full documents 2026-07-13 08:24:59 +02:00
8417a545a2 feat(i18n): legal, market-gate, report and block strings (en/es/ast/pt) 2026-07-13 08:24:59 +02:00
6b154ec717 docs(legal): full legal package — privacy, terms, community rules, seed-law notice (en+es) + Play/Apple/analysis internal docs 2026-07-13 08:24:27 +02:00
184c15bcec feat(market): save others' offers as favorites (Wallapop-style)
Adds a Favorites feature: a heart on the offer detail saves another
person's listing to an encrypted, per-identity SavedOffersStore
(keystore JSON snapshot, no plaintext at rest). A new /favorites screen
(wired from the drawer) lists saved offers offline-first and, when a
relay is reachable, flags ones that are gone as "no longer available".
i18n en/es/pt/ast; store + screen + detail-heart tests.
2026-07-13 08:21:43 +02:00
c4421f768a feat(profile): Material default avatar + square photo crop
Drop the DiceBear generated avatar (and dicebear_core/dicebear_styles/
flutter_svg): the default is again a Material coloured-initial disc from
the pubkey. Picking a photo now goes through a square crop step
(crop_your_image — pure Flutter, all platforms incl. desktop, Apache-2.0,
only pulls the image package we already had) before the 24 KB thumbnail.
2026-07-12 23:39:56 +02:00
9560d0d18f feat(profile): drop seed-glyph illustrations from the avatar picker
The picker now offers only a photo; with none, the generated DiceBear
avatar is used. Removes the seedks-ttf illustration row (and the now-dead
avatarGlyphNames/avatarGlyphToken). tane:seed:<glyph> values set before
this still render (avatarGlyphChar kept) for backward compatibility.
2026-07-12 23:22:20 +02:00
d4a9142ee2 feat(profile): default avatars via DiceBear "thumbs" (CC0), drop boring_avatars
Replace the flutter_boring_avatars fallback with a friendly DiceBear
"thumbs" face, generated offline (dicebear_core) and deterministically
from the pubkey, rendered as SVG (flutter_svg). The thumbs style is
CC0 1.0 (public domain) — no attribution, AGPL-clean — unlike Multiavatar
whose custom licence restricts use. Style parsed once and per-pubkey SVG
memoized; the CC0 <metadata> block is stripped to keep flutter_svg quiet.
2026-07-12 21:59:10 +02:00
d85b557f1e feat(profile): generated default avatars (boring-avatars beam) from pubkey
People without a photo or seed illustration now get a deterministic
pattern avatar drawn from their pubkey (flutter_boring_avatars, MIT),
in an earthy seed-tone palette, replacing the coloured-initial disc.
Same pubkey renders the same picture on every device with nothing shared.
2026-07-12 21:59:10 +02:00
f31f58cc7e Merge Tanemaki → Tane rename into main 2026-07-12 13:37:25 +02:00
51f8177f70 remove unused import 2026-07-12 13:35:24 +02:00
9913d03c7a Merge main (handover test-fix d42ed0d) into Tane rename 2026-07-12 13:28:58 +02:00
5d0da128b1 docs(store): rename Tanemaki → Tane in Play Store metadata and mockups
Fastlane title.txt + full_description.txt (en-US, es-ES) now lead with
Tane and keep tanemaki (種まき) as etymology; CI comment and the Material
mockups updated too.
2026-07-12 13:16:53 +02:00
2f7095ca59 fix(branding): rename remaining Tanemaki → Tane in platform metadata
Web PWA manifest name, index.html <title> and apple-mobile-web-app-title,
and the Windows executable ProductName / FileDescription.
2026-07-12 13:15:33 +02:00
ec5dca1e1c docs: finish Tanemaki → Tane rename (user-facing docs + origin doc)
Make Tane primary in README, VISION, CLAUDE, the intros and the
plain-language explainer (renamed que-es-tanemaki.md → que-es-tane.md,
dropping its "provisional name" caveats); keep Tanemaki (種まき) only as
the etymology.

PLAN.md is the origin record: its naming section §5-ter is preserved
verbatim as history under a dated "superseded" note, and the Nota de
vigencia now records Tane + the tane.comunes.org domain.
2026-07-12 13:13:56 +02:00
adbe6b00fb docs: rename Tanemaki → Tane in design docs and notes
Product-name mentions → Tane; backup-file extension .tanemaki → .tane;
website tanemaki.app → tane.comunes.org. Etymology-bearing docs
(README, VISION, PLAN, CLAUDE, intros) handled separately.
2026-07-12 13:09:12 +02:00
064248144a refactor(branding): rename app Tanemaki → Tane
Public name is now Tane (種, "seed"); Tanemaki (種まき, "to scatter seeds")
is kept only as etymology in the About screen and onboarding.

App not yet distributed, so format tokens rename cleanly with no migration:
backup extension .tanemaki→.tane, QR scheme tanemaki://→tane://, file
prefixes tane-*. Website link and species-catalog User-Agent →
tane.comunes.org. Android label capitalised to "Tane".

Identity/crypto constants already use org.comunes.tane (derivation, HKDF
backup, sync namespace, TANE1 recovery tag) — left untouched, so keys,
backups and sync are unaffected. Tests updated; analyze + full suite green.
2026-07-12 13:04:16 +02:00
d42ed0df1a test(fix): stop handover widget test hanging on awaited stream .first
The 'giving all with payment and promise' widget test awaited
repo.watchSales().first / watchPlantares().first in its body. A Drift
stream's .first never completes under the widget-test fake-async clock
(pumpAndSettle has already returned, so dart_test.yaml's per-test timeout
can't bound it), so the test hung to the framework's 10-min hard cap.

- Read the recorded sale/plantare with one-shot db.select(...).get()
  queries instead (same filter, resolves fine under fake-async) — the
  suite now passes in ~1s.
- Add a pure-Dart guard test that scans test/ui/ and fails loudly if any
  awaited watchX().first reappears, so this stops being recurrent.
2026-07-12 08:17:12 +02:00
b22db7d0cb fix(inventory): move 'clear filters' to its own line below the chips
Pinning it beside the horizontal scroll squeezed the chips into a narrow
strip and clipped them under the edge fade (e.g. 'Comparto' cut off by
the scroll arrow). Now the chips get the full width and 'clear filters'
sits on its own line below, shown only when a filter is active.
2026-07-12 07:35:20 +02:00
be5977d688 fix(ux): filter clear pinned, no-match text, instant avatar save, market avatar, favourites, WoT drawer
Six reported UX issues:
- Inventory 'clear filters' is now pinned outside the horizontal scroll,
  always visible — you no longer have to scroll past every chip to find it.
- Empty result now reads 'No seeds match your filters' (not 'no seeds
  yet') when a SEARCH or the 'this month' filter hid everything — the
  'filtered' flag was missing query + sowThisMonthOnly. Guarded by a test.
- Profile avatar saves + publishes the instant you pick it, so it no
  longer looks saved when it wasn't (the Save button sits below the fold).
- Market offer detail shows the author's real avatar (photo/illustration)
  instead of a generic person icon — resolves + caches their kind:0 picture.
- 'Wishlist' renamed to Favourites/Favoritos (en/es/pt/ast).
- The drawer 'following' item now opens the web-of-trust 'your people'
  screen when the social layer is on (was a dead item).

analyze clean; inventory/market/home + overflow suites green.
2026-07-12 02:56:08 +02:00
514d3c7364 feat(profile): show other people's avatars in chat, chat list, your people
Follow-up to the avatar feature: peers' published photos/illustrations
now actually render where people appear.

- CachedAvatar: a PeerAvatar that resolves the person's picture from the
  ProfileCache (FutureBuilder), falling back to the coloured-initial disc.
  For list/row sites — one avatar each.
- Chat bubbles: the peer's and my own avatar are resolved once into state
  (_peerPicture/_selfPicture — from the cache / ProfileStore, freshened
  from the peer's published profile) and threaded to each bubble, so many
  bubbles of two people don't each hit the cache.
- Chat list + your-people rows: swapped the static person-icon disc for
  CachedAvatar, so photos/illustrations show there too.
- Tests: CachedAvatar render modes (cached illustration / initial / no
  cache). analyze clean; chat + your-people suites green.

Market offers show no author avatar today (would be a new element, not a
swap) — left out of scope.
2026-07-11 22:50:10 +02:00
461bc3cb36 feat(profile): profile photo or seed-illustration avatar
Profiles had only a coloured-initial disc. Now each person can set an
avatar — a real photo OR one of our own seed illustrations (so
pseudonymity stays the default; no photo required).

- ui/avatar.dart: the one-string value scheme carried in the kind:0
  'picture' — a 'data:' photo thumbnail, a 'tane:seed:<glyph>' token, or
  empty. The Nostr ProfileTransport already published 'picture'.
- Photos ride inline as a tiny thumbnail (reuses offerThumbnailDataUri,
  24 KB cap) — no media server, like offer photos.
- avatar_edit.dart: pick/take a photo, choose a seed illustration, or
  remove; ProfileStore stores it; ProfileScreen shows a big editable
  avatar and publishes it.
- PeerAvatar renders photo / illustration / initial fallback.
- ProfileCache gains picture/setPicture; the inbox caches peers' avatars
  alongside their names.
- i18n avatar block (en/es/pt/ast). Tests: value scheme + PeerAvatar
  render modes, store + cache round-trips.

Follow-up: render peers' avatars in chat/market/your-people (thread the
cached picture into PeerAvatar at each call site).
2026-07-11 22:21:52 +02:00
d28454b010 docs: plain-language 'what is Tanemaki' explainer (es)
A short, all-audiences write-up derived from VISION.md: names concepts
like free software / copyleft / decentralised and explains each in plain
words rather than assuming them. States the name is tentative.
2026-07-11 22:21:52 +02:00
ceb606ae57 polish(detail): TODO review — calendar intro, drop abundance, collapse seed-saving
From the owner's TODO.org review of the shipped features:
- Crop calendar now says the months are YOUR notes: a hint line in the
  editor (cropCalendar.editorHint) and a caption under the 'this month'
  screen (calendar.selfNote) — it read as if the app knew when to sow.
- Dropped the 'how much I have' (abundance) input + display: it competed
  with the lot quantity as a second 'how much'. Removed from the lot sheet
  and the lot summary; the DB column + backup/CSV/catalog plumbing stay
  dormant (no destructive migration). offer_status carries 'do I share'.
- Seed-saving guidance moved into a collapsed expander (detail.seedSaving)
  — reference facts about the species shouldn't sit open among the
  grower's own records; opt-in depth, per progressive disclosure.
- Condition checks already live behind the collapsed 'advanced' expander
  (seed lots only) — left as the discreet power-user surface.

Tests: dropped the obsolete abundance→share nudge test; the seed-saving
section test now expands before asserting. analyze clean; suites green.
2026-07-11 14:08:10 +02:00
248b239c48 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	apps/app_seeds/lib/i18n/strings.g.dart
#	apps/app_seeds/lib/state/inventory_cubit.dart
2026-07-11 13:31:04 +02:00
456cc8154d Merge branch 'claude/festive-einstein-384c45'
Ego-centric trust replaces the global Duniter WoT, plus Wallapop-style
ratings v1 (kind 30778). Conflicts resolved: pubspec assets (kept
seed_saving, dropped trust referents), chat_screen (avatars/no-links
from main + rating strip and simplified TrustCubit from the branch),
strings.g.dart regenerated from merged sources.
2026-07-11 13:25:51 +02:00
2c42b1022a docs(trust): record the ego-centric pivot and ratings v1
network-trust.md §2 rewritten (ego-centric model, misuse resistance,
honest losses, unburnt Duniter bridge) + new §2.1 for ratings.
open-decisions.md: 2026-07-10 WoT-parameters decision superseded;
two new dated entries (ego-centric trust, ratings v1 with its soft
conversation anchor and the strong-anchor question kept open).
CLAUDE.md Block 2 paragraph updated to match.
2026-07-11 13:17:32 +02:00
7e7340d643 feat(ratings): Wallapop-style ratings in chat and offer detail
- SocialSession grows a ratings transport (fifth interface on the one
  shared channel).
- PeerRatingCubit: count, locale-aware average, and circleCount — how
  many ratings come from your own circle (reuses the ego-centric trust
  rule), the signal that makes strangers' review-stuffing irrelevant.
- Chat: slim rating strip under the trust banner; you can rate only
  someone you've talked with (soft anchor until the signed bilateral
  exchange form lands). Sheet with 5 stars + optional comment,
  pre-filled for editing, with a take-back action.
- Offer detail: author's stars under their name, with 'N from people
  you know' when your circle rated them; nothing shown when unrated.
- i18n ratings.* (en/es/pt/ast); tests for cubit and sheet.
2026-07-11 13:13:43 +02:00
a24831530d feat(core): person-to-person ratings transport (kind 30778)
Wallapop-style reputation primitive next to trust: Rating (1..5 stars +
optional comment), RatingTransport (rate/retract/ratingsOf/myRatingOf)
and a Nostr implementation on the shared channel. Addressable kind
30778, d = subject: one live rating per (rater, subject) — re-rating
replaces (a single author can never pile up reviews) and retracting
overwrites in place. Client-side dedupe mirrors NIP-01 replacement
order for relays that keep history.
2026-07-11 13:06:53 +02:00
fef174e8c4 refactor(trust): ego-centric trust replaces the global Duniter membership
The global membership rule (curated bootstrap referents + sigQty/stepMax
parameters) solved sybil-proof identity for a UBI — a problem this app
doesn't have — and its screen leaked graph jargon (npub roots, parameter
steppers). Trust is now computed from the user's own position only:
you vouch / vouched by people you know (distance <=2) / vouched by N.

- TrustCubit: drop networkMember tier, referents and params; keep the
  circle rule and the 365-day vouch expiry (renewable, self-pruning).
- Delete TrustReferents, WotSettings, TrustNetworkScreen and the bundled
  referents asset; unwire injector/bootstrap/app/chat.
- New 'Your people' screen (/your-people, from the profile): who you
  vouch for (revocable) and who vouches for you, names via ProfileCache.
- i18n: wot.* removed, yourPeople.* added (en/es/pt/ast); trust.member
  removed. Kind 30777 events on relays stay fully compatible — only the
  interpretation changes.
2026-07-11 13:03:20 +02:00
3c5200f235 Merge branch 'claude/zealous-nobel-7dabeb' 2026-07-11 08:08:39 +02:00
9f01c3893a polish(ux): drop the checkmark on selected filter chips
The tick overlapped the leading icon on selected chips, reading as a
confusing blob. The solid fill already signals 'on', so hide the
checkmark. Also make the selected label reliably white via an explicit
per-chip style: the global ChipTheme's secondaryLabelStyle was not
applied to FilterChips, leaving dark text on green. Plain attribute/
market chips now share a PlainFilterChip widget; swatch and month chips
just drop their tick.
2026-07-11 08:05:57 +02:00
491e079b50 feat(handover): one hand-over sheet replaces the separate sale/plantare doors
'Seeds changed hands' is now the single entry point on the variety
detail (and per lot row): direction (gave/received), one-tap 'all of it'
(default — the whole-plant/shrub case), optional partial quantity,
counterparty, and reveal-on-tap money and return-promise sections that
spawn the Sale/Plantare rows through recordHandover in one save.

Zero-lot varieties still work (ledger rows only); several lots get a
chip picker. The Sales/Plantares screens keep their own add buttons for
off-app records — the sheets stay, demoted to secondary.

i18n handover block in en/es/pt/ast; widget tests for the flow.
2026-07-11 08:00:45 +02:00
e6e70e4b97 feat(handover): recordHandover — one transaction for the moment seeds change hands
Returns to the original model (data-model §2.8: a closed deal produces a
Movement and, optionally, a Plantare): the given/received Movement plus,
when they came with it, the Sale (informational payment note) and the
Plantare (return promise), all sharing the counterparty. The Movement
carries the promise via the plantare_id column the schema had reserved.

'Gave all' (one tap, the natural path for shrubs/whole plants) moves the
lot's whole quantity, leaves an empty jar (0, same unit), clears
abundance, and returns the lot to private — withdrawing its market offer
on the next publish cycle, price cleared with it.

New enum HandoverDirection; tests in test/data/handover_test.dart.
2026-07-11 08:00:08 +02:00
1103b6ce15 feat(offers): asking price on lots, published with sale offers (schema v11)
The data model always meant sale offers to carry an informational price
(data-model §2.8, sharing-model §4.4); with offer state collapsed onto
the Lot, the price lives there. Null amount on a sell lot = 'to be
agreed' — the NIP-99 event simply omits the price tag.

- schema v11: Lots.price_amount/price_currency, guarded migration,
  schema dump + generated schema_v11 for the round-trip test
- ShareableLot/VarietyLot carry the price (sell-only in shareableLots);
  addLot/updateLot persist it and clear it when the lot leaves sale
- OffersCubit.publishLots forwards it (outbox flush picks it up free)
- lot sheet: price + currency inputs revealed only on 'For sale', with
  the €/Ğ1/hours quick-picks extracted to a shared CurrencyQuickPicks
  widget (reused by the sale sheet)
- backup JSON round-trips the new columns
- i18n share.price/priceHint in en/es/pt/ast
2026-07-11 08:00:08 +02:00
5ecdd1222a feat(seed-saving): source attribution + advisory note
Record where the guidance comes from (copyright/attribution) and make
clear it is general, not local gospel — kept light:
- seed_saving.json gains a top-level sources list (Seed to Seed / Seed
  Savers Exchange / Organic Seed Alliance); parsed into
  SeedSavingData.sources, exposed via SeedSavingCatalog.sources.
- The detail section shows a small muted footer: an italic 'advisory —
  adapt to your climate' line plus a 'Source: …' credit.
- i18n seedSaving.advisory + sourcePrefix (en/es/pt/ast).
- Tests: asset carries sources; the section renders advisory + credit.
2026-07-11 07:52:48 +02:00
4a1e72bc7e polish(ux): selected filter chips fill solid for clear contrast
The selected state used a pale tonal container (seedPrimaryContainer)
that sat almost on top of the pale canvas, so 'on' barely read. Selected
chips now fill solid — seedGreen for the plain attribute/market chips and
the swatch's own ink for family/form chips — with white label, icon and
checkmark. Idle chips stay light with dark ink, so the on/off jump is
unmistakable. White-on-ink AA is locked by a new contrast test.
2026-07-11 07:48:27 +02:00
805f1dc535 fix(ux): make the scroll cue visible — edge scrim + chevron
The fade-to-transparent was invisible: the row sits on the same pale
canvas, so masking the edge to transparent just revealed more of the
same colour (and often landed on an inter-chip gap). Replace it with a
surface-coloured scrim the chips slide under, topped by an outward
chevron, shown only on an edge with more content. RTL-mirrored.
2026-07-11 07:25:36 +02:00
9e083d8b7f test(seed-saving): drop unused material import 2026-07-11 07:20:37 +02:00
4bb94db3e0 feat(seed-saving): per-crop 'how to save this seed' guidance
A bundled, curated knowledge layer — the differentiator of a serious
seed app. For a variety's crop it shows how the plant reproduces and what
it takes to keep the variety true: life cycle, pollination (self/cross +
insect/wind), isolation distance, how many plants to save from, dry/wet
processing, a difficulty pill, and a short tip — all in human words.

- assets/catalog/seed_saving.json: curated public-domain data (Seed to
  Seed / Seed Savers), keyed by botanical FAMILY (defaults) with
  per-species/genus OVERRIDES that win — so e.g. Vicia faba is correctly
  insect-cross-pollinated despite the selfing Fabaceae default, and maize
  is wind-pollinated with a large population. Notes are locale-keyed
  (es/en), extensible to any language — a starter set, not a ceiling.
- domain/seed_saving.dart (pure): enums + SeedSavingGuide (merge, note
  fallback) + SeedSavingData.guideFor (species → genus → family).
- data/seed_saving_catalog.dart: loads the asset once into an in-memory
  singleton (no DB table, no migration); injector loads it at startup.
- VarietyDetail gains  (from the linked species — the reliable
  key, vs the editable category); detail screen shows a _SeedSavingView
  when a guide resolves.
- i18n seedSaving block (labels + enum values + params) in en/es/pt/ast.
- Tests: domain lookup/merge, the real asset's key corrections, and the
  detail section shows/hides. 314 suite tests green; analyze clean.
2026-07-11 07:18:39 +02:00
4ebfdca2fd feat(ux): edge fades hint that filter rows scroll horizontally
Chips clipped at the viewport edge with no cue that more filters exist.
New reusable EdgeFade widget (ShaderMask + dstIn) fades an edge only
while content remains beyond it, driven by scroll metrics; RTL mirrors
via Directionality. Applied to the inventory filter bar, market filter
bar and calendar month strip.
2026-07-11 07:15:45 +02:00
a461106dbf feat(chat): disallow links in messages
Local-first chat between people whose web of trust is still forming is a
natural phishing/scam vector, so messages may not contain URLs. A
conservative `containsUrl` rule (explicit scheme, www., or a common-TLD
domain — but not "3.5kg" or "12.30") gates it: the composer warns and
keeps the text for editing, and MessagesCubit.send drops any URL as a
backstop. Incoming text was already non-tappable (plain selectable text).
2026-07-11 07:14:58 +02:00
d481e62ff7 feat(chat): lighter bubbles, my own name on my avatar
Feedback: my bubbles were too dark, and avatars showed only a person
glyph, never the profile name. Now both bubbles are light with dark text
(mine a tonal green, the peer's white with a hairline) — the side and the
per-person avatar colour carry the "who", so nothing is heavy to read.
Load my own display name from ProfileStore so my avatar shows my initial
too, not just the peer's; each avatar still falls back to a glyph when no
name is known.
2026-07-11 07:08:54 +02:00
73e96a3ce5 feat(chat): sender avatar on both sides, coloured per person
Feedback: avatars only on the left, all the same colour, so it was hard
to tell who said what. In a 1:1 only the peer had an avatar (one colour).
Now each message carries its sender's avatar on their own side — mine
trailing, the peer's leading — each tinted by a deterministic colour from
their pubkey. Two sides, two colours: who-said-what is obvious at a glance.
2026-07-11 06:59:24 +02:00
a5f50314e4 fix(drawer): scroll destinations, pin the utility footer
The drawer was a single Column with a Spacer pinning intro/Settings/About
to the bottom. Once Calendar was added it overflowed a short screen (~94px),
so Settings and About fell off-screen and their navigation tests couldn't
tap them. Put the destinations in a scrolling ListView and keep the header
and the utility footer pinned, so Settings/About stay reachable at any
height. Fixes the about_screen/home_screen drawer navigation tests.
2026-07-11 06:52:05 +02:00
dcda349878 polish(ux): AA-contrast chip inks, 14-tone palette, calendar month auto-scroll
Blind-build polish pass over the recent sales/chips/calendar work:

- Chip label contrast: 7 palette inks (terracotta, ochre, rose, seedling,
  plant, bulb, cutting) sat below WCAG AA on their own fill; darkened
  (hue preserved) to ≥4.8:1. New category_palette_contrast_test locks it.
- Palette expanded 8 → 14 earthy tones (hues spread around the wheel) so
  family colours rarely collide for a typical 6–10 family inventory,
  keeping the stable-by-name, cross-screen-consistent scheme. Every new
  ink meets AA on its fill.
- Calendar month strip now actually scrolls the selected month into view
  (the comment promised it but it was never wired) — late-year months are
  no longer left off-screen when the screen opens.
- Minor: dead code removed from the month chip.

dart analyze clean; palette/contrast/calendar/quantity/overflow/inventory
suites green.
2026-07-11 06:49:00 +02:00
aec14f6518 feat(home): drop calendar card — drawer entry is enough
Remove the "this month" card from the home menu; the calendar stays
reachable from the drawer and the /calendar route is unchanged. Also
drop the now-unused calendar.homeSubtitle i18n key (en/es/pt/ast) and
regenerate slang.
2026-07-11 06:48:41 +02:00
68b04ea409 feat(chat): usable 1:1 chat — bottom-anchored, Drift-backed, dated
- Anchor the message list to the bottom (`reverse: true`) so new messages
  stay in view instead of landing below the fold.
- Move chat history from the OS keystore (O(n²) JSON blob, silent 200-msg
  cap) to a separate encrypted Drift/SQLCipher DB (`ChatDatabase`):
  indexed append, uncapped history, dedup as a unique-key invariant. It's
  an ephemeral per-device cache, isolated from the inventory schema, its
  migrations, and its sync. No data migration (pre-release).
- Add day separators (Today/Yesterday/locale date) and a per-bubble time,
  all via ICU (12/24h per locale; Localizations locale maps Asturian →
  Spanish for intl date symbols).
- Add peer avatars (deterministic colour from the pubkey + name initial),
  surface send failures that were previously silent, and make bubble text
  selectable (addresses, links).
- New i18n keys in en/es/pt/ast; tests for grouping, formatting, avatars,
  scroll anchoring, storage and send errors.

Docs: docs/design/chat-storage.md + open-decisions.md.
2026-07-11 06:40:50 +02:00
44337497d0 feat(calendar): "what's due this month" — dedicated screen + inventory filter
Reuses the existing per-variety crop-calendar masks (sow/transplant/
flowering/fruiting/seed-harvest) — no schema change, no migration — to
answer "qué toca este mes" across the whole inventory. Since the months
are the grower's own record, there's no hemisphere assumption.

- Repo: CalendarEntry + watchCalendar() (varieties with any recorded
  phase); VarietyListItem now carries sowMonths.
- Dedicated CalendarScreen (/calendar): a month strip (defaults to the
  current month, intl month names) and varieties grouped by phase —
  actions (sow/transplant/harvest seed) above informational (flowering/
  fruiting), each tinted, tap → variety detail; empty-month state.
- Inventory filter: a 'this month' chip (shown only when some variety
  has a calendar) keeps what sows in the current month; folded into the
  attributes group + clear-filters.
- Entry points: a live Home card + a drawer item.
- i18n calendar block + menu.calendar (en/es/pt/ast).
- Tests: calendar_test (watchCalendar, list carries sow-months, pure
  filter), calendar_screen_test (phase grouping, month switch, empty),
  plus the screen added to the small-screen overflow guard.
2026-07-11 06:27:57 +02:00
5b487ba1ec feat(ux): usable amounts, colour-coded category/form chips, currency quick-picks
Four usability passes across the everyday flows:

- Quantity picker: a countable unit (cob/pod/ear…) now defaults to 1 and
  the stepper clamps at a minimum of 1 — you can never store 'a cob' with
  a blank/zero figure. Vibe units (a few) still carry no number.
- Category & form chips are colour-coded (new category_palette.dart): a
  small muted earthy palette maps each botanical family to a STABLE
  tonality (fill + readable ink), and each lot form gets its own tone.
  Applied to the inventory filter bar (now grouped attributes | forms |
  families, separated by a hairline), the list's category headers (a
  matching colour dot), and the lot-form selector.
- Sale currency: quick-pick chips (€ · Ğ1 · hours) fill the field in one
  tap — Ğ1 offered quietly among familiar options, never imposed — while
  free text still takes anything else. New i18n key sale.hours (en/es/pt/ast).

Tests: quantity_picker_test (default-1, clamp, vibe, clear-still-1),
category_palette_test (stable/distinct swatches); overflow guard + the
inventory widget tests stay green.
2026-07-11 06:06:04 +02:00
e2665c27d4 feat(sales): local sales/purchase ledger (any currency)
A sale is a distinct model from a gift or a Plantare (reproduction
commitment): a recorded seed sale or purchase with an optional price in
ANY currency — €, Ğ1, time, or none yet. Mirrors the Plantare feature.

- schema v10: Sales table (SyncColumns), guarded createTable migration,
  schema dump + generated schema_v10 for the migration round-trip test
- enum SaleDirection { iSold, iBought }
- VarietyRepository: create/watch/watchForVariety/delete + backup
  export/exportForSync/import (LWW-by-HLC, tombstones)
- InventorySnapshot.sales + JSON codec encode/decode (round-trips amount,
  currency, counterparty)
- UI: SalesScreen (/sales), sale sheet, drawer entry, variety-detail
  action beside 'add Plantare'; money hides a trailing .0
- i18n sale block + menu.sales in en/es/pt/ast
- tests: sales repo test (5) incl. Ğ1/price-less/backup round-trip;
  Sales screen added to the small-screen overflow guard

Also harden the overflow guard: swallow the headless engine's image
resource service PNG-decode errors (unrelated to layout) while still
failing on real RenderFlex overflows, so home/about stop reporting
false failures.
2026-07-11 02:33:42 +02:00
833ddaed4d feat(plantare): usable UI — commitments screen, add sheet, entry points
The reproduction-commitment (Plantare) UI, local-first and plain-spoken:
- A Plantares screen (drawer entry, /plantares) listing your commitments,
  grouped Open / Done, with a big 'mark returned', a menu (let it go / reopen
  / remove) and an empty-state that explains what a Plantare is.
- An add sheet (scrollable): a two-choice 'who reproduces & returns' (I return
  / owed to me), an optional 'with whom', an optional 'what comes back' in the
  grower's own words, and a note. No sale, no currency — a promise.
- Reachable from a seed too: an 'Add a commitment' action on the variety
  detail, pre-attached to that variety.
- i18n en/es/pt/ast (human words: 'Plantare' + a plain explanation).

Added the Plantares screen to the small-phone overflow guard (18 cases green).
2026-07-11 02:11:57 +02:00
c75fb59926 feat(plantare): reproduction-commitment data layer (schema v9)
The Plantare — a promise to reproduce seed and return some (data-model §2.7,
the seed-domain Pledge with return_kind=similar). NOT a sale. Local-first
v1: your honest ledger of commitments (from/to a person by name); the
bilateral signed cross-party form is a later social-layer phase.

- New Plantares table (SyncColumns): varietyId, direction (iReturn/owedToMe),
  counterparty, owedDescription, madeOn, dueBy, status (open/returned/
  forgiven), settledOn, note. schemaVersion 8 -> 9 with a guarded createTable
  migration; schema exported + migration-test helper regenerated.
- VarietyRepository: createPlantare / watchPlantares / watchPlantaresForVariety
  / setPlantareStatus / deletePlantare (soft-delete, CRDT-stamped).
- Included in backups + sync: InventorySnapshot + JSON codec (round-trips
  isDeleted) + exportInventory/exportForSync/importInventory, so commitments
  survive restore and replicate LWW like every other row.

Tests: v1..v8 -> v9 migration + fresh v9; repo create/list/settle/reopen/
delete; and a backup round-trip preserving a commitment. 13 green.

UI (detail section + Plantares screen) follows.
2026-07-11 02:01:25 +02:00
68ffb55f47 fix(ui): scroll the germination & condition-check sheets; extend overflow guard
Two save-button sheets wrapped their body in a bare Column (no scroll): the
germination sheet (which grows a list of past tests) and the condition-check
sheet. On a small phone — especially with the keyboard up — the Save button
was pushed off the bottom, unreachable. Both now wrap the body in a
SingleChildScrollView, like the other input sheets already do.

Also extends the small-phone overflow guard to the profile (identity card +
Save), about, intro carousel, and the market/chat screens in their offline
'set up sharing' state. 17 cases green at 320x568 across es/pt/ast.
2026-07-11 01:11:12 +02:00
d89ee64bc7 test(ui): extend the small-phone overflow guard (about, intro, profile)
Adds about, intro and the profile screen (identity card + Save button) to
the 320x568 overflow guard across es/pt/ast. All pass — these screens
already fit small phones; the guard now protects them from regressions.
15 cases green.
2026-07-11 00:59:57 +02:00
2c33172ba4 test(ui): small-phone overflow guard; fix Home clipping on small screens
Adds test/ui/small_screen_overflow_test.dart: pumps the full screens at a
320x568 (iPhone-SE-class) viewport in the longest-text locales (es/pt/ast);
a RenderFlex overflow fails the test, so a clipped title or save button is
caught. It found — and this fixes — a real bug: the Home menu (a fixed,
non-scrolling Center>Column) clipped ~80px on small phones in every locale.
Home is now wrapped in LayoutBuilder + SingleChildScrollView +
ConstrainedBox(minHeight: maxHeight): centered when it fits, scrolls when it
doesn't, never clipped.

Also makes the test harness faithful: wrapScreen/wrapDetail now map the
locale through materialLocaleFor (ast->es) exactly like the app, so Asturian
screens don't throw on MaterialLocalizations (AppBar tooltips) — that had
masqueraded as an overflow.

Scope notes in the test: full screens only (modal bottom sheets get an
unbounded height in widget tests -> false huge overflow; they already scroll
their body), and the variety-detail screen is skipped (its ~2px hit is on a
DISPOSED/DEFUNCT RenderFlex — a transient during the cubit rebuild, not the
stable layout).
2026-07-11 00:53:44 +02:00
caf9d2b96e test: global 90s timeout + document the hang-proof test workflow
Diagnosed the recurring 'tests hang' pain by measurement: the logic layer
is fast and reliable (dart test commons_core ~1s/82 tests; flutter test
test/services ~11s/124 tests, green), and only WIDGET tests hang — because
pumpAndSettle() waits for a screen to stop scheduling frames and a live
source (Drift/Nostr stream, periodic Timer, connectivity/plugin) never does,
so it sits at the 10-minute default and the run looks stuck.

- apps/app_seeds/dart_test.yaml sets timeout: 90s so such a test FAILS fast
  and names itself instead of blocking the whole run.
- CLAUDE.md now spells out the split-by-layer workflow (logic via
  dart test/flutter test test/services; widget tests targeted + timed out;
  never pumpAndSettle a live screen).
2026-07-11 00:26:55 +02:00
b15f912ad8 fix(sync): replicate deletions and keep photos off the wire
Reusing the backup snapshot for sync had two bugs that would bite real use:

1. Deletions didn't propagate. exportInventory() filters isDeleted=false
   (user backups are tombstone-free by design) and the JSON codec dropped
   isDeleted (omitted on encode, hardcoded false on decode) — so deleting a
   variety on one device never reached the others. Now the codec round-trips
   isDeleted (emitted only when true, so backups stay byte-identical and it's
   backward compatible), and a new exportForSync() includes tombstones. The
   importer already merges them LWW-correctly.

2. Photos bloated events. The snapshot embeds photo bytes as base64, so a
   real inventory would blow past relay event-size limits and sync would
   silently fail. exportForSync() omits attachments entirely (photos stay
   device-local; media sync is a separate, deferred concern).

buildSnapshot() now uses exportForSync(); backups keep using exportInventory
(photos in, tombstones out) unchanged.

Tests: a deletion replicates as a tombstone; the sync snapshot carries no
photo bytes while a sealed backup still does.
2026-07-11 00:17:08 +02:00
d0dbed9bc2 feat(sync): device-to-device inventory sync, end to end
Wires the encrypted sync transport into a working replication between one
identity's own devices, reusing the CRDT-ready model (HLC + tombstones +
LWW) the data model was built for.

- SocialSession gains a 5th interface, sync, over the shared connection,
  built with a per-install deviceId + the app's inventory namespace.
- DeviceIdStore: a stable random per-install id in the keystore — distinct
  from the seed-derived CRDT node id, which is IDENTICAL across a user's
  devices (so it couldn't tell them apart / they'd clobber each other's
  replaceable record).
- InventorySnapshotIO (implemented by ExportImportService): build/apply the
  inventory as the SAME interchange JSON as backups but UNSEALED — the sync
  transport does the encryption, so it must not double-seal. Apply = the
  existing idempotent LWW importInventory.
- SyncService: on (re)connect publishes this device's snapshot + subscribes
  to the others'; a local edit (debounced, off tableUpdates) republishes;
  incoming snapshots merge idempotently; skips its own echo; a byte check
  skips re-publishing an unchanged snapshot so the ping-pong converges.
  Foreground only, like the inbox.
- DI/Bootstrap/switchSocialAccount wire and lifecycle it per identity.

Tests: DeviceIdStore, ExportImportService snapshot round-trip (unsealed +
idempotent), SyncService.handleRemote (applies others', skips own).

Follow-ups: deltas vs full-snapshot pushes; background sync; conflict UI.
2026-07-10 23:45:40 +02:00
bf15992a97 feat(sync): encrypted device-to-device sync transport (core foundation)
The most foundational Block-2+ gap: the data model was built CRDT-ready
(HLC, tombstones, LWW, append-only Movement) and the import reconciler
proves the merge — but nothing replicated a device's inventory to your
OTHER devices. Only manual file backup/restore moved data between devices.

This lands the transport foundation in commons_core (seed-agnostic, on the
shared connection — the 'one connection, N interfaces' shape):

- SyncTransport: moves an opaque, encrypted snapshot blob between ONE
  identity's own devices. The core carries bytes + does the crypto; the app
  decides what the bytes are and how to merge them.
- NostrSyncTransport: NIP-78 app-data (kind 30078, addressable/replaceable
  per device via the d-tag), content NIP-44-encrypted TO SELF, discovery
  filtered to your own author key. Each device keeps one replaceable record.

Tests (MiniRelay, pure Dart): a snapshot replicates to the identity's other
device; the relay only ever sees ciphertext; another identity neither
receives nor could decrypt; re-push replaces; two devices keep their own.

Confirms the open-decisions §D.4 guarantee (sync leaks neither the key nor
plaintext). NEXT slice (app): a SyncService that serializes the inventory
(reusing ExportImportService), pushes on mutation, and runs the existing
idempotent importInventory on receive; a stable per-install device id;
wire sync into SocialSession.
2026-07-10 23:16:07 +02:00
d9c2f46b79 feat(inventory): auto-recover from a transient stream failure
Instead of showing a retry button on the first failure, silently re-open
the inventory stream with exponential backoff (250ms→4s, 6 attempts,
staying in the loading spinner). The startup DB-not-ready race clears in
well under a second, so the user just sees the spinner briefly and the
list appears — no tap needed. Only after the retry budget is exhausted do
we surface the manual-retry error.

Tests cover auto-recovery, no error flicker during retries, and the
give-up path (via fake_async to fast-forward the backoff).
2026-07-10 23:10:00 +02:00
1666f5b31f fix(inventory): recover from a stuck loading spinner
The inventory stream subscription had no onError handler, so a transient
failure at startup (e.g. the encrypted DB not yet ready) went unhandled
and left loading=true forever — the spinner that only a restart cleared.

Handle stream errors: drop out of loading, surface an error state, and
offer a retry that re-opens the stream. Add a _LoadError view (i18n en/
es/ast/pt) and cover both the failure and the retry-recovers paths.
2026-07-10 22:12:24 +02:00
b9492cb65a refactor(social): one shared relay connection per identity
Every feature (offers, messaging, trust, profile, the inbox listener) used
to open its OWN RelayPool via social.openSession — several sockets to the
same relays, more battery, and reconnection logic living only in the inbox
listener.

Add SocialConnection: ONE shared session per identity, lazily connected and
reused by all. It watches connectivity — dropping the session when the
network goes and reconnecting when it returns, announcing each change on a
sessions stream so the inbox listener re-subscribes automatically. Callers
get the session via connection.session() and never close it (the connection
owns its lifecycle); recreated/disposed on an identity switch.

- InboxService now consumes the shared connection (subscribes on its
  sessions stream) instead of owning its own socket + connectivity code.
- createOffersCubit + the chat/market/profile screens use the shared
  connection; dead createMessagesCubit/createTrustCubit factories removed.
- DI registers SocialConnection per identity; Bootstrap starts it (after
  the inbox subscribes); switchSocialAccount disposes + recreates it.

Tests: SocialConnection lifecycle (reuse, concurrent connect, offline drop +
reconnect, unreachable retry) with a fake opener + online stream; inbox test
updated. nostr added as a dev_dependency for the channel fake.
2026-07-10 21:50:34 +02:00
2c8869ea0d Merge branch 'claude/nostalgic-wright-acfc25': inline offer photos
Market offers carry a small base64 data-URI thumbnail embedded in the Nostr
event (no media server); full photo stays in the encrypted inventory. Blossom
alternative parked on branch parked/offer-photos-blossom.

# Conflicts:
#	apps/app_seeds/lib/i18n/strings.g.dart
2026-07-10 21:43:35 +02:00
a2ce4dbb45 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	apps/app_seeds/lib/i18n/strings.g.dart
2026-07-10 21:37:44 +02:00
f951b05e0a fix(android): enable core library desugaring for flutter_local_notifications 2026-07-10 21:30:22 +02:00
2b419e6516 feat(market): embed offer photos inline instead of a media server
Replace the Blossom media-server approach with a fully decentralized one:
generate a small (~320px) JPEG thumbnail from the lot's cover photo and
embed it in the offer event as a data: URI, so photos ride the relays with
no server and no IP leak. The full photo stays in the encrypted inventory.

- offer_thumbnail.dart: downscale-until-it-fits data-URI builder + decoder
- market_widgets: render data: URIs from memory, http(s) URLs from network
- offers_cubit: publish a thumbnail (best-effort) in place of an upload
- drop MediaTransport/Blossom from commons_core (http/crypto deps) and the
  media-server setting; parked on branch parked/offer-photos-blossom

Relay event-size limits cap the image to a thumbnail; higher-res sharing
would need the parked Blossom path.
2026-07-10 21:27:59 +02:00
d2b98af36d Merge branch 'claude/awesome-golick-007e7d': message unread badges + OS notifications
# Conflicts:
#	apps/app_seeds/lib/di/injector.dart
#	apps/app_seeds/lib/i18n/ast.i18n.json
#	apps/app_seeds/lib/i18n/en.i18n.json
#	apps/app_seeds/lib/i18n/es.i18n.json
#	apps/app_seeds/lib/i18n/pt.i18n.json
#	apps/app_seeds/lib/i18n/strings.g.dart
#	apps/app_seeds/lib/i18n/strings_ast.g.dart
#	apps/app_seeds/lib/i18n/strings_en.g.dart
#	apps/app_seeds/lib/i18n/strings_es.g.dart
#	apps/app_seeds/lib/i18n/strings_pt.g.dart
#	apps/app_seeds/lib/ui/chat_screen.dart
2026-07-10 21:15:59 +02:00
5017ea51e0 feat(market): host and publish the cover photo with each offer
Wire the Blossom MediaTransport into publishing: shareable lots carry
their varietyId, publishLots uploads the lot's cover photo and puts the
returned URL on the offer (best-effort — a hosting failure still publishes
the offer, just without a photo). Add a configurable media server (Comunes
default, empty to opt out) to settings and the market advanced config.
2026-07-10 21:12:36 +02:00
d026376993 feat(messages): unread badges + OS notification for private messages
Private messages (NIP-17) arrived in the foreground with no signal: no
unread count, no badge, no OS notification. Hook both into the single
InboxService.ingest choke point.

- UnreadService: per-peer "last read" in the keystore (timestamps+pubkey
  only, no message text), live count via a changes stream, active-peer
  suppression so the open chat never badges or notifies.
- UnreadBadge: reusable, RTL-aware (Flutter Badge) — on the home hamburger,
  the drawer "Chat" item, and per-conversation in the messages list.
- Mark-read on chat open (ChatScreen).
- NotificationService over flutter_local_notifications: generic
  "New message from <name>" (no message text, for privacy), payload = pubkey,
  tap opens /chat/:pubkey. No-op on web/Windows; Android POST_NOTIFICATIONS.
- i18n notifications.newMessageFrom (en/es/pt/ast).

Background/push stays out of scope (foreground-only by design).

Tests: UnreadService, NotificationService (mock plugin), InboxService hooks
(new peer notifies/counts; duplicate, own, and open-chat do not), and a
UnreadBadge widget test. dart analyze clean; commons_core green.
2026-07-10 21:12:00 +02:00
7fce1ce6f0 feat(commons_core): Blossom MediaTransport for offer photos
Add a MediaTransport interface (sibling of Offer/Message/Trust transports)
and a Blossom (BUD-01/02) backend: sign a kind-24242 upload auth with the
same secp256k1 identity, PUT the bytes over HTTP, return the content-
addressed URL. Idempotent by SHA-256; http.Client injected for tests.
2026-07-10 21:04:47 +02:00
e715b129f6 feat(trust): full Duniter web-of-trust membership (params + referents)
Slice 4 of Block 2. The pure rule was already parameterised; this adds the
policy, cold-start and UI around it.

- commons_core: WotParams (sigQty/stepMax/sigValidity, Duniter defaults) +
  WebOfTrust.membersWith; npubToHex helper (NIP-19 decode) for adding roots.
- WotSettings (keystore): the active parameters, configurable — a young
  network loosens them and tightens as it grows, as Ğ1 did. Defaults Duniter.
- TrustReferents: the bootstrap 'seeds' membership is measured from — a
  bundled asset (empty until real founders are curated, no invented keys)
  unioned with referents the user adds by npub/QR. Honest cold-start.
- TrustCubit: computes the full membership verdict against referents+params
  alongside the personal circle, and exposes a TrustTier (networkMember >
  inYourCircle > vouched > unknown). Certifications issued with the active
  validity (they expire and renew, Duniter rule).
- UI: chat trust badge by tier; a 'Network of trust' screen (manage roots +
  advanced params) reached from the profile. i18n en/es/pt/ast.

Tests: WotParams/membersWith, npubToHex, TrustReferents, WotSettings, and
TrustCubit tiers/membership. Resolves the WoT-parameters decision
(open-decisions §B). Trust net stays empty/undetermined until seeded — by
design; users bootstrap their own roots.
2026-07-10 21:02:08 +02:00
b56ce68167 feat(market): show offer photos in the market list and detail
Render offer.imageUrl (already carried by NIP-99) as a thumbnail on each
market card and a hero image on the offer detail page. Remote images
degrade gracefully: a spinner while loading, a neutral placeholder when
the media server is unreachable. Publishing the photo comes next.
2026-07-10 20:58:45 +02:00
f767ccd70e Merge branch 'claude/mystifying-meitner-3b8553': fix Asturian boot-splash locale 2026-07-10 20:23:38 +02:00
0cdeee50f8 fix(i18n): map Asturian to Spanish chrome in boot splash
The pre-init BootstrapSplash passed the raw device locale to its
MaterialApp, so booting under an Asturian (ast) device locale threw
the 'locale not supported by localization delegates' warning — the
Material/Cupertino delegates ship no ast. The main app already routes
ast to Spanish framework chrome via materialLocaleFor; apply the same
mapping in the splash. Drop @visibleForTesting since it's now shared
production code.
2026-07-10 20:23:03 +02:00
a109a84714 feat(identity): switch social identity, all from the one backup
Adds pseudonymous, switchable social identities derived from the SAME root
seed via an account index (NostrKeyDerivation.deriveFromSeed(seed, account)).
HKDF is one-way so accounts are unlinkable to the Ğ1 key; account 0 is the
original identity, byte-for-byte unchanged (no rotation for current users),
and every account regenerates from the single seed — so switching adds
nothing to back up.

- SocialAccountStore: keystore-backed active account + max created.
- Per-identity stores (chats, profile, name cache) namespaced by account
  scope (account 0 = legacy keys, no migration) so identities never mix.
- switchSocialAccount() re-derives the identity, re-scopes the stores and
  restarts the inbox listener; RestartWidget rebuilds the tree to pick up
  the new social singletons. DB/inventory untouched.
- Profile 'Your identities' switcher: list, create, switch (with a note
  that messages/contacts are kept separate per identity). i18n en/es/pt/ast.

Tests: account-indexed derivation (legacy 0 unchanged, accounts distinct
yet deterministic, negatives rejected); SocialAccountStore; per-identity
store scope isolation. Resolves the flagged 'change identity' decision
(open-decisions §B).

Known: switching resets navigation to home (full tree rebuild).
2026-07-10 20:22:24 +02:00
4b71859416 feat(labels): choose copies per label, prefilled from container count
Each label in the print sheet gets an editable copy count, suggested from
the lot's latest condition-check container count (3 jars → 3 labels), and
falling back to 1. Type or step the count; the total updates live and Save
is disabled at zero. On save each seed expands into that many identical
labels.

- SeedLabelEntry.suggestedCopies; labelRows reads the latest container count
- label print sheet: per-row −/number/+ stepper, live total, expand on save
- tests: repository suggestedCopies + print-sheet copy chooser
2026-07-10 20:12:36 +02:00
4d110e8082 Merge branch 'worktree-print-labels': print seed labels with QR
# Conflicts:
#	apps/app_seeds/lib/i18n/strings.g.dart
#	apps/app_seeds/lib/ui/inventory_list_screen.dart
2026-07-10 19:20:33 +02:00
6c8a1ad129 Merge branch 'worktree-print-labels': print seed labels with QR
# Conflicts:
#	apps/app_seeds/lib/i18n/strings.g.dart
#	apps/app_seeds/lib/ui/inventory_list_screen.dart
2026-07-10 19:19:29 +02:00
3ea1bfbf55 Fix test 2026-07-10 19:14:29 +02:00
33c45bcab1 Add Windows plugin 2026-07-10 19:14:21 +02:00
efb369eb81 feat(labels): print seed labels with QR from an inventory selection
Add multi-select to the inventory and a "Print labels" flow that renders a
PDF sheet of labels (small stickers or large cards), each carrying the seed's
common name, variety, scientific name, year/origin and a tanemaki://seed QR
that holds the seed's data offline (future scan-to-import).

- SeedLabelCodec: compact, versioned tanemaki://seed URI (encode/decode)
- LabelSheetService: pure-Dart PDF grid (pdf + barcode + DejaVu), RTL-aware
- VarietyRepository.labelRows: one row per lot, locale-picked common name
- InventoryCubit selection mode; contextual app bar + label print sheet
- i18n en/es/pt (printLabels.*)

Tests: codec, PDF service (both formats + RTL), labelRows, selection cubit,
inventory widget selection.
2026-07-10 19:14:04 +02:00
a71493bfb1 Merge branch 'claude/intelligent-bardeen-0d5ee9': market detail page, filters, publish-dedup
# Conflicts:
#	apps/app_seeds/lib/i18n/strings.g.dart
#	apps/app_seeds/lib/i18n/strings_ast.g.dart
2026-07-10 19:11:59 +02:00
0213152bfb feat(market): fix publish-duplicates, add offer detail page + filters
The vecino/market view had three gaps:

- Publishing inventory duplicated the listing. discover() appended every
  offer the relay streamed, but relays legitimately resend an addressable
  NIP-99 event (stored copy + live echo on publish). Merge by
  (authorPubkeyHex, id) so each offer appears once; two authors reusing a
  lot id stay distinct.
- No product detail. Tapping a card jumped straight to chat. Add a
  read-only "product page" (MarketOfferDetailScreen) with title, mode,
  category, eco badge, coarse location, price, a "Shared by" seller
  section (profile fetched by pubkey) and the message button. Reached via
  a new /market/offer route; cards are now fully tappable. Shows only what
  the network already carries — no photos/description added to the wire.
- No filters. Add a filter bar (type, category, eco) mirroring inventory,
  each chip group shown only when a discovered offer can match it. To make
  the eco filter real, publish the organic flag on the wire: Offer.isOrganic
  -> NIP-99 'organic' tag -> OfferMapper -> ShareableLot.

Tests: commons_core organic round-trip; app-side dedup, two-author
separation, filter logic, mapper passthrough; detail-screen widget tests.
i18n keys added to en/es/pt/ast and slang regenerated.
2026-07-10 19:10:40 +02:00
09b7575df3 perf(startup): paint a splash immediately, run init off the first frame
main() awaited all of configureDependencies() (open the encrypted DB,
derive the social key, seed/scan the species catalog) before the first
runApp, so nothing painted until init finished — startup jank visible as
skipped frames. Now main() runs runApp immediately with a Bootstrap
widget that shows a native-splash-matching green splash while the heavy
work runs, then swaps in TaneApp. On failure it shows a localized error
with a retry (configureDependencies is idempotent, so retry is safe).

Adds a bootstrap i18n group (en/es/pt/ast) and a widget test for the
splash/error frame.
2026-07-10 17:34:08 +02:00
21ec836bd8 fix(chat): dedupe messages on display, not just in the store
Duplicated bubbles came from the cubit emitting every inbox event to the
screen regardless of whether it was already shown. On opening a chat the
saved message loads from history AND the live subscription hands back the
same stored gift wrap the relay re-delivers — two bubbles. (The store was
already idempotent, but the cubit ignored that for display.)

MessagesCubit now keeps a seen-set (sender+timestamp+text): a re-delivered
or already-loaded message is skipped, and pre-existing duplicates in old
saved history collapse on load too. Arrival order preserved (no resort).
Tests: re-delivered wrap shown once; history-raced-by-live shown once.
2026-07-10 17:02:40 +02:00
3dca732da9 feat(chat): app-wide inbox listener so messages arrive without opening the chat
Reception only ran inside an open ChatScreen for a known peer, so a first
message from a new peer was invisible: nothing in local storage -> nothing
in the inbox list -> you never opened the chat -> you never subscribed.

Add InboxService: one long-lived NIP-17 inbox subscription for the whole
app (foreground), persisting every incoming message to MessageStore and
firing a 'changes' signal the inbox list live-reloads on. Reconnects when
the network returns; degrades to nothing offline. Started from main when a
social identity exists.

Make MessageStore.append idempotent (dedup by sender+timestamp+text) and
serialized behind a write lock — the global listener and an open chat's own
subscription now write the same conversation concurrently and relays
re-deliver stored gift wraps on every resubscribe. Tests for both.

Known trade-offs (follow-ups): foreground-only (no push yet); each of
InboxService/ChatScreen/MarketScreen opens its own RelayPool (a shared
connection is a later optimization).
2026-07-10 16:53:03 +02:00
138027a8ef fix(nav): inventory is a spoke off the home hub — back arrow, no duplicate drawer
The inventory screen carried its own const AppDrawer() (no marketEnabled),
so opening the menu from inventory greyed out market/chat/profile — the
recurring 'phase 1' look. It was also reached via go() (route replace),
which is why it showed a hamburger instead of a back arrow like the market.

Make it coherent with the market: reach inventory via push() (from the
home card and the drawer), and drop its drawer so the AppBar shows a back
arrow returning to the home hub. The drawer now lives only on the home
screen, which always passes marketEnabled — the wrong-drawer bug class is
gone. Update the home_screen test to assert the spoke has a back arrow and
no hamburger.
2026-07-10 16:24:54 +02:00
e641f8e0fa docs(claude): prefer workspace-level commands (analyze/test/pub get) from root 2026-07-10 16:16:29 +02:00
5ab505bc94 fix(di): robust all-or-nothing init so the social layer never greys out
The idempotency guard used SocialService as its sentinel, but that was
registered mid-cascade, so a run that crashed part-way could leave a
half-built container the next run would reuse (or hit 'already
registered' on) — intermittently falling back to the inventory-only look
with market/chat/profile greyed out.

- Guard on a dedicated _DepsReady marker registered LAST, so the sentinel
  means the WHOLE container is wired, never a partial one.
- Reset a half-wired container (crashed prior run) before rebuilding,
  instead of reusing it. Safe: main aborts before runApp on a partial run.
- Make the social key derivation non-fatal: on failure the app opens on
  inventory (social hidden) rather than blanking. main tolerates an
  absent SocialService.
2026-07-10 16:14:10 +02:00
1af0282e00 fix(market): search your zone by a coarse prefix, not the exact cell
Discovery searched the full 5-char geohash (~2.4 km) with an exact Nostr tag
match, so two devices in adjacent cells never found each other even though
publish emits the full NIP-52 prefix ladder. Search now coarsens the area to a
configurable precision (SocialSettings.searchPrecision, default 4 ~20 km) via
searchPrefix(), with a human-worded 'how far' selector (Very close / Around
here / My region -> 5/4/3) in the market setup sheet.

Also: surface a clear 'couldn't reach the servers' message when no relay
accepts a share (instead of 'shared 0'), and add wss://relay.comunes.org as the
first default relay (reliable community home; public relays are backup).

Tests: settings precision (default/clamp/roundtrip), searchPrefix helper,
neighbour-cell discovery regression (offers_cubit + commons_core nostr
transports), and the range-selector widget test.
2026-07-10 16:12:38 +02:00
5dd0f243ce Merge branch 'main' into spike/block2-derisking
# Conflicts:
#	apps/app_seeds/lib/i18n/strings.g.dart
2026-07-10 15:48:36 +02:00
ea5ab0342d perf(species): seed the bundled catalog only once per version
Skip the ~1.5 MB catalog parse and the table scan on every launch — the
main startup cost. `seedBundledIfNeeded` re-seeds only when the recorded
version differs from `speciesCatalogVersion`, writing the version after
the seed commits so an interrupted seed retries next launch.

Adds `parseSpeciesCatalogVersion` and a test keeping the constant in sync
with the asset's `version` field.
2026-07-10 15:46:04 +02:00
c9c764e624 feat(i18n): add Asturian (asturianu) as a language
Adds Asturianu, translated in full (345 strings), plus the wiring to
make a minority language work end to end — the OS language picker is an
unreliable way to reach it, so the in-app choice must be explicit and
remembered.

- Model Asturian as the real `ast` locale (honest, international-by-design),
  not a fake `es-AST` region — slang rejects a 3-letter region anyway.
- Flutter ships no Material/Cupertino/intl localizations for `ast`, so map
  the framework chrome to Spanish (`materialLocaleFor`); the app's own text
  stays Asturian via slang. Fixes an "Invalid locale ast" crash when the
  auto-backup date was formatted with intl.
- Persist the picked language in the keystore (LocaleStore) and restore it
  at startup so it survives restarts and wins over the device locale.
- Settings: add the Asturianu tile; compare the full AppLocale so `es` and
  `ast` aren't both marked selected.

Tests: LocaleStore round-trip, locale switch + Spanish-chrome fallback,
and a regression for the intl date crash under Asturian.
2026-07-10 15:43:33 +02:00
8d1a66e7b8 feat(market): search + pull-to-refresh in the market list
Match the inventory patterns the market was missing:
- Search: OffersState gains a `query` + `visibleOffers` getter and
  OffersCubit.search() filters the already-discovered offers locally by
  summary (case-insensitive), never re-hitting the transport. The filter
  survives a refresh. Rounded search field mirrors inventory's.
- Pull-to-refresh: RefreshIndicator re-runs discovery for the current
  area (empty state included, so a swipe re-scans). Text filter kept.
- i18n: market.searchHint / market.noMatches (en/es/pt).

Note: the shared slang output (strings*.g.dart, incl. the generated
Asturian locale) is regenerated wholesale, so it also carries the
in-progress `ast` locale keys already present in the working tree.
2026-07-10 15:40:13 +02:00
5cb1c33d1b chore(i18n): regenerate strings after merging main 2026-07-10 15:27:36 +02:00
a14065db3f Merge branch 'main' into spike/block2-derisking 2026-07-10 15:27:01 +02:00
3e84e9bb3f fix(block2): make DI idempotent — stop the app falling back to 'inventory only'
Feedback: at times the social items showed greyed ('like phase 1'), only
inventory active. Cause: a hot restart / Android Activity restart re-runs main
while GetIt still holds the singletons; re-registration threw, main aborted
before rebuilding TaneApp with the social layer, leaving the pre-Block-2 tree.
configureDependencies now returns early if already configured, so re-entry
succeeds and TaneApp is always built with the social layer wired.
2026-07-10 15:24:23 +02:00
3fe5397675 feat(block2): global 'you're offline' banner (network state)
Feedback: offline, the social layer stops working with no clear reason. A slim
amber strip now shows above every screen when there's no network (connectivity_
plus), so it's obvious WHY sharing is paused — the inventory keeps working.
Wrapped once via the MaterialApp builder; connectivity check is guarded so it
no-ops where the plugin is absent (tests/unsupported platforms). i18n en/es/pt.
Analyzer clean.
2026-07-10 15:18:16 +02:00
cc762a139c Merge branch 'spike/block2-derisking' 2026-07-10 13:46:20 +02:00
a070d866e6 docs(block2): note 'change social identity' as an important pending decision
User-flagged (on-device feedback): no way to change the social identity today
(derived once from the root seed at startup). Records the two options —
pseudonymous social key (recommended) vs full root-seed reset — and the runtime
re-derivation implications, for a future round.
2026-07-10 13:41:44 +02:00
c6b91b7666 Merge branch 'spike/block2-derisking' 2026-07-10 13:40:07 +02:00
a447149a74 fix(block2): clearer market states — 'can't reach servers' (retry) vs 'set your area'
Feedback: the market sometimes looked 'disabled'. Now the offline/empty states
are distinct and honest:
- No working relay (defaults exist, so it's a reachability issue) → 'Can't reach
  the community servers right now' + a Retry.
- Online but no area set → 'Set your area' prompt (you'd otherwise see a bare
  'nothing nearby').
- Area set + nothing found → the existing empty state.

i18n en/es/pt; offline tests updated; analyzer clean.
2026-07-10 13:36:01 +02:00
5b52298160 fix(block2): sharing config UX + discovery + labels (raw feedback batch)
From on-device feedback:
- Area is no longer shown as a raw geohash 'name'. 'Use my location' is the
  primary action; a human status line says whether your area is set (coarse,
  never exact). The geohash code + the relay servers moved under 'Advanced'
  (code field labelled 'a code like sp3e9 — not a place name'), which also
  brings back node/relay selection that had been fully hidden.
- Location denial no longer yanks you into system settings — returns quietly
  with an actionable inline message.
- Discovery no longer spins forever when nobody's sharing nearby: a timeout
  resolves 'searching' to the empty state.
- Home card 'Open market' -> 'Market' + 'Discover and share seeds nearby'
  (the word 'open' confused).

i18n en/es/pt; analyzer clean; the use-my-location test asserts the status flip.
2026-07-10 13:32:11 +02:00
a9c6030dde feat(block2): Ğ1 — publish a Ğ1 address + 'Pay in Ğ1' in chat (levels 1-2)
The optional Ğ1 (free currency) integration, per g1-integration.md.
- Profile: an optional 'Ğ1 address' field, persisted locally and published in
  your NIP-01 kind:0 metadata (separate from the Nostr key).
- commons_core: UserProfile/ProfileTransport carry a 'g1' field (round-trip
  tested).
- Chat: when the peer published a Ğ1 address, a 'Pay in Ğ1' app-bar action opens
  their wallet (url_launcher) so value moves in the wallet, not the app — copies
  the address as a fallback. No payment rails in-app (sharing-model §4.3).

NOTE (vjrj): the wallet deep-link uses the Cesium web wallet as a universal
fallback; swap the URI in chat_screen._payG1 for the Ğ1nkgo/Ğecko scheme you
prefer. Analyzer clean (both packages); profile transport test covers g1.
2026-07-10 13:06:38 +02:00
267896430f Merge branch 'spike/block2-derisking' 2026-07-10 13:04:05 +02:00
3067d9ca7f feat(block2): QR of your npub in the profile (scan to share at a fair)
Adds a QrView (painted from the pure-Dart 'barcode' matrix — already transitive
via pdf, no native plugin) and shows a QR of your npub on the profile identity
card, so you can hand your identity over with a scan. Analyzer clean.
2026-07-10 13:00:47 +02:00
e32fdc9d38 Merge branch 'spike/block2-derisking' 2026-07-10 12:57:02 +02:00
48228c0bc6 feat(block2): show contact names (resolve peers' published profiles)
Closes the profile loop — peers now appear by name, not a raw key.
- ProfileCache: keystore-backed cache of peers' published display names (works
  offline) + shortPubkey() fallback.
- Chat: the app bar shows the peer's name (cached first, then freshened from
  their kind:0 over the session); falls back to a short key.
- Inbox: each conversation shows the cached name, and missing names are resolved
  over one session and cached.
- Wired via DI + TaneApp(profileCache).

Analyzer clean; ProfileCache covered by a plain test.
2026-07-10 12:47:48 +02:00
856736c8c5 Merge branch 'spike/block2-derisking' 2026-07-10 12:42:33 +02:00
458502b12b fix(block2): back buttons on social screens + clear 'Share my seeds' button
Two UX reports:
- Market/Messages (and Profile/Chat) had no back button — they were opened with
  context.go (replaces the stack). Switched the drawer entries, home Market card,
  offer 'Message' link and inbox taps to context.push, so the AppBar shows a back
  arrow to return.
- The market's upload icon (tooltip-only) was unclear. Replaced it with a labelled
  FloatingActionButton.extended 'Share my seeds' (campaign icon); the config gear
  stays in the AppBar.

Analyzer clean.
2026-07-10 12:40:08 +02:00
30246f4292 feat(block2): profiles — your identity card + name/about (NIP-01 kind:0)
Drawer 'Profile' is now live.
- commons_core: ProfileTransport + UserProfile + NostrProfileTransport (kind:0
  publish/fetch), exposed as SocialSession.profile. Round-trip tested against the
  in-process relay (fast dart test).
- app: ProfileStore (local name/about, keystore) + ProfileScreen — shows your
  shareable npub with copy, edits display name + 'about', saves locally and (when
  online) publishes kind:0 so peers can recognise you instead of a raw key.
- Drawer 'Profile' -> /profile (gated like Market/Chat).
- i18n en/es/pt. ProfileStore + profile transport covered by plain/dart tests.

Analyzer clean (both packages).
2026-07-10 12:37:07 +02:00
432acded29 Merge branch 'spike/block2-derisking' 2026-07-10 12:31:01 +02:00
abc2d37402 feat(block2): messages inbox + drawer 'Chat' link (was unreachable)
Chat was only reachable via an offer's 'Message' button, and the drawer 'Chat'
sat on 'coming soon' — so messaging/trust were effectively hidden.
- MessageStore keeps a conversation index (the keystore has no key enumeration)
  and exposes conversations() — peers newest-active-first with their last line.
- ChatListScreen: the messages inbox (tap a conversation → /chat/:pubkey).
- Drawer 'Chat' is now a live destination -> /messages (gated like Market).
- i18n en/es/pt. conversations() covered by a plain test (no widget hang).

Analyzer clean.
2026-07-10 12:26:33 +02:00
867bf38c5a Merge branch 'spike/block2-derisking' 2026-07-10 12:20:52 +02:00
7bf80d2031 feat(block2): persist chat history (messages survive leaving the chat)
Messages were in-memory only — gone on leaving the conversation.
- MessageStore: keystore-backed (no plaintext), a capped per-peer JSON list
  (last 200). Simple; the encrypted Drift DB is the eventual home at scale.
- MessagesCubit: loads saved history on start (subscribes FIRST, then loads, so
  a message arriving during the load isn't dropped) and persists every sent and
  received message. Wired via DI + TaneApp(messageStore).

Tests (plain 'test', no hang risk): MessageStore round-trip/per-peer/cap, and a
cubit test that reopens a fresh cubit and sees the saved conversation. Analyzer
clean; run 'flutter test' locally to confirm.
2026-07-10 12:20:25 +02:00
6d8c6cf7e1 test(block2): drop the market discover widget tests that hang the runner
Even with fixed pumps, driving discovery through testWidgets hangs here (the
transient 'searching' spinner never lets pumpAndSettle settle). Discovery logic
is already covered by offers_cubit_test (plain test + pumpEventQueue). Keep only
the offline/setup widget tests, which have no live stream. Reported by the user.
2026-07-10 12:14:53 +02:00
f3a7ad198d feat(block2): 'in your circle' trust — friend-of-a-friend, not a raw count
A network-wide 'N people vouch' count invites spam and means little. Now the
trust banner computes whether the peer is within YOUR circle — you vouch for
them, or someone you vouch for does (friend-of-a-friend) — via the pure
WebOfTrust rule (seeds={you}, threshold 1, distance 2). When known, the banner
turns green with 'In your circle' (else it falls back to the count).

The full member policy (threshold/distance/bootstrap) stays open (network-trust
§2); this is a deliberately loose 'people near you' heuristic.

Analyzer clean; trust cubit test adds a friend-of-a-friend vs stranger case
(run flutter test locally to confirm — the widget suite is too slow to run here).
2026-07-10 12:10:06 +02:00
320b9b6dba Merge branch 'spike/block2-derisking' 2026-07-10 12:07:11 +02:00
46b5537a11 test(block2): stop market discover tests hanging on the searching spinner
The 'searching' state shows a CircularProgressIndicator, so pumpAndSettle never
settled and the widget test hung ~10 min. Pump a couple of fixed frames instead
(the buffered offers deliver on a microtask), and close the fake discover stream
so it's finite. Reported by the user.
2026-07-10 12:06:19 +02:00
c18314d8c5 Merge branch 'spike/block2-derisking' 2026-07-10 11:56:56 +02:00
c513a6ce4b fix(block2): hide server config, enable drawer Market, badge my own offers
Three feedback fixes:
- Servers are fully hidden now — relays are managed automatically (default
  community servers; the pool skips dead ones). Sharing setup shows only 'your
  area'; no jargon-y URL field. (3 default relays for redundancy.)
- Drawer 'Market' is a live destination (was stuck on 'coming soon'), gated on
  the social layer being wired like the home card.
- My own listings in the market carry a 'You' badge (and no 'message yourself'
  button), so it's clear which offers are mine.

Analyzer clean. Note: could not run the flutter widget suite here (10+ min
compile after the geolocator native dep in this sandbox) — run 'flutter test'
locally to confirm; the You-badge test was simplified to be robust.
2026-07-10 11:56:55 +02:00
49a43722eb Merge branch 'spike/block2-derisking' 2026-07-10 11:24:40 +02:00
d369015e01 feat(block2): default relays + pick-from-list servers (works out of the box)
Feedback: the blank 'web address' field was unusable — nobody knows what to put,
so the market never worked at first launch.
- SocialSettings ships default public relays (nos.lol, relay.damus.io), applied
  automatically for new users so the market works day one. An explicitly empty
  choice still turns the network off (privacy). Exposure is minimal: offers are
  opt-in and carry only a coarse geohash.
- Sharing setup now shows the servers as a short PICK-LIST (checkboxes, pre-
  selected) instead of a blank field; a community's own server goes under a small
  'add another' escape hatch. Intro reworded — no need to know a community.
- Tests updated (defaults) and pinned offline (setRelayUrls([])) so widget tests
  don't hit the network. 22 green, analyzer clean.
2026-07-10 11:19:49 +02:00
a1a2f1230a Merge branch 'spike/block2-derisking' 2026-07-10 11:10:07 +02:00
a72224f95a fix(block2): sharing sheet overflow + location button feedback/robustness
Reported: sheet overflowed 7.7px with the keyboard up, and 'use my location'
seemed to do nothing.
- Config sheet is now scrollable (SingleChildScrollView) — no keyboard overflow.
- 'Use my location' shows an inline spinner + inline error (the old snackbar was
  hidden BEHIND the bottom sheet, so failures were invisible). Message is now
  actionable ('check location is on and the permission is granted').
- Provider hardening: request permission first; on permanent denial open app
  settings; if location services are off open location settings; time-limit the
  fix and fall back to last-known position so it doesn't hang indoors.

Analyzer clean; market tests green.
2026-07-10 11:09:12 +02:00
6fa5b9bdc5 feat(backup): tap automatic-backups to make a copy now 2026-07-10 11:05:36 +02:00
a85e71dc8d feat(block2): trust UI — vouch for a person in chat (web of trust)
- TrustCubit: reads how many vouch for the peer + whether YOU do; toggleVouch
  certifies/revokes over TrustTransport; never vouches for self; offline-safe.
  Kept simple — the full known-member rule (threshold+distance) and its
  bootstrap set are still-open policy (network-trust §2); a live count + your
  vouch is the honest first step.
- Chat screen now opens ONE session carrying both messaging AND trust (the
  shared-connection shape), and shows a slim vouch banner: 'Vouched for by N' +
  an 'I know this person' / 'You vouch for them' toggle.
- i18n en/es/pt (human words: 'I know this person', not 'certify').

Tests: 4 trust cubit + messaging + chat, 10 green; analyzer clean.
2026-07-10 11:04:07 +02:00
efd64c4da4 feat(block2): messaging UI — private 1:1 chat (NIP-17)
- MessagesCubit: 1:1 conversation over MessageTransport. Filters the shared
  inbox to the peer; tags our own sends with our pubkey (NIP-17 wraps don't come
  back to the sender) so the UI can align bubbles; degrades gracefully offline.
- ChatScreen (mockup 10): message bubbles (mine right / peer left) + a composer;
  opens a session lazily, shows 'set up sharing' when there's no relay.
- Entry point: a 'Message' button on each offer card (hidden on your own
  listings) → /chat/:pubkey.
- Routing + i18n en/es/pt.

Tests: 5 cubit (send/receive/order/filter/offline) + chat offline + market, green.
2026-07-10 10:59:47 +02:00
0dd7c28039 Merge branch 'main' into spike/block2-derisking
# Conflicts:
#	apps/app_seeds/lib/app.dart
#	apps/app_seeds/lib/i18n/strings.g.dart
#	apps/app_seeds/lib/main.dart
2026-07-10 10:50:16 +02:00
08d8dc1172 feat(block2): offline outbox — share now, publish when connected
Sender-side offline delivery hardening.
- OfferOutbox: a keystore-backed (no plaintext) queue of lot ids to publish
  later. Stores ids, not offers, so on flush each offer is rebuilt from the
  lot's CURRENT state — an offline edit is respected, a deleted lot drops out.
- flushOutbox(): on reconnect, republishes queued lots and clears them (no-op
  offline; drops ids whose lot is gone).
- Market screen: sharing offline parks the lots ('we'll share these when you're
  connected'); opening the market online auto-flushes the queue first.
- Wired via DI + TaneApp(outbox); i18n en/es/pt.

Tests: 4 outbox + 3 flush + market UI, 20 green. Analyzer clean for new code.
2026-07-10 10:45:53 +02:00
ee78b0ed62 feat(block2): relay pool — publish to many relays, discover deduped, tolerate failures
Relay-strategy hardening (network-trust §3): don't hang on one relay.
- NostrChannel interface: what the transports need (sign, publish, subscribe,
  reqOnce). Implemented by NostrConnection (single relay) and the new RelayPool.
- RelayPool: connects to all configured relays (skips unreachable ones, throws
  only when none are reachable), publishes to every live relay (accepted if any
  accepts), merges discovery deduped by event id, and survives a relay dropping.
- Transports now depend on NostrChannel, not a concrete connection.
- SocialService.openSession takes ALL configured relays (was relays.first);
  createOffersCubit passes the whole list.

commons_core 63 tests green (3 new pool tests over two in-process relays);
app_seeds social/market 18 green.
2026-07-10 10:35:39 +02:00
15bd689a99 Solve lint issue 2026-07-10 10:34:43 +02:00
bf0e770aae feat(block2): 'use my location' — set the sharing area without typing a code
Makes the coarse area human, per feedback that a typed code is jargon.
- CoarseLocationProvider interface + geolocator-backed impl (BSD-3): low
  accuracy only, returns null on any denial/error (degrades quietly). Behind an
  interface so it's fakeable and the plugin stays at the composition root.
- Config sheet: a 'Use my approximate location' button (shown only when a
  provider is wired) fills the area from the device position, reduced to a
  5-char geohash via commons_core's Geohash — a precise fix never leaves the
  device. 'Couldn't get your location' on failure.
- Threaded via TaneApp(location); main wires GeolocatorCoarseLocation.
- Platform: ACCESS_COARSE_LOCATION (Android) + NSLocationWhenInUseUsageDescription
  (iOS), both scoped to the coarse-area use.
- i18n en/es/pt. Widget test fills the area from a fake location. 8 tests green.
2026-07-10 10:25:44 +02:00
6195bece4a feat(block2): publish my seeds to the network (completes publish->discover)
The other half of the market: share your marked lots as offers.
- VarietyRepository.shareableLots(): non-private lots WITH their id (stable,
  updatable offer id) + the variety label. Private lots never leave the device.
- OffersCubit.publishLots(): maps each via OfferMapper and publishes, tagged
  with the coarse area + signed by the derived key; counts acceptances; no-op
  offline or with no area.
- Market screen: a 'share my seeds' action (online only) — publishes, then
  re-discovers to show them; 'mark some seeds first' when nothing's shared.
- i18n en/es/pt.

14 tests green (repo query, cubit publish, market UI); analyzer clean for new code.
2026-07-10 10:18:30 +02:00
39adc423b9 feat(backup): add automatic weekly encrypted backups
A silent safety net against in-app data loss or DB corruption, with zero
friction. Driven off the app lifecycle (startup + when hidden), it writes a
sealed .tanemaki copy into the app's private storage once a week, keeping the
newest 3 (rotating). Reuses the manual backup's encryption via the extracted
ExportImportService.buildSealedBackup(); never throws, so a failed copy can't
crash startup or backgrounding. Not registered on web (no file storage).

Settings shows a quiet reassurance line with the date of the last automatic
copy (localised via intl), hidden where no automatic backup exists.
2026-07-10 10:17:36 +02:00
2d4e14e0da feat(block2): geohash encoder in commons_core (for 'use my location')
Pure Dart lat/lon -> low-precision geohash (public-domain algorithm), the piece
the coarse-area capture needs so people never type a code. Precision-controlled
(5 chars ~ ±2.4 km). Tested against the canonical u4pruydqqvj vector + prefix/
proximity properties. 5 tests green.
2026-07-10 03:18:30 +02:00
6d2d0517cc fix(block2): plain-language sharing setup — no relay/server jargon
The market config leaked network jargon ('community servers / one address per
line'), confusing for the 10-80 audience (violates the human-words rule):
- Lead with a plain intro: what sharing does + a community you trust runs a
  shared meeting point, no company in the middle.
- Bury the technical web address under an 'advanced' expander (most people won't
  have one; 'leave empty if you don't have one yet').
- Human labels: 'Your area' (not 'Area code'), 'Web address' under advanced.

Market tests green.
2026-07-10 03:16:39 +02:00
0e7eb22c26 chore(i18n): regenerate strings for market.* after merging main 2026-07-10 03:07:59 +02:00
12f48ea318 Merge branch 'main' into spike/block2-derisking 2026-07-10 03:06:30 +02:00
2c358ec33a feat(block2): market screen — discover seeds near you (offers UI)
Wires the 'coming soon' market card to a real discovery screen (mockups 04/05):
- MarketScreen: opens an OfferTransport lazily; local-first, so it degrades to a
  human 'set up sharing' prompt when no relay/area is set or the net is down.
- Discovers offers by the saved coarse area, lists them with human reciprocity
  labels (gift/swap/for sale), 'near you' (never exact location), price only for
  sales. Config sheet to set area + community servers (no bundled public relays).
- Threaded via TaneApp(social, socialSettings) — null keeps the card 'coming
  soon' (so Block 1 tests pass unchanged); main wires it live.
- i18n en/es/pt (market.*), reuses share.* for type labels.

3 market widget tests + home tests green; app_seeds analyzes clean.
2026-07-10 03:05:12 +02:00
801a846d6e feat(block2): social settings + offers cubit factory (area, relays)
Persistence + wiring the offers screen needs, per the product decisions:
- SocialSettings (keystore-backed, no plaintext): coarse area geohash +
  configurable relay URLs, both empty by default (no bundled public relays —
  don't leak metadata to third parties). Registered in DI.
- createOffersCubit(): opens a SocialSession on the first configured relay when
  online, else a null-transport cubit that degrades gracefully. OffersCubit now
  closes the owning session on dispose.

4 settings tests green; offers/social suites still green.
2026-07-10 02:54:06 +02:00
a59adcca6b feat(block2): offers logic layer — OffersCubit + OfferMapper (no UI yet)
The testable core of the offers UI slice, transport-agnostic:
- OfferMapper: pure app-sharing-intent (OfferStatus) -> network Offer/OfferType
  mapping; keeps price only for sales, refuses to publish private lots.
- OffersCubit: discover(geohash) streams results; publish() reports the verdict;
  depends on the OfferTransport interface (fake in tests) and degrades
  gracefully offline (null transport = social layer unavailable, never throws).

8 tests green (fake in-memory transport, no relay). Screen/routing/i18n come
next — they need two product decisions (coarse-area capture, relay list).
2026-07-10 02:49:06 +02:00
51f9338433 feat(block2): wire the social identity into app_seeds (DI, no UI)
Third slice: bridge Block 1's root seed to the Block 2 transport foundation.

- SocialService: derives the secp256k1 Nostr identity from the SAME root seed
  the recovery QR backs up (no extra backup), exposes npub/pubkey, and opens a
  SocialSession = one shared connection carrying all three transports.
- injector.dart: registers SocialService in get_it. Local-first — derivation is
  cheap + offline; no relay is contacted at startup. Replaces the old
  "nodeId slice of the seed is the social pubkey" placeholder; keeps the CRDT
  author id distinct (unifying it would rewrite authorship — a later decision).
- Test: deterministic identity from the seed hex, matches commons_core, rejects
  malformed hex. 5 tests green; app_seeds analyzes clean (0 errors).
2026-07-10 02:44:37 +02:00
2b58fe40ce feat(block2): Nostr transport backend in commons_core (all 3 contracts)
Second production slice: the concrete Nostr backend on the shared connection,
using the vetted nostr package's crypto (NIP-44 spec-vector tested, gift wrap).

- NostrConnection: one dart:io WebSocket + identity + EVENT/OK, REQ/EOSE via
  the library's Event/Filter/Request. Pure Dart, no Flutter.
- NostrOfferTransport (NIP-99 kind 30402) + Nip99Codec: geohash coarsened to a
  NIP-52 prefix ladder on the wire; Offer stays inventory/location-agnostic.
- NostrMessageTransport (NIP-17): send/inbox via the library's DirectMessage
  gift-wrap onion.
- NostrTrustTransport (custom addressable kind 30777): certify/revoke/discover.
- MiniRelay test support + integration tests: offers, messaging (metadata
  privacy), WoT membership + revoke, and trust-filters-offers — all over ONE
  shared connection, hermetic in-process relay.

Renamed the core offer lifecycle enum OfferStatus -> OfferLifecycle to avoid
colliding with app_seeds' sharing-intent OfferStatus (no Block 1 code touched;
app_seeds analyzes clean, 0 errors). commons_core: 55 tests green.
2026-07-10 02:38:50 +02:00
edb480b21b feat(block2): commons_core social foundation — derivation, types, interfaces, WoT
First production slice of Block 2 (transport foundation), on the vetted
pure-Dart nostr package (LGPL-3.0, AGPL-compatible, Flutter-free):

- NostrKeyDerivation: seed -> secp256k1 Nostr identity via domain-separated
  HKDF (reuses cryptography's Hkdf like BackupBox), one-way, versioned. Wraps
  nostr's Keys for signing. Tested (reproducible, avalanche, one-way, integrates
  with IdentityService seed).
- Agnostic value types: Offer/OfferType/OfferStatus/DiscoveryQuery/PublishResult,
  Certification.
- Transport interfaces: OfferTransport, MessageTransport (+PrivateMessage),
  TrustTransport — the three sibling contracts.
- WebOfTrust: pure Duniter membership rule (threshold + distance, fixpoint).
  Promoted from the spike; TDD (7 cases).

No network yet (that's the next slice). commons_core: 50 tests green, analyzer
clean. Block 1 APIs unchanged.
2026-07-10 02:31:02 +02:00
05a9d45a82 docs(block2): lift the gate — open the social round
The de-risking spike validated the whole social happy path, so Block 2 moves
from "do NOT start" to STARTED. Records the build order (transport foundation in
commons_core first, on the vetted pure-Dart nostr lib) and marks open-decisions
D.3/D.7 as resolved by the spike.
2026-07-10 02:26:16 +02:00
cc88f3d688 spike(block2): de-risk web of trust — the last big unknown
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.
2026-07-10 02:17:35 +02:00
2cafc7fc12 spike(block2): de-risk NIP-17 messaging + validate shared-connection shape
Extends the Block 2 spike to attack the highest-risk unbuilt piece (private
messaging) and to prove the Q2 architecture recommendation with running code:

- NostrConnection: one shared socket+key+sign+REQ/EOSE lifecycle. Refactored
  NostrOfferTransport onto it; added NostrMessageTransport — two thin contracts
  on ONE connection (the 'one connection, three interfaces' shape).
- NIP-44 v2 encryption (secp256k1 ECDH -> ChaCha20 + HMAC, length-hiding pad).
- NIP-17/NIP-59 gift-wrap: rumor(14) -> seal(13) -> wrap(1059, ephemeral key).
  Alice->Bob DM round-trips through the relay: encrypted, sender authenticated,
  and metadata-private (relay/eavesdropper sees only ciphertext, an ephemeral
  author and the recipient p-tag — Alice stays hidden).

Findings updated: NIP-17 risk drops Medium-High -> Medium (hardening, not
feasibility); next de-risking target is the web of trust. NIP-44 not yet
vector-verified (noted). 20 tests green, analyzer clean, Block 1 untouched.
2026-07-10 02:11:54 +02:00
49b872b405 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
26 changed files with 2051 additions and 25 deletions

View file

@ -1,9 +1,9 @@
# Spike — Block 2 (social layer) de-risking findings
*Throwaway research spike. Language: English (findings), per code convention.
The throwaway spike code (`spike/block2_spike/`) has been moved out of this
public repo and preserved privately — it was **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
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
@ -34,7 +34,7 @@ See "How to run" at the end.
**Result: works, deterministic, one-way. This unblocks "one identity, one backup".**
- Implemented `NostrKey.deriveFromSeed`:
- 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
@ -80,13 +80,13 @@ trust do NOT fit behind the same interface — they share a *connection*, not a
*contract*.**
### The seam works (offers)
- `OfferTransport` =
- [`OfferTransport`](../../spike/block2_spike/lib/src/offer_transport.dart) =
`publish(Offer)` / `discover(DiscoveryQuery)` / `retract(id)`. The Nostr
NIP-99 backend (`NostrOfferTransport`
+ `Nip99Codec`) sits entirely
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` is agnostic by
- [`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
@ -114,14 +114,14 @@ manageable**:
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` is
[`NostrConnection`](../../spike/block2_spike/lib/src/nostr_connection.dart) is
**one shared** socket + key + sign + REQ/EOSE lifecycle, and **all three thin
interfaces sit on top of the same instance** —
`OfferTransport`
[`OfferTransport`](../../spike/block2_spike/lib/src/nostr_offer_transport.dart)
(NIP-99),
`MessageTransport`
[`MessageTransport`](../../spike/block2_spike/lib/src/nostr_message_transport.dart)
(NIP-17) and
`TrustTransport`
[`TrustTransport`](../../spike/block2_spike/lib/src/nostr_trust_transport.dart)
(custom WoT). The trust test even runs offer discovery and trust annotation
over one connection. This is the shape to lift into `commons_core`: shared
connection, per-concern contract. Trying to make `OfferTransport` also carry
@ -139,7 +139,7 @@ that whole happy path now runs — what's left is production hardening (below).
**Result: the flow works end to end. The real risk is NIP maturity/ecosystem,
not the mechanics.**
- Built a hermetic in-process `MiniRelay`
- 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.
@ -183,13 +183,13 @@ 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`):
- **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`):
([`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).
@ -223,12 +223,12 @@ There is no settled NIP for a web of trust, so — exactly as
anticipates — the spike models **its own WoT, Duniter-compatible**, over Nostr:
- **Certifications as events**
(`NostrTrustTransport`):
([`NostrTrustTransport`](../../spike/block2_spike/lib/src/nostr_trust_transport.dart)):
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`) — the
([`WebOfTrust`](../../spike/block2_spike/lib/src/web_of_trust.dart)) — the
`commons_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
@ -263,9 +263,8 @@ entity, and optionally importing the on-chain Ğ1 WoT (level 3) as a trust sourc
([open-decisions.md](open-decisions.md) §D.1, §D.6). That is what should drive
the Phase-3 estimate and the funding ask.
4. **Do not start Block 2 from this spike.** It is throwaway research code (not
vector-verified, no offline delivery, no persistence, single relay). The
`spike/block2_spike/` code has been moved out of this public repo (preserved
privately) now these findings are captured. The funded social
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 in `commons_core`**, using
the shapes proven here — one `NostrConnection`, three interfaces, the pure
`WebOfTrust` rule — and spend its risk budget on hardening and bootstrap, not
@ -284,12 +283,11 @@ entity, and optionally importing the on-chain Ğ1 WoT (level 3) as a trust sourc
## How to run
The throwaway spike code has been moved out of this public repo and is preserved
privately; nothing here was wired into `app_seeds` or `commons_core`'s production
graph, and the Block 1 suite is untouched. For the record, it ran as:
```sh
cd spike/block2_spike # in the private archive
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.

3
spike/block2_spike/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
# Throwaway spike — don't commit build artifacts or the resolved lockfile.
.dart_tool/
pubspec.lock

View file

@ -0,0 +1,19 @@
/// THROWAWAY Block 2 de-risking spike. Not production; not on the pub workspace.
library;
export 'src/bech32.dart';
export 'src/hkdf.dart';
export 'src/message_transport.dart';
export 'src/mini_relay.dart';
export 'src/nip44.dart';
export 'src/nip99.dart';
export 'src/nostr_connection.dart';
export 'src/nostr_event.dart';
export 'src/nostr_key.dart';
export 'src/nostr_message_transport.dart';
export 'src/nostr_offer_transport.dart';
export 'src/nostr_trust_transport.dart';
export 'src/offer.dart';
export 'src/offer_transport.dart';
export 'src/trust_transport.dart';
export 'src/web_of_trust.dart';

View file

@ -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<int> 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<int> _hrpExpand(String hrp) => [
...hrp.codeUnits.map((c) => c >> 5),
0,
...hrp.codeUnits.map((c) => c & 31),
];
List<int> _createChecksum(String hrp, List<int> 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<int> _convertBits8to5(List<int> data) {
var acc = 0;
var bits = 0;
final out = <int>[];
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<int> data) {
final five = _convertBits8to5(data);
final checksum = _createChecksum(hrp, five);
final combined = [...five, ...checksum];
return '${hrp}1${combined.map((v) => _charset[v]).join()}';
}

View file

@ -0,0 +1,55 @@
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<int> ikm,
required String info,
List<int> 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 = <int>[];
var previous = <int>[];
var counter = 1;
while (out.length < length) {
final input = <int>[...previous, ...infoBytes, counter];
previous = Hmac(sha256, prk).convert(input).bytes;
out.addAll(previous);
counter++;
}
return Uint8List.fromList(out.sublist(0, length));
}
/// HKDF-Extract (RFC 5869 §2.2): `PRK = HMAC(salt, ikm)`. NIP-44 uses this
/// alone to turn the ECDH secret into the conversation key.
Uint8List hkdfExtract({required List<int> salt, required List<int> ikm}) =>
Uint8List.fromList(Hmac(sha256, salt).convert(ikm).bytes);
/// HKDF-Expand (RFC 5869 §2.3) with a *byte* info (NIP-44 uses the nonce as
/// info), producing [length] bytes.
Uint8List hkdfExpandBytes({
required List<int> prk,
required List<int> info,
required int length,
}) {
final out = <int>[];
var previous = <int>[];
var counter = 1;
while (out.length < length) {
previous = Hmac(sha256, prk).convert([...previous, ...info, counter]).bytes;
out.addAll(previous);
counter++;
}
return Uint8List.fromList(out.sublist(0, length));
}

View file

@ -0,0 +1,28 @@
/// One decrypted direct message handed to the app.
class DirectMessage {
const DirectMessage({
required this.fromPubkey,
required this.text,
required this.at,
});
/// The authenticated sender (from the inner rumor). NOT the gift-wrap author,
/// which is a throwaway ephemeral key that's the metadata-privacy point.
final String fromPubkey;
final String text;
final DateTime at;
}
/// The second interface over the shared `NostrConnection` (Q2): private 1:1
/// messaging. Deliberately SEPARATE from `OfferTransport` its verbs (send a
/// private DM / read an inbox) don't fit publish/discover. Backed by NIP-17
/// gift-wrapped, NIP-44-encrypted events.
abstract interface class MessageTransport {
/// Sends [text] privately to [toPubkey].
Future<void> send({required String toPubkey, required String text});
/// Streams decrypted messages addressed to this identity.
Stream<DirectMessage> inbox();
Future<void> close();
}

View file

@ -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 publishdiscover 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 3000039999, 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<NostrEvent> _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<MiniRelay> 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<String, dynamic>));
case 'REQ':
final subId = msg[1] as String;
final filters = msg.sublist(2).cast<Map<String, dynamic>>();
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<void> stop() => _server.close(force: true);
}
class _Sub {
_Sub(this.ws, this.id, this.filters);
final WebSocket ws;
final String id;
final List<Map<String, dynamic>> filters;
bool matches(NostrEvent e) => filters.any((f) => _matchesFilter(e, f));
bool _matchesFilter(NostrEvent e, Map<String, dynamic> 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<String>();
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;
}
}

View file

@ -0,0 +1,142 @@
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:pointycastle/export.dart';
import 'hkdf.dart';
/// NIP-44 v2 payload encryption (secp256k1 ECDH conversation key ChaCha20 +
/// HMAC-SHA256, with length-hiding padding). This is the crypto NIP-17 private
/// DMs ride on. Spike-grade: follows the NIP-44 *construction* faithfully but is
/// NOT cross-checked against the reference test vectors see findings §4. Good
/// enough to prove the messaging flow and architecture round-trip.
class Nip44 {
static final _params = ECCurve_secp256k1();
/// secp256k1 field prime.
static final BigInt _p = BigInt.parse(
'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F',
radix: 16,
);
static final _salt = ascii.encode('nip44-v2');
/// ECDH conversation key shared by [privHexA] and x-only [pubHexB]:
/// `HKDF-Extract(salt="nip44-v2", ikm = (d·P).x)`. Symmetric: AB and BA
/// yield the same key.
static Uint8List conversationKey(String privHexA, String pubHexB) {
final d = _bi(privHexA);
final point = _liftX(_bi(pubHexB));
final shared = (point * d)!;
final x = shared.x!.toBigInteger()!;
return hkdfExtract(salt: _salt, ikm: _to32(x));
}
/// Encrypts [plaintext] under [convKey]; returns base64 NIP-44 payload.
static String encrypt(Uint8List convKey, String plaintext, {Uint8List? nonce}) {
final n = nonce ?? _randomBytes(32);
final keys = hkdfExpandBytes(prk: convKey, info: n, length: 76);
final chachaKey = keys.sublist(0, 32);
final chachaNonce = keys.sublist(32, 44);
final hmacKey = keys.sublist(44, 76);
final padded = _pad(utf8.encode(plaintext));
final ciphertext = _chacha20(chachaKey, chachaNonce, padded);
final mac = Hmac(sha256, hmacKey).convert([...n, ...ciphertext]).bytes;
return base64.encode([2, ...n, ...ciphertext, ...mac]);
}
/// Decrypts a base64 NIP-44 payload under [convKey]. Throws on version/MAC
/// failure (so an eavesdropper with the wrong key cannot read it).
static String decrypt(Uint8List convKey, String payload) {
final raw = base64.decode(payload);
if (raw[0] != 2) throw const FormatException('unsupported nip44 version');
final n = raw.sublist(1, 33);
final ciphertext = raw.sublist(33, raw.length - 32);
final mac = raw.sublist(raw.length - 32);
final keys = hkdfExpandBytes(prk: convKey, info: n, length: 76);
final chachaKey = keys.sublist(0, 32);
final chachaNonce = keys.sublist(32, 44);
final hmacKey = keys.sublist(44, 76);
final expected = Hmac(sha256, hmacKey).convert([...n, ...ciphertext]).bytes;
if (!_constantTimeEquals(expected, mac)) {
throw const FormatException('nip44 MAC mismatch (wrong key or tampered)');
}
final padded = _chacha20(chachaKey, chachaNonce, Uint8List.fromList(ciphertext));
final len = (padded[0] << 8) | padded[1];
return utf8.decode(padded.sublist(2, 2 + len));
}
// --- padding (NIP-44 length hiding) ---
static Uint8List _pad(List<int> plaintext) {
final unpadded = plaintext.length;
final paddedLen = _calcPaddedLen(unpadded);
final buf = Uint8List(2 + paddedLen);
buf[0] = (unpadded >> 8) & 0xff;
buf[1] = unpadded & 0xff;
buf.setRange(2, 2 + unpadded, plaintext);
return buf;
}
static int _calcPaddedLen(int len) {
if (len <= 32) return 32;
final nextPower = 1 << ((len - 1).bitLength);
final chunk = nextPower <= 256 ? 32 : nextPower ~/ 8;
return chunk * ((len - 1) ~/ chunk + 1);
}
// --- primitives ---
static Uint8List _chacha20(List<int> key, List<int> nonce, Uint8List input) {
final engine = ChaCha7539Engine()
..init(
true,
ParametersWithIV(
KeyParameter(Uint8List.fromList(key)),
Uint8List.fromList(nonce),
),
);
final out = Uint8List(input.length);
engine.processBytes(input, 0, input.length, out, 0);
return out;
}
/// BIP340 lift_x: reconstruct the curve point with even y from x-only [x].
static ECPoint _liftX(BigInt x) {
final ySq = (x.modPow(BigInt.from(3), _p) + BigInt.from(7)) % _p;
var y = ySq.modPow((_p + BigInt.one) ~/ BigInt.from(4), _p);
if (y.isOdd) y = _p - y;
return _params.curve.createPoint(x, y);
}
static BigInt _bi(String hex) => BigInt.parse(hex, radix: 16);
static Uint8List _to32(BigInt v) {
final out = Uint8List(32);
var x = v;
for (var i = 31; i >= 0; i--) {
out[i] = (x & BigInt.from(0xff)).toInt();
x = x >> 8;
}
return out;
}
static bool _constantTimeEquals(List<int> a, List<int> b) {
if (a.length != b.length) return false;
var diff = 0;
for (var i = 0; i < a.length; i++) {
diff |= a[i] ^ b[i];
}
return diff == 0;
}
static final _rng = Random.secure();
static Uint8List _randomBytes(int n) =>
Uint8List.fromList(List.generate(n, (_) => _rng.nextInt(256)));
}

View file

@ -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 = <List<String>>[
['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<String>? _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,
);
}

View file

@ -0,0 +1,95 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'nostr_event.dart';
/// The ONE piece shared by every Nostr-backed transport (Q2 recommendation): a
/// single relay socket + the signing identity + the EVENT/OK and REQ/EOSE
/// lifecycle. `OfferTransport`, `MessageTransport` and a future `TrustTransport`
/// each sit on top of this they share this *connection*, not a *contract*.
class NostrConnection {
NostrConnection._(this._socket, this.privateKeyHex, this.publicKeyHex) {
_socket.listen(_onData, onDone: _onDone);
}
final WebSocket _socket;
/// The derived (Q1) identity this connection signs with.
final String privateKeyHex;
final String publicKeyHex;
final _incoming = StreamController<List<dynamic>>.broadcast();
int _subCounter = 0;
static Future<NostrConnection> connect(
String relayUrl, {
required String privateKeyHex,
required String publicKeyHex,
}) async {
final socket = await WebSocket.connect(relayUrl);
return NostrConnection._(socket, privateKeyHex, publicKeyHex);
}
void _onData(dynamic d) => _incoming.add(jsonDecode(d as String) as List);
void _onDone() {
if (!_incoming.isClosed) _incoming.close();
}
/// Publishes an already-signed [event]; resolves with the relay's OK verdict.
Future<({bool accepted, String message})> publish(NostrEvent event) async {
final ok = _incoming.stream.firstWhere(
(m) => m[0] == 'OK' && m[1] == event.id,
);
_socket.add(jsonEncode(['EVENT', event.toJson()]));
final r = await ok.timeout(const Duration(seconds: 5));
return (accepted: r[2] as bool, message: r.length > 3 ? r[3] as String : '');
}
/// Streams events matching [filter] (live), until the caller cancels.
Stream<NostrEvent> subscribe(Map<String, dynamic> filter) {
final subId = 'sub${_subCounter++}';
late StreamController<NostrEvent> controller;
StreamSubscription? sub;
controller = StreamController<NostrEvent>(
onListen: () {
sub = _incoming.stream.listen((m) {
if (m[0] == 'EVENT' && m[1] == subId) {
controller.add(NostrEvent.fromJson(m[2] as Map<String, dynamic>));
}
});
_socket.add(jsonEncode(['REQ', subId, filter]));
},
onCancel: () async {
_socket.add(jsonEncode(['CLOSE', subId]));
await sub?.cancel();
},
);
return controller.stream;
}
/// Collects stored events matching [filter] up to EOSE, then closes the sub.
Future<List<NostrEvent>> reqOnce(Map<String, dynamic> filter) async {
final subId = 'once${_subCounter++}';
final results = <NostrEvent>[];
final done = Completer<void>();
final sub = _incoming.stream.listen((m) {
if (m[1] != subId) return;
if (m[0] == 'EVENT') {
results.add(NostrEvent.fromJson(m[2] as Map<String, dynamic>));
} 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;
}
Future<void> close() async {
await _socket.close();
if (!_incoming.isClosed) await _incoming.close();
}
}

View file

@ -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<List<String>> 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<String, dynamic> toJson() => {
'id': id,
'pubkey': pubkey,
'created_at': createdAt,
'kind': kind,
'tags': tags,
'content': content,
'sig': sig,
};
static NostrEvent fromJson(Map<String, dynamic> 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;
}
}

View file

@ -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<int> 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<int> 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;
}

View file

@ -0,0 +1,136 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:bip340/bip340.dart' as bip340;
import 'message_transport.dart';
import 'nip44.dart';
import 'nostr_connection.dart';
import 'nostr_event.dart';
/// NIP-17 private DM backend for [MessageTransport], on the shared
/// [NostrConnection]. This is the spike's answer to the highest-risk Q3 piece:
/// does the metadata-private messaging flow actually work, and does it fit the
/// "one connection, three interfaces" shape?
///
/// The NIP-17 / NIP-59 gift-wrap onion:
/// rumor (kind 14, UNSIGNED) the real message; author = real sender
/// seal (kind 13, signed by sender) NIP-44(rumor) to recipient
/// wrap (kind 1059, signed by a throwaway EPHEMERAL key) NIP-44(seal)
/// The relay and any observer see only the wrap: a random author, a `p` tag for
/// the recipient, and opaque ciphertext. The real sender is hidden until the
/// recipient unwraps.
class NostrMessageTransport implements MessageTransport {
NostrMessageTransport(this._conn);
final NostrConnection _conn;
static const kindRumor = 14;
static const kindSeal = 13;
static const kindGiftWrap = 1059;
final _rng = Random.secure();
@override
Future<void> send({required String toPubkey, required String text}) async {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
// 1. Rumor the real, UNSIGNED message from the real sender.
final rumor = NostrEvent(
pubkey: _conn.publicKeyHex,
createdAt: now,
kind: kindRumor,
tags: [
['p', toPubkey],
],
content: text,
);
rumor.id = rumor.computeId(); // id but no signature (NIP-59)
// 2. Seal sender-signed, NIP-44 to recipient.
final sealKey = Nip44.conversationKey(_conn.privateKeyHex, toPubkey);
final seal = NostrEvent(
pubkey: _conn.publicKeyHex,
createdAt: now,
kind: kindSeal,
tags: const [],
content: Nip44.encrypt(sealKey, jsonEncode(rumor.toJson())),
)..signWith(_conn.privateKeyHex);
// 3. Gift wrap signed by a THROWAWAY ephemeral key, NIP-44 to recipient.
final eph = _ephemeralKey();
final wrapKey = Nip44.conversationKey(eph.priv, toPubkey);
final wrap = NostrEvent(
pubkey: eph.pub,
createdAt: now,
kind: kindGiftWrap,
tags: [
['p', toPubkey],
],
content: Nip44.encrypt(wrapKey, jsonEncode(seal.toJson())),
)..signWith(eph.priv);
final r = await _conn.publish(wrap);
if (!r.accepted) {
throw StateError('relay rejected gift wrap: ${r.message}');
}
}
@override
Stream<DirectMessage> inbox() {
final filter = {
'kinds': [kindGiftWrap],
'#p': [_conn.publicKeyHex],
};
return _conn.subscribe(filter).map(_unwrap).where((m) => m != null).cast();
}
/// Collect the inbox up to EOSE (tests/metrics).
Future<List<DirectMessage>> inboxUntilEose() async {
final wraps = await _conn.reqOnce({
'kinds': [kindGiftWrap],
'#p': [_conn.publicKeyHex],
});
return wraps.map(_unwrap).whereType<DirectMessage>().toList();
}
DirectMessage? _unwrap(NostrEvent wrap) {
try {
// Unwrap with our key + the ephemeral (wrap) author.
final wrapKey = Nip44.conversationKey(_conn.privateKeyHex, wrap.pubkey);
final seal = NostrEvent.fromJson(
jsonDecode(Nip44.decrypt(wrapKey, wrap.content)) as Map<String, dynamic>,
);
// Open the seal with our key + the seal (real sender) author.
final sealKey = Nip44.conversationKey(_conn.privateKeyHex, seal.pubkey);
final rumor = NostrEvent.fromJson(
jsonDecode(Nip44.decrypt(sealKey, seal.content)) as Map<String, dynamic>,
);
// Authenticity: the seal is signed by the sender, and the inner rumor
// must claim the SAME author. Otherwise someone re-wrapped a foreign seal.
if (rumor.pubkey != seal.pubkey || !seal.verify()) return null;
return DirectMessage(
fromPubkey: rumor.pubkey,
text: rumor.content,
at: DateTime.fromMillisecondsSinceEpoch(rumor.createdAt * 1000),
);
} catch (_) {
return null; // not for us / wrong key / tampered
}
}
({String priv, String pub}) _ephemeralKey() {
final bytes = Uint8List.fromList(
List.generate(32, (_) => _rng.nextInt(256)),
);
final priv = bytes
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
return (priv: priv, pub: bip340.getPublicKey(priv));
}
@override
Future<void> close() => _conn.close();
}

View file

@ -0,0 +1,71 @@
import 'dart:async';
import 'nip99.dart';
import 'nostr_connection.dart';
import 'offer.dart';
import 'offer_transport.dart';
/// Nostr NIP-99 backend for [OfferTransport], on a shared [NostrConnection]
/// (Q2/Q3). Signs offers with the derived key (Q1) and publishes them as
/// kind-30402 classified listings; discovery is a REQ filtered by kind + `#g`.
class NostrOfferTransport implements OfferTransport {
NostrOfferTransport(this._conn);
final NostrConnection _conn;
final Nip99Codec _codec = Nip99Codec();
/// Convenience: open a dedicated connection for this transport.
static Future<NostrOfferTransport> connect(
String relayUrl, {
required String privateKeyHex,
required String pubkeyHex,
}) async =>
NostrOfferTransport(await NostrConnection.connect(
relayUrl,
privateKeyHex: privateKeyHex,
publicKeyHex: pubkeyHex,
));
Map<String, dynamic> _filter(DiscoveryQuery q) => {
'kinds': [Nip99Codec.kindActive],
'#g': [q.geohashPrefix],
'limit': q.limit,
};
@override
Future<PublishResult> publish(Offer offer) async {
final event = _codec.encode(
offer,
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
)..signWith(_conn.privateKeyHex);
final r = await _conn.publish(event);
return PublishResult(
accepted: r.accepted,
transportRef: event.id,
message: r.message,
);
}
@override
Stream<Offer> discover(DiscoveryQuery query) => _conn.subscribe(_filter(query)).map(_codec.decode).where(
(o) => query.types.isEmpty || query.types.contains(o.type),
);
/// Collect matches up to EOSE (for tests/metrics).
Future<List<Offer>> discoverUntilEose(DiscoveryQuery query) async {
final events = await _conn.reqOnce(_filter(query));
return events
.map(_codec.decode)
.where((o) => query.types.isEmpty || query.types.contains(o.type))
.toList();
}
@override
Future<void> retract(String offerId) async {
// A NIP-09 deletion of the addressable coordinate would go here; the OK-path
// is identical to publish. Omitted from the spike surface.
}
@override
Future<void> close() => _conn.close();
}

View file

@ -0,0 +1,97 @@
import 'nostr_connection.dart';
import 'nostr_event.dart';
import 'trust_transport.dart';
import 'web_of_trust.dart';
/// Duniter-style certifications carried as Nostr events, on the shared
/// [NostrConnection]. There is no settled NIP for a web of trust (NIP-85 is
/// unsettled), so the spike uses a **custom addressable kind** mapped to the
/// Duniter model exactly the "own WoT, Duniter-compatible" path
/// g1-integration.md §"WoT propia pero compatible" anticipates.
///
/// Addressable by (kind, issuer, `d`=subject): one live certification per
/// (certifier, subject), so re-certifying renews and revoking replaces the
/// relay keeps only the latest, like Ğ1 keeps one active cert per pair.
class NostrTrustTransport implements TrustTransport {
NostrTrustTransport(this._conn);
final NostrConnection _conn;
/// Custom Tanemaki certification kind (addressable range).
static const kindCertification = 30777;
int get _now => DateTime.now().millisecondsSinceEpoch ~/ 1000;
@override
Future<void> certify({
required String subjectPubkey,
Duration validity = const Duration(days: 365),
String note = '',
}) =>
_publishCert(subjectPubkey, note: note, validity: validity, revoked: false);
@override
Future<void> revoke({required String subjectPubkey}) =>
_publishCert(subjectPubkey, note: '', validity: Duration.zero, revoked: true);
Future<void> _publishCert(
String subject, {
required String note,
required Duration validity,
required bool revoked,
}) async {
final event = NostrEvent(
pubkey: _conn.publicKeyHex,
createdAt: _now,
kind: kindCertification,
tags: [
['p', subject],
['d', subject], // one addressable cert per (issuer, subject)
['status', revoked ? 'revoked' : 'valid'],
if (!revoked) ['expiration', '${_now + validity.inSeconds}'],
],
content: note,
)..signWith(_conn.privateKeyHex);
final r = await _conn.publish(event);
if (!r.accepted) throw StateError('relay rejected cert: ${r.message}');
}
@override
Future<List<Certification>> allCertifications() async {
final events = await _conn.reqOnce({
'kinds': [kindCertification],
});
return events.map(_toCertification).toList();
}
@override
Future<Set<String>> certifiersOf(String subjectPubkey) async {
final events = await _conn.reqOnce({
'kinds': [kindCertification],
'#p': [subjectPubkey],
});
final now = DateTime.now();
return events
.map(_toCertification)
.where((c) => c.subject == subjectPubkey && c.isValidAt(now))
.map((c) => c.issuer)
.toSet();
}
Certification _toCertification(NostrEvent e) {
final exp = e.tag('expiration');
return Certification(
issuer: e.pubkey,
subject: e.tag('p') ?? e.tag('d') ?? '',
issuedAt: DateTime.fromMillisecondsSinceEpoch(e.createdAt * 1000),
expiresAt: exp == null
? null
: DateTime.fromMillisecondsSinceEpoch(int.parse(exp) * 1000),
revoked: e.tag('status') == 'revoked',
note: e.content,
);
}
@override
Future<void> close() => _conn.close();
}

View file

@ -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<OfferType> types;
final int limit;
}

View file

@ -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<PublishResult> publish(Offer offer);
/// Streams offers matching [query]. Emits already-stored matches, then live
/// ones as they arrive, until the caller cancels the subscription.
Stream<Offer> 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<void> retract(String offerId);
Future<void> close();
}

View file

@ -0,0 +1,28 @@
import 'web_of_trust.dart';
/// The third interface over the shared `NostrConnection` (Q2): the web of trust.
/// Its verbs certify / revoke / read certifications are again distinct from
/// offers and messaging, confirming the three-contract split. Certifications are
/// public signed events (like the on-chain Ğ1 WoT), so this transport publishes
/// and discovers rather than encrypts.
abstract interface class TrustTransport {
/// Publishes a signed "I vouch for [subjectPubkey]" certification, valid for
/// [validity] (Duniter certifications expire and must be renewed).
Future<void> certify({
required String subjectPubkey,
Duration validity = const Duration(days: 365),
String note = '',
});
/// Revokes this identity's certification of [subjectPubkey] (replaces the
/// addressable event with a revoked one).
Future<void> revoke({required String subjectPubkey});
/// All certifications currently visible on the relay (to build a [WebOfTrust]).
Future<List<Certification>> allCertifications();
/// Distinct, currently-valid certifiers of [subjectPubkey].
Future<Set<String>> certifiersOf(String subjectPubkey);
Future<void> close();
}

View file

@ -0,0 +1,119 @@
/// One "A vouches for B" certification (Duniter/Ğ1 style). Public by design
/// like the on-chain Ğ1 WoT and signed by the issuer.
class Certification {
const Certification({
required this.issuer,
required this.subject,
required this.issuedAt,
this.expiresAt,
this.revoked = false,
this.note = '',
});
final String issuer; // certifier pubkey
final String subject; // certified pubkey
final DateTime issuedAt;
final DateTime? expiresAt;
final bool revoked;
final String note;
bool isValidAt(DateTime now) =>
!revoked && (expiresAt == null || expiresAt!.isAfter(now));
}
/// Pure, Flutter-free web-of-trust computation. This is the piece that would
/// live in `commons_core` (identity/trust is generic). It answers "who counts
/// as a known member" from a set of certification edges, using Duniter's two
/// rules: **N certifications from existing members** and **within distance D of
/// the bootstrap referents**. TDD target no I/O, deterministic.
class WebOfTrust {
WebOfTrust();
final Map<String, Set<String>> _out = {}; // issuer -> subjects
final Map<String, Set<String>> _in = {}; // subject -> issuers
/// Builds a graph from valid certifications as of [now] (drops expired /
/// revoked, ignores self-certifications).
factory WebOfTrust.fromCertifications(
Iterable<Certification> certs, {
required DateTime now,
}) {
final wot = WebOfTrust();
for (final c in certs) {
if (c.issuer == c.subject) continue;
if (!c.isValidAt(now)) continue;
wot.addEdge(c.issuer, c.subject);
}
return wot;
}
void addEdge(String issuer, String subject) {
(_out[issuer] ??= {}).add(subject);
(_in[subject] ??= {}).add(issuer);
}
/// Distinct certifiers of [subject].
Set<String> certifiersOf(String subject) => _in[subject] ?? const {};
int certifierCount(String subject) => certifiersOf(subject).length;
/// The set of known members: [seeds] (bootstrap referents, certified face to
/// face at a fair) plus everyone reachable by the rules. Iterated to a
/// fixpoint, because becoming a member can let you push others over the
/// threshold.
///
/// - [threshold]: min certifications **from current members** (Duniter sigQty,
/// ~5).
/// - [maxDistance]: max certification hops from any seed (Duniter stepMax, ~5).
Set<String> members({
required Set<String> seeds,
required int threshold,
required int maxDistance,
}) {
final members = {...seeds};
final distance = _distancesFromSeeds(seeds);
var changed = true;
while (changed) {
changed = false;
for (final subject in _in.keys) {
if (members.contains(subject)) continue;
final d = distance[subject];
if (d == null || d > maxDistance) continue;
final memberCertifiers =
certifiersOf(subject).where(members.contains).length;
if (memberCertifiers >= threshold) {
members.add(subject);
changed = true;
}
}
}
return members;
}
bool isMember(
String pubkey, {
required Set<String> seeds,
required int threshold,
required int maxDistance,
}) =>
members(seeds: seeds, threshold: threshold, maxDistance: maxDistance)
.contains(pubkey);
/// BFS hop-distance from the nearest seed over certification edges.
Map<String, int> _distancesFromSeeds(Set<String> seeds) {
final dist = <String, int>{for (final s in seeds) s: 0};
final queue = [...seeds];
var head = 0;
while (head < queue.length) {
final node = queue[head++];
final d = dist[node]!;
for (final next in _out[node] ?? const <String>{}) {
if (!dist.containsKey(next)) {
dist[next] = d + 1;
queue.add(next);
}
}
}
return dist;
}
}

View file

@ -0,0 +1,25 @@
# 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)
pointycastle: ^3.9.0 # secp256k1 ECDH + ChaCha20 for NIP-44 (messaging spike)
dev_dependencies:
test: ^1.25.6
lints: ^6.0.0

View file

@ -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}$')));
});
});
}

View file

@ -0,0 +1,146 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:block2_spike/block2_spike.dart';
import 'package:test/test.dart';
/// Q3 (hardest piece) NIP-17 private messaging: does the gift-wrap flow work
/// end to end, stay unreadable to the relay, and hide the sender's identity?
void main() {
NostrKey keyFor(int fill) =>
NostrKey.deriveFromSeed(Uint8List(32)..fillRange(0, 32, fill));
group('NIP-44 encryption', () {
test('conversation key is symmetric (A→B == B→A)', () {
final a = keyFor(1);
final b = keyFor(2);
final ab = Nip44.conversationKey(a.privateKeyHex, b.publicKeyHex);
final ba = Nip44.conversationKey(b.privateKeyHex, a.publicKeyHex);
expect(ab, ba);
});
test('round-trips text, and a wrong key cannot decrypt', () {
final a = keyFor(1);
final b = keyFor(2);
final eve = keyFor(9);
final key = Nip44.conversationKey(a.privateKeyHex, b.publicKeyHex);
final payload = Nip44.encrypt(key, 'tengo tomate rosa, ¿te va bien?');
expect(Nip44.decrypt(key, payload), 'tengo tomate rosa, ¿te va bien?');
final eveKey = Nip44.conversationKey(eve.privateKeyHex, b.publicKeyHex);
expect(() => Nip44.decrypt(eveKey, payload), throwsFormatException);
});
test('ciphertext length hides the exact plaintext length (padding)', () {
final a = keyFor(1);
final b = keyFor(2);
final key = Nip44.conversationKey(a.privateKeyHex, b.publicKeyHex);
final short = base64.decode(Nip44.encrypt(key, 'hi')).length;
final alsoShort = base64.decode(Nip44.encrypt(key, 'hello!')).length;
// Both pad to the same 32-byte bucket equal payload sizes.
expect(short, alsoShort);
});
});
group('NIP-17 gift-wrapped DM over the relay', () {
late MiniRelay relay;
setUp(() async => relay = await MiniRelay.start());
tearDown(() async => relay.stop());
test('Alice → Bob: message arrives, sender is authenticated', () async {
final alice = keyFor(1);
final bob = keyFor(2);
final aliceConn = await NostrConnection.connect(
relay.url,
privateKeyHex: alice.privateKeyHex,
publicKeyHex: alice.publicKeyHex,
);
final bobConn = await NostrConnection.connect(
relay.url,
privateKeyHex: bob.privateKeyHex,
publicKeyHex: bob.publicKeyHex,
);
final aliceMsg = NostrMessageTransport(aliceConn);
final bobMsg = NostrMessageTransport(bobConn);
await aliceMsg.send(
toPubkey: bob.publicKeyHex,
text: '¿cambiamos semilla de calabaza?',
);
final inbox = await bobMsg.inboxUntilEose();
expect(inbox, hasLength(1));
expect(inbox.single.text, '¿cambiamos semilla de calabaza?');
// Authenticated as Alice, even though the wrap was signed by an ephemeral.
expect(inbox.single.fromPubkey, alice.publicKeyHex);
await aliceMsg.close();
await bobMsg.close();
});
test('the relay/eavesdropper sees neither plaintext nor the real sender',
() async {
final alice = keyFor(1);
final bob = keyFor(2);
final aliceConn = await NostrConnection.connect(
relay.url,
privateKeyHex: alice.privateKeyHex,
publicKeyHex: alice.publicKeyHex,
);
await NostrMessageTransport(aliceConn).send(
toPubkey: bob.publicKeyHex,
text: 'SECRET-PLAINTEXT-MARKER',
);
// Snoop the relay's stored events directly (as a relay operator would).
final snoop = await NostrConnection.connect(
relay.url,
privateKeyHex: keyFor(7).privateKeyHex,
publicKeyHex: keyFor(7).publicKeyHex,
);
final wraps = await snoop.reqOnce({'kinds': [1059]});
expect(wraps, hasLength(1));
final wire = jsonEncode(wraps.single.toJson());
expect(wire, isNot(contains('SECRET-PLAINTEXT-MARKER')));
// The wrap's author is the ephemeral key, NOT Alice.
expect(wraps.single.pubkey, isNot(alice.publicKeyHex));
// Only the recipient is addressable (the `p` tag), never the sender.
expect(wraps.single.tag('p'), bob.publicKeyHex);
await aliceConn.close();
await snoop.close();
});
test('a third party cannot unwrap a message not addressed to them',
() async {
final alice = keyFor(1);
final bob = keyFor(2);
final mallory = keyFor(5);
final aliceConn = await NostrConnection.connect(
relay.url,
privateKeyHex: alice.privateKeyHex,
publicKeyHex: alice.publicKeyHex,
);
await NostrMessageTransport(aliceConn).send(
toPubkey: bob.publicKeyHex,
text: 'solo para Bob',
);
// Mallory subscribes to 1059 with no p-filter and tries to unwrap.
final malloryConn = await NostrConnection.connect(
relay.url,
privateKeyHex: mallory.privateKeyHex,
publicKeyHex: mallory.publicKeyHex,
);
final malloryMsg = NostrMessageTransport(malloryConn);
final got = await malloryMsg.inboxUntilEose();
// Her inbox filters on her own `p` tag, so the relay never even hands her
// Bob's wrap; and the eavesdropper test proves that even raw snooping
// yields only ciphertext. Two layers, same outcome: she sees nothing.
expect(got, isEmpty, reason: 'not addressed to Mallory (p-tag scoping)');
await aliceConn.close();
await malloryConn.close();
});
});
}

View file

@ -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');
});
}

View file

@ -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();
});
}

View file

@ -0,0 +1,115 @@
import 'dart:typed_data';
import 'package:block2_spike/block2_spike.dart';
import 'package:test/test.dart';
/// Q4 (last big unknown) Duniter-style web of trust over Nostr, on the SAME
/// shared connection as offers and messaging. Proves certifications publish &
/// discover, the membership rule computes from discovered events, and trust can
/// filter offers (the network-trust §2 spam defence).
void main() {
NostrKey keyFor(int fill) =>
NostrKey.deriveFromSeed(Uint8List(32)..fillRange(0, 32, fill));
late MiniRelay relay;
setUp(() async => relay = await MiniRelay.start());
tearDown(() async => relay.stop());
Future<NostrConnection> connFor(NostrKey k) => NostrConnection.connect(
relay.url,
privateKeyHex: k.privateKeyHex,
publicKeyHex: k.publicKeyHex,
);
test('certifications publish, discover, and make a newcomer known', () async {
final seeds = [for (var i = 1; i <= 3; i++) keyFor(i)];
final newcomer = keyFor(50);
// Three seed members each certify the newcomer.
for (final s in seeds) {
final t = NostrTrustTransport(await connFor(s));
await t.certify(subjectPubkey: newcomer.publicKeyHex);
await t.close();
}
// Anyone can read the certs and compute membership.
final reader = NostrTrustTransport(await connFor(keyFor(99)));
final certifiers = await reader.certifiersOf(newcomer.publicKeyHex);
expect(certifiers, hasLength(3));
final wot = WebOfTrust.fromCertifications(
await reader.allCertifications(),
now: DateTime.now(),
);
final seedSet = seeds.map((s) => s.publicKeyHex).toSet();
expect(
wot.isMember(newcomer.publicKeyHex,
seeds: seedSet, threshold: 3, maxDistance: 5),
isTrue,
);
await reader.close();
});
test('revoking a certification drops the certifier (addressable replace)',
() async {
final alice = keyFor(1);
final bob = keyFor(2);
final aliceTrust = NostrTrustTransport(await connFor(alice));
await aliceTrust.certify(subjectPubkey: bob.publicKeyHex);
var certs = await aliceTrust.certifiersOf(bob.publicKeyHex);
expect(certs, contains(alice.publicKeyHex));
await aliceTrust.revoke(subjectPubkey: bob.publicKeyHex);
certs = await aliceTrust.certifiersOf(bob.publicKeyHex);
expect(certs, isNot(contains(alice.publicKeyHex)),
reason: 'revoked cert replaces the valid one and is filtered out');
await aliceTrust.close();
});
test('trust filters offers: a known author ranks; an unknown one is flagged',
() async {
// Bootstrap: 3 seeds certify Alice. Eve is uncertified (a stranger/spammer).
final seeds = [for (var i = 1; i <= 3; i++) keyFor(i)];
final alice = keyFor(20);
final eve = keyFor(66);
for (final s in seeds) {
final t = NostrTrustTransport(await connFor(s));
await t.certify(subjectPubkey: alice.publicKeyHex);
await t.close();
}
// Both Alice and Eve publish offers in the same area.
for (final who in [alice, eve]) {
final offers = NostrOfferTransport(await connFor(who));
await offers.publish(Offer(
id: 'o-${who.publicKeyHex.substring(0, 6)}',
authorPubkeyHex: who.publicKeyHex,
summary: 'Semilla',
type: OfferType.gift,
approxGeohash: 'sp3e9',
));
await offers.close();
}
// Bob discovers offers, then annotates each author with trust over the
// SAME shared connection (one socket, offers + trust both on top of it).
final bobConn = await connFor(keyFor(7));
final found = await NostrOfferTransport(bobConn)
.discoverUntilEose(const DiscoveryQuery(geohashPrefix: 'sp3e9'));
final trust = NostrTrustTransport(bobConn);
final wot = WebOfTrust.fromCertifications(
await trust.allCertifications(),
now: DateTime.now(),
);
final seedSet = seeds.map((s) => s.publicKeyHex).toSet();
bool known(String pk) =>
wot.isMember(pk, seeds: seedSet, threshold: 3, maxDistance: 5);
expect(found, hasLength(2));
expect(known(alice.publicKeyHex), isTrue);
expect(known(eve.publicKeyHex), isFalse,
reason: 'uncertified author stays "unknown" and is deprioritised');
await trust.close();
});
}

View file

@ -0,0 +1,92 @@
import 'package:block2_spike/block2_spike.dart';
import 'package:test/test.dart';
/// Pure web-of-trust rules (Duniter: N certs from members + within distance D).
/// This is the `commons_core`-worthy piece: deterministic, no I/O.
void main() {
final now = DateTime(2026, 7, 10);
Certification cert(String a, String b, {DateTime? expires, bool revoked = false}) =>
Certification(
issuer: a,
subject: b,
issuedAt: DateTime(2026),
expiresAt: expires,
revoked: revoked,
);
test('seeds are always members', () {
final wot = WebOfTrust.fromCertifications([], now: now);
expect(
wot.members(seeds: {'s1'}, threshold: 5, maxDistance: 5),
contains('s1'),
);
});
test('reaches the threshold from member certifiers → becomes known', () {
final seeds = {'s1', 's2', 's3', 's4', 's5'};
final certs = [for (final s in seeds) cert(s, 'newcomer')];
final wot = WebOfTrust.fromCertifications(certs, now: now);
final members = wot.members(seeds: seeds, threshold: 5, maxDistance: 5);
expect(members, contains('newcomer'));
});
test('below the threshold stays unknown', () {
final seeds = {'s1', 's2', 's3', 's4', 's5'};
final certs = [cert('s1', 'x'), cert('s2', 'x')]; // only 2 of 5
final wot = WebOfTrust.fromCertifications(certs, now: now);
expect(
wot.members(seeds: seeds, threshold: 5, maxDistance: 5),
isNot(contains('x')),
);
});
test('membership is transitive via a fixpoint (needs a to count for b)', () {
final seeds = {'s1', 's2'};
final certs = [
cert('s1', 'a'), cert('s2', 'a'), // a: 2 member certs member
cert('a', 'b'), cert('s2', 'b'), // b: certified by a (now member) + s2
];
final wot = WebOfTrust.fromCertifications(certs, now: now);
final members = wot.members(seeds: seeds, threshold: 2, maxDistance: 5);
expect(members, containsAll(['a', 'b']));
});
test('distance rule cuts off far-away nodes even if well-certified', () {
// s a b c d e chain; only s is a seed; threshold 1.
final seeds = {'s'};
final certs = [
cert('s', 'a'),
cert('a', 'b'),
cert('b', 'c'),
cert('c', 'd'),
cert('d', 'e'),
];
final wot = WebOfTrust.fromCertifications(certs, now: now);
final members = wot.members(seeds: seeds, threshold: 1, maxDistance: 2);
expect(members, containsAll(['a', 'b'])); // dist 1 and 2
expect(members, isNot(contains('c'))); // dist 3 > maxDistance
expect(members, isNot(contains('e')));
});
test('expired and revoked certifications do not count', () {
final seeds = {'s1', 's2', 's3', 's4', 's5'};
final certs = [
cert('s1', 'x'),
cert('s2', 'x'),
cert('s3', 'x'),
cert('s4', 'x', expires: DateTime(2020)), // expired
cert('s5', 'x', revoked: true), // revoked
];
final wot = WebOfTrust.fromCertifications(certs, now: now);
expect(wot.certifierCount('x'), 3, reason: 'expired+revoked dropped');
expect(
wot.members(seeds: seeds, threshold: 5, maxDistance: 5),
isNot(contains('x')),
);
});
test('self-certification is ignored', () {
final wot = WebOfTrust.fromCertifications([cert('a', 'a')], now: now);
expect(wot.certifierCount('a'), 0);
});
}