feat(block2): 'in your circle' trust — friend-of-a-friend, not a raw count

A network-wide 'N people vouch' count invites spam and means little. Now the
trust banner computes whether the peer is within YOUR circle — you vouch for
them, or someone you vouch for does (friend-of-a-friend) — via the pure
WebOfTrust rule (seeds={you}, threshold 1, distance 2). When known, the banner
turns green with 'In your circle' (else it falls back to the count).

The full member policy (threshold/distance/bootstrap) stays open (network-trust
§2); this is a deliberately loose 'people near you' heuristic.

Analyzer clean; trust cubit test adds a friend-of-a-friend vs stranger case
(run flutter test locally to confirm — the widget suite is too slow to run here).
This commit is contained in:
vjrj 2026-07-10 12:10:06 +02:00
parent eed7c70037
commit 163d659119
10 changed files with 96 additions and 16 deletions

View file

@ -372,6 +372,7 @@
"none": "No one vouches for them yet", "none": "No one vouches for them yet",
"count": "Vouched for by {n}", "count": "Vouched for by {n}",
"vouch": "I know this person", "vouch": "I know this person",
"vouched": "You vouch for them" "vouched": "You vouch for them",
"circle": "In your circle"
} }
} }

View file

@ -372,6 +372,7 @@
"none": "Nadie los avala aún", "none": "Nadie los avala aún",
"count": "Avalada por {n}", "count": "Avalada por {n}",
"vouch": "Conozco a esta persona", "vouch": "Conozco a esta persona",
"vouched": "Avalas a esta persona" "vouched": "Avalas a esta persona",
"circle": "En tu círculo"
} }
} }

View file

@ -368,6 +368,7 @@
"none": "Ainda ninguém os avaliza", "none": "Ainda ninguém os avaliza",
"count": "Avalizada por {n}", "count": "Avalizada por {n}",
"vouch": "Conheço esta pessoa", "vouch": "Conheço esta pessoa",
"vouched": "Avalizas esta pessoa" "vouched": "Avalizas esta pessoa",
"circle": "No teu círculo"
} }
} }

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 3 /// Locales: 3
/// Strings: 989 (329 per locale) /// Strings: 992 (330 per locale)
/// ///
/// Built on 2026-07-10 at 09:34 UTC /// Built on 2026-07-10 at 10:09 UTC
// coverage:ignore-file // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import

View file

@ -1185,6 +1185,9 @@ class Translations$trust$en {
/// en: 'You vouch for them' /// en: 'You vouch for them'
String get vouched => 'You vouch for them'; String get vouched => 'You vouch for them';
/// en: 'In your circle'
String get circle => 'In your circle';
} }
// Path: intro.slides // Path: intro.slides
@ -1975,6 +1978,7 @@ extension on Translations {
'trust.count' => ({required Object n}) => 'Vouched for by ${n}', 'trust.count' => ({required Object n}) => 'Vouched for by ${n}',
'trust.vouch' => 'I know this person', 'trust.vouch' => 'I know this person',
'trust.vouched' => 'You vouch for them', 'trust.vouched' => 'You vouch for them',
'trust.circle' => 'In your circle',
_ => null, _ => null,
}; };
} }

View file

@ -659,6 +659,7 @@ class _Translations$trust$es extends Translations$trust$en {
@override String count({required Object n}) => 'Avalada por ${n}'; @override String count({required Object n}) => 'Avalada por ${n}';
@override String get vouch => 'Conozco a esta persona'; @override String get vouch => 'Conozco a esta persona';
@override String get vouched => 'Avalas a esta persona'; @override String get vouched => 'Avalas a esta persona';
@override String get circle => 'En tu círculo';
} }
// Path: intro.slides // Path: intro.slides
@ -1333,6 +1334,7 @@ extension on TranslationsEs {
'trust.count' => ({required Object n}) => 'Avalada por ${n}', 'trust.count' => ({required Object n}) => 'Avalada por ${n}',
'trust.vouch' => 'Conozco a esta persona', 'trust.vouch' => 'Conozco a esta persona',
'trust.vouched' => 'Avalas a esta persona', 'trust.vouched' => 'Avalas a esta persona',
'trust.circle' => 'En tu círculo',
_ => null, _ => null,
}; };
} }

