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

@ -5,38 +5,72 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../services/social_service.dart';
import '../services/social_settings.dart';
/// Trust standing of one peer: how many people vouch for them, and whether YOU
/// do. Kept simple on purpose the full "known member" rule (threshold +
/// distance) and its bootstrap set are a policy decision still open
/// (network-trust.md §2). Vouching + a live count is the useful, honest first
/// step.
/// Where a peer stands, from strongest to weakest signal.
enum TrustTier {
/// Passes the full Duniter membership rule (sigQty certifications from members,
/// within stepMax of a bootstrap referent).
networkMember,
/// In your personal circle (you vouch, or a friend-of-a-friend does).
inYourCircle,
/// Vouched for by someone, but not (yet) a member nor in your circle.
vouched,
/// No certifications seen.
unknown,
}
/// Trust standing of one peer: the full Duniter membership verdict, your own
/// circle, and the raw certifier count computed from the public certification
/// graph with the active [WotParams] and bootstrap referents.
class TrustState extends Equatable {
const TrustState({
this.certifierCount = 0,
this.iVouch = false,
this.knownToYou = false,
this.isNetworkMember = false,
this.networkBootstrapped = false,
this.loading = true,
this.busy = false,
});
/// How many people (network-wide) vouch for the peer.
/// How many people (network-wide) currently vouch for the peer.
final int certifierCount;
/// Whether this user vouches for the peer.
final bool iVouch;
/// Whether the peer is within the user's own circle of trust — you vouch for
/// them, or someone you vouch for does (friend-of-a-friend). This is the
/// meaningful signal; a raw network count invites spam.
/// Whether the peer is within the user's own circle (you, or a
/// friend-of-a-friend, vouch). Spam-resistant and works from day one.
final bool knownToYou;
/// Whether the peer is a full member per the Duniter rule (sigQty + stepMax
/// from the bootstrap referents).
final bool isNetworkMember;
/// Whether any bootstrap referents exist at all. When false, network
/// membership can't be determined yet (the trust net isn't seeded) and the UI
/// should say so instead of implying the peer failed the rule.
final bool networkBootstrapped;
final bool loading;
final bool busy;
/// The strongest applicable signal, for the badge.
TrustTier get tier {
if (isNetworkMember) return TrustTier.networkMember;
if (knownToYou) return TrustTier.inYourCircle;
if (certifierCount > 0) return TrustTier.vouched;
return TrustTier.unknown;
}
TrustState copyWith({
int? certifierCount,
bool? iVouch,
bool? knownToYou,
bool? isNetworkMember,
bool? networkBootstrapped,
bool? loading,
bool? busy,
}) =>
@ -44,21 +78,34 @@ class TrustState extends Equatable {
certifierCount: certifierCount ?? this.certifierCount,
iVouch: iVouch ?? this.iVouch,
knownToYou: knownToYou ?? this.knownToYou,
isNetworkMember: isNetworkMember ?? this.isNetworkMember,
networkBootstrapped: networkBootstrapped ?? this.networkBootstrapped,
loading: loading ?? this.loading,
busy: busy ?? this.busy,
);
@override
List<Object?> get props =>
[certifierCount, iVouch, knownToYou, loading, busy];
List<Object?> get props => [
certifierCount,
iVouch,
knownToYou,
isNetworkMember,
networkBootstrapped,
loading,
busy,
];
}
/// Reads and toggles this user's vouch for [peerPubkey] over a [TrustTransport].
/// Reads and toggles this user's vouch for [peerPubkey] over a [TrustTransport],
/// and computes the full Duniter membership verdict against the bootstrap
/// [referents] and active [params].
class TrustCubit extends Cubit<TrustState> {
TrustCubit(
this._transport, {
required this.peerPubkey,
required this.selfPubkey,
this.referents = const {},
this.params = WotParams.duniter,
Future<void> Function()? onDispose,
}) : _onDispose = onDispose,
super(const TrustState());
@ -66,17 +113,26 @@ class TrustCubit extends Cubit<TrustState> {
final TrustTransport? _transport;
final String peerPubkey;
final String selfPubkey;
/// Bootstrap referents (Duniter seeds) the membership rule trusts by
/// construction. Empty = the network isn't seeded yet.
final Set<String> referents;
/// Active web-of-trust parameters (sigQty / stepMax / validity).
final WotParams params;
final Future<void> Function()? _onDispose;
bool get isOnline => _transport != null;
/// Bootstrap "circle" rule: one vouch from your side, out to a friend-of-a-
/// friend. Deliberately loose (the full member policy threshold/distance
/// is still open, network-trust.md §2); this is just "people near you".
/// Personal "circle" rule: one vouch from your side, out to a friend-of-a-
/// friend. Loose by design "people near you", separate from formal
/// membership.
static const _circleThreshold = 1;
static const _circleDistance = 2;
/// Loads the peer's certifiers and whether they're within your circle.
/// Loads the certification graph and computes: the peer's certifier count,
/// whether you vouch, whether they're in your circle, and whether they pass
/// the full Duniter membership rule against the bootstrap referents.
Future<void> load() async {
final transport = _transport;
if (transport == null) {
@ -94,15 +150,23 @@ class TrustCubit extends Cubit<TrustState> {
threshold: _circleThreshold,
maxDistance: _circleDistance,
);
// Full membership: only meaningful once the trust net has referents.
final members = referents.isEmpty
? const <String>{}
: wot.membersWith(seeds: referents, params: params);
emit(state.copyWith(
certifierCount: certifiers.length,
iVouch: certifiers.contains(selfPubkey),
knownToYou: circle.contains(peerPubkey),
isNetworkMember: members.contains(peerPubkey),
networkBootstrapped: referents.isNotEmpty,
loading: false,
));
}
/// Adds or removes this user's vouch, then reloads. Never vouches for self.
/// The certification is issued with the active validity so it expires and
/// must be renewed (Duniter rule).
Future<void> toggleVouch() async {
final transport = _transport;
if (transport == null || peerPubkey == selfPubkey) return;
@ -110,7 +174,10 @@ class TrustCubit extends Cubit<TrustState> {
if (state.iVouch) {
await transport.revoke(subjectPubkey: peerPubkey);
} else {
await transport.certify(subjectPubkey: peerPubkey);
await transport.certify(
subjectPubkey: peerPubkey,
validity: params.sigValidity,
);
}
await load();
emit(state.copyWith(busy: false));