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