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.
This commit is contained in:
vjrj 2026-07-10 21:01:11 +02:00
parent a96049dd36
commit 4cf53f259f
29 changed files with 1111 additions and 39 deletions

View file

@ -14,6 +14,7 @@ export 'src/ids/id_gen.dart';
export 'src/social/certification.dart';
export 'src/social/geohash.dart';
export 'src/social/message_transport.dart';
export 'src/social/nostr_ids.dart';
export 'src/social/nostr/nostr_channel.dart';
export 'src/social/nostr/nostr_connection.dart';
export 'src/social/nostr/nostr_message_transport.dart';

View file

@ -0,0 +1,21 @@
import 'package:nostr/nostr.dart';
/// Decodes a NIP-19 `npub` into its 64-char hex public key.
///
/// Accepts an already-hex key unchanged (so callers can take either form), and
/// throws [FormatException] on anything that is neither. Used to add web-of-trust
/// referents shared as an `npub` (or scanned from a QR).
String npubToHex(String input) {
final value = input.trim();
if (_isHex64(value)) return value.toLowerCase();
if (!value.startsWith('npub1')) {
throw const FormatException('not an npub or hex public key');
}
final decoded = Nip19.decode(payload: value);
if (decoded.prefix != Nip19Prefix.npub || !_isHex64(decoded.data)) {
throw const FormatException('not an npub public key');
}
return decoded.data.toLowerCase();
}
bool _isHex64(String s) => RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(s);

View file

@ -1,5 +1,44 @@
import 'package:equatable/equatable.dart';
import 'certification.dart';
/// The Duniter/Ğ1 web-of-trust parameters. Kept as data (not magic numbers) so
/// they can be persisted and tuned as the network grows a young network needs
/// looser values than a mature one, exactly as Ğ1 itself started.
///
/// - [sigQty]: certifications from existing members needed to become a member.
/// - [stepMax]: max certification hops from a bootstrap referent (seed).
/// - [sigValidity]: how long a certification counts before it must be renewed.
class WotParams extends Equatable {
const WotParams({
required this.sigQty,
required this.stepMax,
required this.sigValidity,
});
/// The Ğ1 reference values (sigQty 5, stepMax 5, ~1-year validity Ğ1 uses a
/// longer window, but the seed app renews yearly to match `certify`'s default).
static const duniter = WotParams(
sigQty: 5,
stepMax: 5,
sigValidity: Duration(days: 365),
);
final int sigQty;
final int stepMax;
final Duration sigValidity;
WotParams copyWith({int? sigQty, int? stepMax, Duration? sigValidity}) =>
WotParams(
sigQty: sigQty ?? this.sigQty,
stepMax: stepMax ?? this.stepMax,
sigValidity: sigValidity ?? this.sigValidity,
);
@override
List<Object?> get props => [sigQty, stepMax, sigValidity];
}
/// Pure web-of-trust computation (Duniter/Ğ1 rules), Flutter-free and I/O-free.
/// Answers "who counts as a known member" from certification edges, using two
/// rules: **N certifications from existing members** (sigQty) and **within
@ -76,6 +115,17 @@ class WebOfTrust {
members(seeds: seeds, threshold: threshold, maxDistance: maxDistance)
.contains(pubkey);
/// Members using a [WotParams] bundle (Duniter naming: sigQty/stepMax).
Set<String> membersWith({
required Set<String> seeds,
required WotParams params,
}) =>
members(
seeds: seeds,
threshold: params.sigQty,
maxDistance: params.stepMax,
);
/// 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};