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

@ -0,0 +1,34 @@
import 'package:commons_core/commons_core.dart';
import 'package:test/test.dart';
void main() {
group('npubToHex', () {
// The canonical NIP-19 example pair.
const npub =
'npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6';
const hex =
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d';
test('decodes a bech32 npub to its hex public key', () {
expect(npubToHex(npub), hex);
});
test('passes a 64-char hex key through (lower-cased)', () {
expect(npubToHex(hex), hex);
expect(npubToHex(hex.toUpperCase()), hex);
});
test('trims surrounding whitespace', () {
expect(npubToHex(' $npub '), hex);
});
test('rejects an nsec (secret key), not a public identity', () {
expect(() => npubToHex('nsec1' 'garbage'), throwsFormatException);
});
test('rejects nonsense', () {
expect(() => npubToHex('not-a-key'), throwsFormatException);
expect(() => npubToHex(''), throwsFormatException);
});
});
}

View file

@ -79,4 +79,28 @@ void main() {
final wot = WebOfTrust.fromCertifications([cert('a', 'a')], now: now);
expect(wot.certifierCount('a'), 0);
});
group('WotParams', () {
test('Duniter defaults are sigQty 5, stepMax 5, 1-year validity', () {
const p = WotParams.duniter;
expect(p.sigQty, 5);
expect(p.stepMax, 5);
expect(p.sigValidity, const Duration(days: 365));
});
test('membersWith uses sigQty/stepMax like the raw rule', () {
final seeds = {'s1', 's2', 's3'};
final certs = [for (final s in seeds) cert(s, 'newcomer')];
final wot = WebOfTrust.fromCertifications(certs, now: now);
const params = WotParams(
sigQty: 3, stepMax: 5, sigValidity: Duration(days: 365));
expect(wot.membersWith(seeds: seeds, params: params),
contains('newcomer'));
// One short of sigQty not a member.
const strict = WotParams(
sigQty: 4, stepMax: 5, sigValidity: Duration(days: 365));
expect(wot.membersWith(seeds: seeds, params: strict),
isNot(contains('newcomer')));
});
});
}