View file

@ -655,6 +655,7 @@ class _Translations$trust$pt extends Translations$trust$en {
@override String count({required Object n}) => 'Avalizada por ${n}'; @override String count({required Object n}) => 'Avalizada por ${n}';
@override String get vouch => 'Conheço esta pessoa'; @override String get vouch => 'Conheço esta pessoa';
@override String get vouched => 'Avalizas esta pessoa'; @override String get vouched => 'Avalizas esta pessoa';
@override String get circle => 'No teu círculo';
} }
// Path: intro.slides // Path: intro.slides
@ -1325,6 +1326,7 @@ extension on TranslationsPt {
'trust.count' => ({required Object n}) => 'Avalizada por ${n}', 'trust.count' => ({required Object n}) => 'Avalizada por ${n}',
'trust.vouch' => 'Conheço esta pessoa', 'trust.vouch' => 'Conheço esta pessoa',
'trust.vouched' => 'Avalizas esta pessoa', 'trust.vouched' => 'Avalizas esta pessoa',
'trust.circle' => 'No teu círculo',
_ => null, _ => null,
}; };
} }

View file

@ -14,30 +14,43 @@ class TrustState extends Equatable {
const TrustState({ const TrustState({
this.certifierCount = 0, this.certifierCount = 0,
this.iVouch = false, this.iVouch = false,
this.knownToYou = false,
this.loading = true, this.loading = true,
this.busy = false, this.busy = false,
}); });
/// How many people (network-wide) vouch for the peer.
final int certifierCount; final int certifierCount;
/// Whether this user vouches for the peer.
final bool iVouch; 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.
final bool knownToYou;
final bool loading; final bool loading;
final bool busy; final bool busy;
TrustState copyWith({ TrustState copyWith({
int? certifierCount, int? certifierCount,
bool? iVouch, bool? iVouch,
bool? knownToYou,
bool? loading, bool? loading,
bool? busy, bool? busy,
}) => }) =>
TrustState( TrustState(
certifierCount: certifierCount ?? this.certifierCount, certifierCount: certifierCount ?? this.certifierCount,
iVouch: iVouch ?? this.iVouch, iVouch: iVouch ?? this.iVouch,
knownToYou: knownToYou ?? this.knownToYou,
loading: loading ?? this.loading, loading: loading ?? this.loading,
busy: busy ?? this.busy, busy: busy ?? this.busy,
); );
@override @override
List<Object?> get props => [certifierCount, iVouch, loading, busy]; List<Object?> get props =>
[certifierCount, iVouch, knownToYou, 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].
@ -57,7 +70,13 @@ class TrustCubit extends Cubit<TrustState> {
bool get isOnline => _transport != null; bool get isOnline => _transport != null;
/// Loads the current certifiers of the peer. /// 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".
static const _circleThreshold = 1;
static const _circleDistance = 2;
/// Loads the peer's certifiers and whether they're within your circle.
Future<void> load() async { Future<void> load() async {
final transport = _transport; final transport = _transport;
if (transport == null) { if (transport == null) {
@ -65,10 +84,20 @@ class TrustCubit extends Cubit<TrustState> {
return; return;
} }
emit(state.copyWith(loading: true)); emit(state.copyWith(loading: true));
final certifiers = await transport.certifiersOf(peerPubkey); final wot = WebOfTrust.fromCertifications(
await transport.allCertifications(),
now: DateTime.now(),
);
final certifiers = wot.certifiersOf(peerPubkey);
final circle = wot.members(
seeds: {selfPubkey},
threshold: _circleThreshold,
maxDistance: _circleDistance,
);
emit(state.copyWith( emit(state.copyWith(
certifierCount: certifiers.length, certifierCount: certifiers.length,
iVouch: certifiers.contains(selfPubkey), iVouch: certifiers.contains(selfPubkey),
knownToYou: circle.contains(peerPubkey),
loading: false, loading: false,
)); ));
} }

View file

@ -173,20 +173,31 @@ class _TrustBanner extends StatelessWidget {
builder: (context, state) { builder: (context, state) {
final cubit = context.read<TrustCubit>(); final cubit = context.read<TrustCubit>();
if (!cubit.isOnline || state.loading) return const SizedBox.shrink(); if (!cubit.isOnline || state.loading) return const SizedBox.shrink();
final known = state.knownToYou;
return Container( return Container(
width: double.infinity, width: double.infinity,
color: seedPrimaryContainer.withValues(alpha: 0.5), color: seedPrimaryContainer.withValues(alpha: 0.5),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.verified_user_outlined, size: 18, color: seedGreen), Icon(
known ? Icons.verified : Icons.verified_user_outlined,
size: 18,
color: seedGreen,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
state.certifierCount == 0 known
? t.trust.none ? t.trust.circle
: t.trust.count(n: state.certifierCount), : state.certifierCount == 0
style: const TextStyle(color: seedOnSurface, fontSize: 13), ? t.trust.none
: t.trust.count(n: state.certifierCount),
style: TextStyle(
color: known ? seedGreen : seedOnSurface,
fontSize: 13,
fontWeight: known ? FontWeight.w600 : FontWeight.w400,
),
), ),
), ),
TextButton( TextButton(

View file

@ -6,7 +6,11 @@ import 'package:tane/state/trust_cubit.dart';
class FakeTrustTransport implements TrustTransport { class FakeTrustTransport implements TrustTransport {
FakeTrustTransport(this.selfId); FakeTrustTransport(this.selfId);
final String selfId; final String selfId;
final Map<String, Set<String>> _certs = {}; final Map<String, Set<String>> _certs = {}; // subject -> issuers
/// Test helper: record an arbitrary issuersubject vouch (to build a graph).
void addCert(String issuer, String subject) =>
(_certs[subject] ??= {}).add(issuer);
@override @override
Future<Set<String>> certifiersOf(String subjectPubkey) async => Future<Set<String>> certifiersOf(String subjectPubkey) async =>
@ -18,14 +22,22 @@ class FakeTrustTransport implements TrustTransport {
Duration validity = const Duration(days: 365), Duration validity = const Duration(days: 365),
String note = '', String note = '',
}) async => }) async =>
(_certs[subjectPubkey] ??= {}).add(selfId); addCert(selfId, subjectPubkey);
@override @override
Future<void> revoke({required String subjectPubkey}) async => Future<void> revoke({required String subjectPubkey}) async =>
_certs[subjectPubkey]?.remove(selfId); _certs[subjectPubkey]?.remove(selfId);
@override @override
Future<List<Certification>> allCertifications() async => const []; Future<List<Certification>> allCertifications() async => [
for (final entry in _certs.entries)
for (final issuer in entry.value)
Certification(
issuer: issuer,
subject: entry.key,
issuedAt: DateTime(2026),
),
];
@override @override
Future<void> close() async {} Future<void> close() async {}
@ -62,6 +74,23 @@ void main() {
await cubit.close(); await cubit.close();
}); });
test('a friend-of-a-friend is in your circle; a stranger is not', () async {
final transport = FakeTrustTransport(me)
..addCert(me, 'friend') // you vouch for a friend
..addCert('friend', peer) // your friend vouches for the peer
..addCert('stranger', 'other'); // unrelated to you
final inCircle = TrustCubit(transport, peerPubkey: peer, selfPubkey: me);
await inCircle.load();
expect(inCircle.state.knownToYou, isTrue);
await inCircle.close();
final outside = TrustCubit(transport, peerPubkey: 'other', selfPubkey: me);
await outside.load();
expect(outside.state.knownToYou, isFalse);
await outside.close();
});
test('never vouches for self', () async { test('never vouches for self', () async {
final cubit = final cubit =
TrustCubit(FakeTrustTransport(me), peerPubkey: me, selfPubkey: me); TrustCubit(FakeTrustTransport(me), peerPubkey: me, selfPubkey: me);