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 46b5537a11
commit f3a7ad198d
10 changed files with 96 additions and 16 deletions

View file

@ -372,6 +372,7 @@
"none": "No one vouches for them yet",
"count": "Vouched for by {n}",
"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",
"count": "Avalada por {n}",
"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",
"count": "Avalizada por {n}",
"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`
///
/// 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
// ignore_for_file: type=lint, unused_import

View file

@ -1185,6 +1185,9 @@ class Translations$trust$en {
/// en: 'You vouch for them'
String get vouched => 'You vouch for them';
/// en: 'In your circle'
String get circle => 'In your circle';
}
// Path: intro.slides
@ -1975,6 +1978,7 @@ extension on Translations {
'trust.count' => ({required Object n}) => 'Vouched for by ${n}',
'trust.vouch' => 'I know this person',
'trust.vouched' => 'You vouch for them',
'trust.circle' => 'In your circle',
_ => 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 get vouch => 'Conozco a esta persona';
@override String get vouched => 'Avalas a esta persona';
@override String get circle => 'En tu círculo';
}
// Path: intro.slides
@ -1333,6 +1334,7 @@ extension on TranslationsEs {
'trust.count' => ({required Object n}) => 'Avalada por ${n}',
'trust.vouch' => 'Conozco a esta persona',
'trust.vouched' => 'Avalas a esta persona',
'trust.circle' => 'En tu círculo',
_ => 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 get vouch => 'Conheço esta pessoa';
@override String get vouched => 'Avalizas esta pessoa';
@override String get circle => 'No teu círculo';
}
// Path: intro.slides
@ -1325,6 +1326,7 @@ extension on TranslationsPt {
'trust.count' => ({required Object n}) => 'Avalizada por ${n}',
'trust.vouch' => 'Conheço esta pessoa',
'trust.vouched' => 'Avalizas esta pessoa',
'trust.circle' => 'No teu círculo',
_ => null,
};
}

View file

@ -14,30 +14,43 @@ class TrustState extends Equatable {
const TrustState({
this.certifierCount = 0,
this.iVouch = false,
this.knownToYou = false,
this.loading = true,
this.busy = false,
});
/// How many people (network-wide) 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.
final bool knownToYou;
final bool loading;
final bool busy;
TrustState copyWith({
int? certifierCount,
bool? iVouch,
bool? knownToYou,
bool? loading,
bool? busy,
}) =>
TrustState(
certifierCount: certifierCount ?? this.certifierCount,
iVouch: iVouch ?? this.iVouch,
knownToYou: knownToYou ?? this.knownToYou,
loading: loading ?? this.loading,
busy: busy ?? this.busy,
);
@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].
@ -57,7 +70,13 @@ class TrustCubit extends Cubit<TrustState> {
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 {
final transport = _transport;
if (transport == null) {
@ -65,10 +84,20 @@ class TrustCubit extends Cubit<TrustState> {
return;
}
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(
certifierCount: certifiers.length,
iVouch: certifiers.contains(selfPubkey),
knownToYou: circle.contains(peerPubkey),
loading: false,
));
}

View file

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

View file

@ -6,7 +6,11 @@ import 'package:tane/state/trust_cubit.dart';
class FakeTrustTransport implements TrustTransport {
FakeTrustTransport(this.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
Future<Set<String>> certifiersOf(String subjectPubkey) async =>
@ -18,14 +22,22 @@ class FakeTrustTransport implements TrustTransport {
Duration validity = const Duration(days: 365),
String note = '',
}) async =>
(_certs[subjectPubkey] ??= {}).add(selfId);
addCert(selfId, subjectPubkey);
@override
Future<void> revoke({required String subjectPubkey}) async =>
_certs[subjectPubkey]?.remove(selfId);
@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
Future<void> close() async {}
@ -62,6 +74,23 @@ void main() {
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 {
final cubit =
TrustCubit(FakeTrustTransport(me), peerPubkey: me, selfPubkey: me);