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.
This commit is contained in:
parent
6eb1517ffb
commit
49b872b405
16 changed files with 1284 additions and 0 deletions
204
docs/design/spike-block2-findings.md
Normal file
204
docs/design/spike-block2-findings.md
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
# Spike — Block 2 (social layer) de-risking findings
|
||||||
|
|
||||||
|
*Throwaway research spike. Language: English (findings), per code convention.
|
||||||
|
Code lives in [`spike/block2_spike/`](../../spike/block2_spike/) and is marked
|
||||||
|
throwaway — **not production, not on the pub workspace, no deps added to Block
|
||||||
|
1**. This document is the deliverable: what we learned, the risks, and a
|
||||||
|
recommendation on whether/how to take on the social round (Phase 3).*
|
||||||
|
|
||||||
|
> **Status: this is a spike, not a green light to build Block 2.** CLAUDE.md and
|
||||||
|
> [open-decisions.md](open-decisions.md) §D.1 are explicit: the social layer is
|
||||||
|
> big, indivisible, and needs its own funded round. This spike only lowers the
|
||||||
|
> unknowns before that round is committed.
|
||||||
|
|
||||||
|
## What the spike answered
|
||||||
|
|
||||||
|
The three open decisions from [open-decisions.md](open-decisions.md) §D.3 and
|
||||||
|
[g1-integration.md](g1-integration.md) "A decidir":
|
||||||
|
|
||||||
|
| # | Open question | Verdict |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Can we derive a Nostr (secp256k1) key from the Ğ1 root seed, one-way & reproducible? | **Yes — low risk.** |
|
||||||
|
| 2 | Does an `OfferTransport` abstraction hold, without leaking inventory/location? | **Yes — with a caveat on messaging/trust coupling.** |
|
||||||
|
| 3 | Does publish→discover-by-geohash actually work on Nostr NIP-99? | **Yes mechanically; NIP *maturity* is the real risk, not the flow.** |
|
||||||
|
|
||||||
|
All of it runs with tests. See "How to run" at the end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q1 — Identity derivation (secp256k1 from the Ğ1 root seed)
|
||||||
|
|
||||||
|
**Result: works, deterministic, one-way. This unblocks "one identity, one backup".**
|
||||||
|
|
||||||
|
- Implemented [`NostrKey.deriveFromSeed`](../../spike/block2_spike/lib/src/nostr_key.dart):
|
||||||
|
`HKDF-SHA256(rootSeed, info: "org.comunes.tane/nostr/secp256k1/v1")` → reduce
|
||||||
|
to a valid secp256k1 scalar `d ∈ [1, n-1]` by rejection (bump a counter in the
|
||||||
|
HKDF info on the astronomically-rare miss, so the result stays a pure function
|
||||||
|
of the seed) → BIP340 x-only pubkey → NIP-19 `npub`/`nsec`.
|
||||||
|
- The root seed is exactly what
|
||||||
|
[`IdentityService.generateRootSeed`](../../packages/commons_core/lib/src/identity/identity_service.dart)
|
||||||
|
already produces (32 bytes). The spike test feeds a real `IdentityService`
|
||||||
|
seed straight in — no shape mismatch.
|
||||||
|
- **Reproducibility:** same seed → identical private key, pubkey, and `npub`
|
||||||
|
(test `is reproducible`). The user backs up ONE thing (the seed QR); the
|
||||||
|
secp256k1 key regenerates on demand. Confirms the [g1-integration.md](g1-integration.md)
|
||||||
|
§"Resuelto" bet.
|
||||||
|
- **One-wayness:** derivation runs the seed through HMAC-SHA256, which is not
|
||||||
|
invertible; the seed never appears in the derived bytes, and unrelated seeds
|
||||||
|
never collide (tested). So publishing an `npub` cannot expose the Ğ1 identity
|
||||||
|
or the seed. (Simultaneous *use* of both identities can still be correlated by
|
||||||
|
an observer — that residual risk is unchanged and is what the "pseudonymous
|
||||||
|
key" advanced option in [g1-integration.md](g1-integration.md) §"peor caso"
|
||||||
|
exists for.)
|
||||||
|
- **The derived key really signs:** BIP340 sign+verify round-trips (test), i.e.
|
||||||
|
a real relay/peer would accept events from this key.
|
||||||
|
|
||||||
|
**Design decisions this firms up (for the social round):**
|
||||||
|
- Fix the derivation `info` string and version it (done: `…/v1`), the same
|
||||||
|
discipline `BackupBox` already uses. Changing it later would silently rotate
|
||||||
|
everyone's Nostr identity, so it is effectively part of `schemaVersion`.
|
||||||
|
- Derivation belongs in `commons_core` (identity is generic). In production it
|
||||||
|
should reuse the `cryptography` package's HKDF already vendored there, not the
|
||||||
|
spike's hand-rolled one.
|
||||||
|
|
||||||
|
**Residual risk: low.** The only real decision left is cosmetic/standardisation:
|
||||||
|
whether to align the derivation path with an existing convention (e.g. a
|
||||||
|
SLIP-0010/BIP32-style path or Cesium's "derive encryption key from signing key"
|
||||||
|
precedent) so other tools could reproduce it. Not a blocker — our scheme is
|
||||||
|
self-consistent and one-way today.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q2 — `OfferTransport` abstraction & the privacy seam
|
||||||
|
|
||||||
|
**Result: the abstraction holds and the privacy seam is real. But messaging and
|
||||||
|
trust do NOT fit behind the same interface — they share a *connection*, not a
|
||||||
|
*contract*.**
|
||||||
|
|
||||||
|
### The seam works (offers)
|
||||||
|
- [`OfferTransport`](../../spike/block2_spike/lib/src/offer_transport.dart) =
|
||||||
|
`publish(Offer)` / `discover(DiscoveryQuery)` / `retract(id)`. The Nostr
|
||||||
|
NIP-99 backend ([`NostrOfferTransport`](../../spike/block2_spike/lib/src/nostr_offer_transport.dart)
|
||||||
|
+ [`Nip99Codec`](../../spike/block2_spike/lib/src/nip99.dart)) sits entirely
|
||||||
|
behind it. A second backend (ActivityPub/FEP-0837) could replace it without
|
||||||
|
the domain noticing.
|
||||||
|
- [`Offer`](../../spike/block2_spike/lib/src/offer.dart) is agnostic by
|
||||||
|
construction: a chosen *summary* + coarse geohash, **no FK into seed tables,
|
||||||
|
no full inventory, no exact address** — exactly the Offer↔Lot split
|
||||||
|
[sharing-model.md](sharing-model.md) §2 and
|
||||||
|
[core-domain-boundary.md](core-domain-boundary.md) §4.3 call for.
|
||||||
|
|
||||||
|
### The privacy seam is enforced on the wire (tested)
|
||||||
|
- The codec **coarsens the geohash** to ≤5 chars (≈±2.4 km) before it ever hits
|
||||||
|
the wire, and emits a NIP-52 prefix *ladder* (`u`, `u0`, `u09`, …) so area
|
||||||
|
queries match by exact tag while nothing finer than the cap leaks.
|
||||||
|
- Tests assert, byte-wise on the serialised event, that a slipped-in precise
|
||||||
|
geohash and a secret inventory marker **never appear**, and that there is no
|
||||||
|
`location`/`address` tag. The seam has no field that *could* carry them —
|
||||||
|
privacy is structural, not a runtime check that can be forgotten.
|
||||||
|
|
||||||
|
### The caveat — coupling (this was the point of Q2)
|
||||||
|
[open-decisions.md](open-decisions.md) §D.3 worried Nostr couples messaging and
|
||||||
|
trust more than the plan implies. The spike confirms the worry is **real but
|
||||||
|
manageable**:
|
||||||
|
|
||||||
|
- Offers (NIP-99, kind 30402), **messaging** (NIP-17 DMs, kind 1059 gift-wrap),
|
||||||
|
and **trust** (NIP-85 / certifications) all reuse the *same derived key* and
|
||||||
|
the *same relay socket* (the OK-handshake, REQ/EOSE plumbing in
|
||||||
|
`NostrOfferTransport` is identical for all three).
|
||||||
|
- But their **verbs differ**: publish/browse a public listing vs. send/receive a
|
||||||
|
private encrypted DM vs. assert/read a signed certification. Forcing DMs and
|
||||||
|
certifications behind `OfferTransport.publish/discover` would overload it.
|
||||||
|
- **Recommendation:** in `commons_core`, model **one shared `NostrConnection`**
|
||||||
|
(socket, key, sign, req/sub lifecycle) with **three thin interfaces on top** —
|
||||||
|
`OfferTransport`, `MessageTransport`, `TrustTransport`. That matches how the
|
||||||
|
code actually factored in the spike and keeps each contract honest, while
|
||||||
|
admitting the reality that one protocol/key/relay underpins all three.
|
||||||
|
|
||||||
|
**Residual risk: medium.** The abstraction is right; the *scope* is the risk —
|
||||||
|
"the social layer" is genuinely offers + messaging + trust + relays together
|
||||||
|
(the §D.1 "indivisible" point), and this spike only built the offers third end
|
||||||
|
to end. Messaging (NIP-17 gift-wrap, offline delivery, spam) and trust (Duniter-
|
||||||
|
style WoT over Nostr) remain the large, unbuilt pieces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q3 — Publish → discover by geohash against a relay
|
||||||
|
|
||||||
|
**Result: the flow works end to end. The real risk is NIP maturity/ecosystem,
|
||||||
|
not the mechanics.**
|
||||||
|
|
||||||
|
- Built a hermetic in-process [`MiniRelay`](../../spike/block2_spike/lib/src/mini_relay.dart)
|
||||||
|
(NIP-01 subset: EVENT/REQ/EOSE/CLOSE, filter by `kinds`/`authors`/`#g`,
|
||||||
|
addressable-event replacement for kind 30402). No network → CI-safe, no flaky
|
||||||
|
public-relay dependency.
|
||||||
|
- End-to-end tests (`roundtrip_test.dart`):
|
||||||
|
- Alice publishes a gift offer; **Bob (a different derived identity) discovers
|
||||||
|
it by a coarser geohash** and gets the summary + Alice's pubkey back. ✅
|
||||||
|
- A query for a **different** area returns nothing (geohash scoping works). ✅
|
||||||
|
- **Re-publishing the same offer id replaces, not duplicates** (addressable
|
||||||
|
events — how a real relay behaves). ✅
|
||||||
|
- Latency measured: **~34 ms** publish+discover, in-process, cold (includes
|
||||||
|
the OK handshake + REQ/EOSE; a floor value — a real relay adds network RTT).
|
||||||
|
|
||||||
|
### On NIP maturity and the ActivityPub fallback
|
||||||
|
- **NIP-99 (classified listings, 30402):** stable enough and used in the wild
|
||||||
|
(e.g. Shopstr). Carries title/summary/price+currency/status of the box, so
|
||||||
|
gift/exchange/sale/wanted all fit; we add an `offer_type` tag for the
|
||||||
|
reciprocity mode NIP-99 lacks. **Low risk.**
|
||||||
|
- **NIP-17 (private DMs, gift-wrap):** newer, more moving parts (sealed +
|
||||||
|
gift-wrapped, per-message ephemeral keys). Viable but the **biggest unbuilt
|
||||||
|
risk** — this is where Q2's "messaging is large" bites. **Medium-high risk;
|
||||||
|
spike did not implement it.**
|
||||||
|
- **NIP-85 / WoT over Nostr:** least settled. The Duniter-style certification
|
||||||
|
model maps onto signed events, but there is no dominant standard. Likely a
|
||||||
|
**custom event kind mapped to the Duniter model** (as g1-integration.md §"WoT
|
||||||
|
propia pero compatible" already anticipates). **Higher risk; design work, not
|
||||||
|
just integration.**
|
||||||
|
- **ActivityPub / FEP-0837 as fallback:** worth keeping as the *second*
|
||||||
|
`OfferTransport` backend for reach/federation, but it does **not** solve the
|
||||||
|
hard parts (private DMs, WoT) any better than Nostr, and it adds instance
|
||||||
|
hosting. Recommendation: **stay Nostr-first**, keep the transport interface so
|
||||||
|
AP can be added later for offer federation, don't invest in AP now.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overall recommendation
|
||||||
|
|
||||||
|
1. **Q1 is done enough to lock.** Adopt seed→secp256k1 derivation as specified;
|
||||||
|
move it into `commons_core` (reusing the vendored HKDF) when the social round
|
||||||
|
starts. Version the `info` string as part of the schema.
|
||||||
|
2. **Q2: adopt the "one `NostrConnection`, three interfaces" shape.** Don't try
|
||||||
|
to make `OfferTransport` also carry messaging and trust. Keep `Offer`
|
||||||
|
agnostic and the geohash-coarsening seam exactly as prototyped.
|
||||||
|
3. **Q3: Nostr-first is the right bet, but scope honestly.** Offers are the easy
|
||||||
|
third and they work. **Messaging (NIP-17) and WoT (NIP-85/custom) are the
|
||||||
|
real weight and remain unproven here** — they, not the offer flow, should
|
||||||
|
drive the Phase-3 estimate and the funding ask ([open-decisions.md](open-decisions.md)
|
||||||
|
§D.1, §D.6).
|
||||||
|
4. **Do not start Block 2 from this spike.** Delete `spike/block2_spike/` once
|
||||||
|
these findings are absorbed. The next concrete step is a *funded* social
|
||||||
|
round that starts by building NIP-17 messaging as its own de-risking target,
|
||||||
|
since that is where the residual risk concentrates.
|
||||||
|
|
||||||
|
## Risk summary
|
||||||
|
|
||||||
|
| Area | Risk | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Seed→Nostr derivation | **Low** | Works, one-way, reproducible, tested. Only standardisation cosmetics left. |
|
||||||
|
| Offer transport + privacy seam | **Low** | Abstraction holds; leak-proofing is structural and tested. |
|
||||||
|
| Offer publish/discover (NIP-99) | **Low** | Mechanically proven; mature-ish NIP with real ecosystem. |
|
||||||
|
| Messaging (NIP-17) | **Medium-High** | Newer, complex, unbuilt; the big Phase-3 cost. |
|
||||||
|
| Web of trust (NIP-85/custom) | **High** | No settled standard; genuine design work. |
|
||||||
|
| Overall scope (§D.1) | **High** | Social layer is indivisible & large; only 1/3 proven here. |
|
||||||
|
|
||||||
|
## How to run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd spike/block2_spike
|
||||||
|
dart pub get
|
||||||
|
dart test # derivation_test.dart, privacy_test.dart, roundtrip_test.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing here is wired into `app_seeds` or `commons_core`'s production graph; the
|
||||||
|
Block 1 suite is untouched.
|
||||||
3
spike/block2_spike/.gitignore
vendored
Normal file
3
spike/block2_spike/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Throwaway spike — don't commit build artifacts or the resolved lockfile.
|
||||||
|
.dart_tool/
|
||||||
|
pubspec.lock
|
||||||
12
spike/block2_spike/lib/block2_spike.dart
Normal file
12
spike/block2_spike/lib/block2_spike.dart
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
/// THROWAWAY Block 2 de-risking spike. Not production; not on the pub workspace.
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'src/bech32.dart';
|
||||||
|
export 'src/hkdf.dart';
|
||||||
|
export 'src/mini_relay.dart';
|
||||||
|
export 'src/nip99.dart';
|
||||||
|
export 'src/nostr_event.dart';
|
||||||
|
export 'src/nostr_key.dart';
|
||||||
|
export 'src/nostr_offer_transport.dart';
|
||||||
|
export 'src/offer.dart';
|
||||||
|
export 'src/offer_transport.dart';
|
||||||
54
spike/block2_spike/lib/src/bech32.dart
Normal file
54
spike/block2_spike/lib/src/bech32.dart
Normal 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()}';
|
||||||
|
}
|
||||||
32
spike/block2_spike/lib/src/hkdf.dart
Normal file
32
spike/block2_spike/lib/src/hkdf.dart
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
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));
|
||||||
|
}
|
||||||
124
spike/block2_spike/lib/src/mini_relay.dart
Normal file
124
spike/block2_spike/lib/src/mini_relay.dart
Normal 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 publish→discover 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 30000–39999, 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
118
spike/block2_spike/lib/src/nip99.dart
Normal file
118
spike/block2_spike/lib/src/nip99.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
77
spike/block2_spike/lib/src/nostr_event.dart
Normal file
77
spike/block2_spike/lib/src/nostr_event.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
77
spike/block2_spike/lib/src/nostr_key.dart
Normal file
77
spike/block2_spike/lib/src/nostr_key.dart
Normal 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;
|
||||||
|
}
|
||||||
152
spike/block2_spike/lib/src/nostr_offer_transport.dart
Normal file
152
spike/block2_spike/lib/src/nostr_offer_transport.dart
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'nip99.dart';
|
||||||
|
import 'nostr_event.dart';
|
||||||
|
import 'offer.dart';
|
||||||
|
import 'offer_transport.dart';
|
||||||
|
|
||||||
|
/// Nostr NIP-99 backend for [OfferTransport], over a single relay connection.
|
||||||
|
///
|
||||||
|
/// Signs every offer with the caller's derived Nostr key (Q1) and publishes it
|
||||||
|
/// as a kind-30402 classified listing (Q2/Q3). Discovery is a REQ filtered by
|
||||||
|
/// kind + `#g` geohash tag. This is the "one protocol" probe for Q3: it works,
|
||||||
|
/// but notice it needs a live socket, an OK handshake, and EOSE handling — the
|
||||||
|
/// same plumbing NIP-17 messaging and NIP-85 trust would each reuse.
|
||||||
|
class NostrOfferTransport implements OfferTransport {
|
||||||
|
NostrOfferTransport._(this._socket, this._privateKeyHex, this._pubkeyHex) {
|
||||||
|
_socket.listen(_onData, onDone: _onDone);
|
||||||
|
}
|
||||||
|
|
||||||
|
final WebSocket _socket;
|
||||||
|
final String _privateKeyHex;
|
||||||
|
final String _pubkeyHex;
|
||||||
|
final Nip99Codec _codec = Nip99Codec();
|
||||||
|
|
||||||
|
final _incoming = StreamController<List<dynamic>>.broadcast();
|
||||||
|
int _subCounter = 0;
|
||||||
|
|
||||||
|
/// Connects to [relayUrl] and returns a transport signing as [pubkeyHex]
|
||||||
|
/// (BIP340 x-only) with [privateKeyHex]. Both come from `NostrKey`.
|
||||||
|
static Future<NostrOfferTransport> connect(
|
||||||
|
String relayUrl, {
|
||||||
|
required String privateKeyHex,
|
||||||
|
required String pubkeyHex,
|
||||||
|
}) async {
|
||||||
|
final socket = await WebSocket.connect(relayUrl);
|
||||||
|
return NostrOfferTransport._(socket, privateKeyHex, pubkeyHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onData(dynamic data) => _incoming.add(jsonDecode(data as String) as List);
|
||||||
|
void _onDone() {
|
||||||
|
if (!_incoming.isClosed) _incoming.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PublishResult> publish(Offer offer) async {
|
||||||
|
final event = _codec.encode(
|
||||||
|
offer,
|
||||||
|
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
);
|
||||||
|
event.signWith(_privateKeyHex);
|
||||||
|
|
||||||
|
final ok = _incoming.stream.firstWhere(
|
||||||
|
(m) => m[0] == 'OK' && m[1] == event.id,
|
||||||
|
);
|
||||||
|
_socket.add(jsonEncode(['EVENT', event.toJson()]));
|
||||||
|
final response = await ok.timeout(const Duration(seconds: 5));
|
||||||
|
return PublishResult(
|
||||||
|
accepted: response[2] as bool,
|
||||||
|
transportRef: event.id,
|
||||||
|
message: response.length > 3 ? response[3] as String : '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<Offer> discover(DiscoveryQuery query) {
|
||||||
|
final subId = 'sub${_subCounter++}';
|
||||||
|
final filter = <String, dynamic>{
|
||||||
|
'kinds': [Nip99Codec.kindActive],
|
||||||
|
'#g': [query.geohashPrefix],
|
||||||
|
'limit': query.limit,
|
||||||
|
};
|
||||||
|
|
||||||
|
late StreamController<Offer> controller;
|
||||||
|
controller = StreamController<Offer>(
|
||||||
|
onListen: () => _socket.add(jsonEncode(['REQ', subId, filter])),
|
||||||
|
onCancel: () {
|
||||||
|
_socket.add(jsonEncode(['CLOSE', subId]));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final sub = _incoming.stream.listen((m) {
|
||||||
|
if (m[0] == 'EVENT' && m[1] == subId) {
|
||||||
|
final offer = _codec.decode(
|
||||||
|
NostrEvent.fromJson(m[2] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
if (query.types.isEmpty || query.types.contains(offer.type)) {
|
||||||
|
controller.add(offer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// EOSE arrives here too; we keep the stream open for live offers.
|
||||||
|
});
|
||||||
|
controller.onCancel = () {
|
||||||
|
_socket.add(jsonEncode(['CLOSE', subId]));
|
||||||
|
sub.cancel();
|
||||||
|
};
|
||||||
|
return controller.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience for tests/metrics: collect matches up to EOSE, then stop.
|
||||||
|
Future<List<Offer>> discoverUntilEose(DiscoveryQuery query) async {
|
||||||
|
final subId = 'once${_subCounter++}';
|
||||||
|
final filter = <String, dynamic>{
|
||||||
|
'kinds': [Nip99Codec.kindActive],
|
||||||
|
'#g': [query.geohashPrefix],
|
||||||
|
'limit': query.limit,
|
||||||
|
};
|
||||||
|
final results = <Offer>[];
|
||||||
|
final done = Completer<void>();
|
||||||
|
final sub = _incoming.stream.listen((m) {
|
||||||
|
if (m[1] != subId) return;
|
||||||
|
if (m[0] == 'EVENT') {
|
||||||
|
final offer = _codec.decode(
|
||||||
|
NostrEvent.fromJson(m[2] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
if (query.types.isEmpty || query.types.contains(offer.type)) {
|
||||||
|
results.add(offer);
|
||||||
|
}
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> retract(String offerId) async {
|
||||||
|
// NIP-09 deletion request referencing the addressable coordinate.
|
||||||
|
final event = NostrEvent(
|
||||||
|
pubkey: _pubkeyHex,
|
||||||
|
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
kind: 5,
|
||||||
|
tags: [
|
||||||
|
['a', '${Nip99Codec.kindActive}:$_pubkeyHex:$offerId'],
|
||||||
|
],
|
||||||
|
content: 'offer retracted',
|
||||||
|
);
|
||||||
|
event.signWith(_privateKeyHex);
|
||||||
|
_socket.add(jsonEncode(['EVENT', event.toJson()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() async {
|
||||||
|
await _socket.close();
|
||||||
|
if (!_incoming.isClosed) await _incoming.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
90
spike/block2_spike/lib/src/offer.dart
Normal file
90
spike/block2_spike/lib/src/offer.dart
Normal 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;
|
||||||
|
}
|
||||||
29
spike/block2_spike/lib/src/offer_transport.dart
Normal file
29
spike/block2_spike/lib/src/offer_transport.dart
Normal 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();
|
||||||
|
}
|
||||||
24
spike/block2_spike/pubspec.yaml
Normal file
24
spike/block2_spike/pubspec.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
test: ^1.25.6
|
||||||
|
lints: ^6.0.0
|
||||||
76
spike/block2_spike/test/derivation_test.dart
Normal file
76
spike/block2_spike/test/derivation_test.dart
Normal 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}$')));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
80
spike/block2_spike/test/privacy_test.dart
Normal file
80
spike/block2_spike/test/privacy_test.dart
Normal 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');
|
||||||
|
});
|
||||||
|
}
|
||||||
132
spike/block2_spike/test/roundtrip_test.dart
Normal file
132
spike/block2_spike/test/roundtrip_test.dart
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue