From cc88f3d688d36d8d1a7377a8cd3876790a73f720 Mon Sep 17 00:00:00 2001 From: vjrj Date: Fri, 10 Jul 2026 02:17:35 +0200 Subject: [PATCH] =?UTF-8?q?spike(block2):=20de-risk=20web=20of=20trust=20?= =?UTF-8?q?=E2=80=94=20the=20last=20big=20unknown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/design/spike-block2-findings.md | 95 ++++++++++---- spike/block2_spike/lib/block2_spike.dart | 3 + .../lib/src/nostr_trust_transport.dart | 97 ++++++++++++++ .../block2_spike/lib/src/trust_transport.dart | 28 +++++ spike/block2_spike/lib/src/web_of_trust.dart | 119 ++++++++++++++++++ spike/block2_spike/test/trust_test.dart | 115 +++++++++++++++++ .../block2_spike/test/web_of_trust_test.dart | 92 ++++++++++++++ 7 files changed, 522 insertions(+), 27 deletions(-) create mode 100644 spike/block2_spike/lib/src/nostr_trust_transport.dart create mode 100644 spike/block2_spike/lib/src/trust_transport.dart create mode 100644 spike/block2_spike/lib/src/web_of_trust.dart create mode 100644 spike/block2_spike/test/trust_test.dart create mode 100644 spike/block2_spike/test/web_of_trust_test.dart diff --git a/docs/design/spike-block2-findings.md b/docs/design/spike-block2-findings.md index 10a37de..dbd8ec2 100644 --- a/docs/design/spike-block2-findings.md +++ b/docs/design/spike-block2-findings.md @@ -22,8 +22,11 @@ The three open decisions from [open-decisions.md](open-decisions.md) §D.3 and | 2 | Does an `OfferTransport` abstraction hold, without leaking inventory/location? | **Yes — and the "one connection, three interfaces" shape is now demonstrated in code.** | | 3 | Does publish→discover-by-geohash actually work on Nostr NIP-99? | **Yes mechanically; NIP *maturity* is the real risk, not the flow.** | | 3b | Does NIP-17 private, metadata-hiding messaging actually work? | **Yes — prototyped end to end. Risk drops from "unbuilt unknown" to "production hardening".** | +| 4 | Does a Duniter-style web of trust work over Nostr, and can it filter spam? | **Yes — certify/discover + threshold-and-distance membership + offer filtering all prototyped.** | -All of it runs with tests (20 passing). See "How to run" at the end. +All of it runs with tests (30 passing). All three transport contracts — +offers, messaging, trust — plus the pure trust-graph logic are exercised. +See "How to run" at the end. --- @@ -112,21 +115,22 @@ manageable**: certifications behind `OfferTransport.publish/discover` would overload it. - **Recommendation — now demonstrated in code, not just asserted:** [`NostrConnection`](../../spike/block2_spike/lib/src/nostr_connection.dart) is - **one shared** socket + key + sign + REQ/EOSE lifecycle, and **two thin - interfaces already sit on top of the same instance** — + **one shared** socket + key + sign + REQ/EOSE lifecycle, and **all three thin + interfaces sit on top of the same instance** — [`OfferTransport`](../../spike/block2_spike/lib/src/nostr_offer_transport.dart) - (NIP-99) and + (NIP-99), [`MessageTransport`](../../spike/block2_spike/lib/src/nostr_message_transport.dart) - (NIP-17). A third, `TrustTransport` (WoT), would slot in the same way. This is - the shape to lift into `commons_core`: shared connection, per-concern - contract. Trying to make `OfferTransport` also carry DMs would have overloaded - it — the split is real. + (NIP-17) and + [`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 + DMs and certifications would have overloaded it — the split is real. -**Residual risk: medium → low-medium.** The abstraction is right *and* two of -the three contracts are now built. The remaining risk is **scope, not -feasibility**: "the social layer" is offers + messaging + trust + relays -together (§D.1 "indivisible"), and trust/WoT plus production hardening of -messaging (below) are still ahead. +**Residual risk: low.** The abstraction is right and all three contracts are +built. The remaining risk is **scope/hardening, not feasibility**: "the social +layer" is offers + messaging + trust + relays together (§D.1 "indivisible"), and +that whole happy path now runs — what's left is production hardening (below). --- @@ -209,6 +213,39 @@ coupling to Nostr. So the spike built it rather than hand-waving: large" weight. - **Spam/abuse** on DMs leans on the (unbuilt) web of trust. +## Q4 — Web of trust (the last big unknown) + +**Result: a Duniter-style WoT maps cleanly onto Nostr, computes correctly from +discovered events, and filters spam. Built and tested.** + +There is no settled NIP for a web of trust, so — exactly as +[g1-integration.md](g1-integration.md) §"WoT propia pero compatible" +anticipates — the spike models **its own WoT, Duniter-compatible**, over Nostr: + +- **Certifications as events** + ([`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`](../../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 + others over the line). Tests cover threshold, distance cut-off, transitive + growth, and exclusion of expired/revoked/self certs. +- **It filters spam (the payoff, network-trust.md §2):** in a test, three seed + members certify Alice; Eve stays uncertified. Bob discovers both their offers, + then annotates authors — **Alice is "known", Eve stays "unknown"** and gets + deprioritised. Trust and offers run **on the same shared connection**, closing + the loop between Q2's architecture and this filter. + +**Residual risk: high → medium.** Feasibility is proven; what's left is *policy +and bootstrap*, not code: choosing sigQty/stepMax/expiry (network-trust.md §2 +"a decidir"), the cold-start referent set (fairs, Ğ1 seed groups — +[g1-integration.md](g1-integration.md)), whether a collective can certify as an +entity, and optionally importing the on-chain Ğ1 WoT (level 3) as a trust source. + ## Overall recommendation 1. **Q1 is done enough to lock.** Adopt seed→secp256k1 derivation as specified; @@ -217,17 +254,21 @@ coupling to Nostr. So the spike built it rather than hand-waving: 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, and now two of three contracts are - proven.** Offers (NIP-99) and private messaging (NIP-17) both work on the - shared connection. The remaining unproven weight is **WoT/trust - (NIP-85/custom)** plus **production hardening of messaging** (interop-exact - NIP-44, offline delivery, multi-device) — that, not the happy-path 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 de-risking target is the **web - of trust** (Duniter-style certifications mapped to Nostr events) — the last - large unknown — followed by hardening messaging for real-world delivery. +3. **Q3/Q4: Nostr-first is the right bet, and all three contracts are now + proven.** Offers (NIP-99), private messaging (NIP-17) and the web of trust + (custom, Duniter-compatible) all work on the **same shared connection** — the + happy path of the entire social layer runs end to end in this spike. What + remains is **not feasibility but hardening and policy**: interop-exact NIP-44, + offline/multi-device delivery, and the WoT parameters + cold-start + ([open-decisions.md](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). 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 + on re-proving the happy path. ## Risk summary @@ -237,15 +278,15 @@ coupling to Nostr. So the spike built it rather than hand-waving: | Offer transport + privacy seam | **Low** | Abstraction holds; leak-proofing is structural and tested. | | Offer publish/discover (NIP-99) | **Low** | Mechanically proven; mature-ish NIP with real ecosystem. | | Messaging (NIP-17) | **Medium** | Flow + metadata-privacy prototyped & tested. Left: interop-exact NIP-44, offline delivery, multi-device. | -| Web of trust (NIP-85/custom) | **High** | No settled standard; genuine design work. The last big unknown. | -| Overall scope (§D.1) | **Medium-High** | Social layer is indivisible & large; 2 of 3 transport contracts now proven, WoT + hardening remain. | +| Web of trust (custom, Ğ1-compatible) | **Medium** | Feasibility proven (certs + membership rule + spam filter tested). Left: parameters (sigQty/stepMax/expiry) + cold-start, not code. | +| Overall scope (§D.1) | **Medium** | Social layer is indivisible & large, but the whole happy path now runs end to end; what remains is hardening + policy, not unknowns. | ## How to run ```sh cd spike/block2_spike dart pub get -dart test # derivation · privacy · roundtrip · messaging (20 tests) +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 diff --git a/spike/block2_spike/lib/block2_spike.dart b/spike/block2_spike/lib/block2_spike.dart index 7fca384..5b8a346 100644 --- a/spike/block2_spike/lib/block2_spike.dart +++ b/spike/block2_spike/lib/block2_spike.dart @@ -12,5 +12,8 @@ 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'; diff --git a/spike/block2_spike/lib/src/nostr_trust_transport.dart b/spike/block2_spike/lib/src/nostr_trust_transport.dart new file mode 100644 index 0000000..371f869 --- /dev/null +++ b/spike/block2_spike/lib/src/nostr_trust_transport.dart @@ -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 certify({ + required String subjectPubkey, + Duration validity = const Duration(days: 365), + String note = '', + }) => + _publishCert(subjectPubkey, note: note, validity: validity, revoked: false); + + @override + Future revoke({required String subjectPubkey}) => + _publishCert(subjectPubkey, note: '', validity: Duration.zero, revoked: true); + + Future _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> allCertifications() async { + final events = await _conn.reqOnce({ + 'kinds': [kindCertification], + }); + return events.map(_toCertification).toList(); + } + + @override + Future> 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 close() => _conn.close(); +} diff --git a/spike/block2_spike/lib/src/trust_transport.dart b/spike/block2_spike/lib/src/trust_transport.dart new file mode 100644 index 0000000..e99be98 --- /dev/null +++ b/spike/block2_spike/lib/src/trust_transport.dart @@ -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 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 revoke({required String subjectPubkey}); + + /// All certifications currently visible on the relay (to build a [WebOfTrust]). + Future> allCertifications(); + + /// Distinct, currently-valid certifiers of [subjectPubkey]. + Future> certifiersOf(String subjectPubkey); + + Future close(); +} diff --git a/spike/block2_spike/lib/src/web_of_trust.dart b/spike/block2_spike/lib/src/web_of_trust.dart new file mode 100644 index 0000000..8fbcafa --- /dev/null +++ b/spike/block2_spike/lib/src/web_of_trust.dart @@ -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> _out = {}; // issuer -> subjects + final Map> _in = {}; // subject -> issuers + + /// Builds a graph from valid certifications as of [now] (drops expired / + /// revoked, ignores self-certifications). + factory WebOfTrust.fromCertifications( + Iterable 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 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 members({ + required Set 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 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 _distancesFromSeeds(Set seeds) { + final dist = {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 {}) { + if (!dist.containsKey(next)) { + dist[next] = d + 1; + queue.add(next); + } + } + } + return dist; + } +} diff --git a/spike/block2_spike/test/trust_test.dart b/spike/block2_spike/test/trust_test.dart new file mode 100644 index 0000000..4b3e371 --- /dev/null +++ b/spike/block2_spike/test/trust_test.dart @@ -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 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(); + }); +} diff --git a/spike/block2_spike/test/web_of_trust_test.dart b/spike/block2_spike/test/web_of_trust_test.dart new file mode 100644 index 0000000..2deb8e6 --- /dev/null +++ b/spike/block2_spike/test/web_of_trust_test.dart @@ -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); + }); +}