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:
parent
46b5537a11
commit
f3a7ad198d
10 changed files with 96 additions and 16 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 issuer→subject 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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue