feat(core): person-to-person ratings transport (kind 30778)

Wallapop-style reputation primitive next to trust: Rating (1..5 stars +
optional comment), RatingTransport (rate/retract/ratingsOf/myRatingOf)
and a Nostr implementation on the shared channel. Addressable kind
30778, d = subject: one live rating per (rater, subject) — re-rating
replaces (a single author can never pile up reviews) and retracting
overwrites in place. Client-side dedupe mirrors NIP-01 replacement
order for relays that keep history.
This commit is contained in:
vjrj 2026-07-11 13:06:53 +02:00
parent fef174e8c4
commit a24831530d
6 changed files with 297 additions and 0 deletions

View file

@ -21,11 +21,14 @@ export 'src/social/nostr/nostr_connection.dart';
export 'src/social/nostr/nostr_message_transport.dart';
export 'src/social/nostr/nostr_offer_transport.dart';
export 'src/social/nostr/nostr_profile_transport.dart';
export 'src/social/nostr/nostr_rating_transport.dart';
export 'src/social/nostr/nostr_trust_transport.dart';
export 'src/social/nostr/relay_pool.dart';
export 'src/social/offer.dart';
export 'src/social/offer_transport.dart';
export 'src/social/profile_transport.dart';
export 'src/social/rating.dart';
export 'src/social/rating_transport.dart';
export 'src/social/sync_transport.dart';
export 'src/social/trust_transport.dart';
export 'src/social/web_of_trust.dart';

View file

@ -0,0 +1,125 @@
import 'package:nostr/nostr.dart';
import '../rating.dart';
import '../rating_transport.dart';
import 'nostr_channel.dart';
/// Ratings carried as Nostr events, on the shared [NostrConnection]. No settled
/// NIP exists for person-to-person reviews, so this uses a custom addressable
/// kind (next to the certification kind): one live rating per (rater, subject),
/// so rating again replaces and retracting overwrites in place.
class NostrRatingTransport implements RatingTransport {
NostrRatingTransport(this._conn);
final NostrChannel _conn;
/// Custom Tanemaki rating kind (addressable range, next to trust's 30777).
static const kindRating = 30778;
@override
Future<void> rate({
required String subjectPubkey,
required int stars,
String comment = '',
}) {
if (stars < Rating.minStars || stars > Rating.maxStars) {
throw ArgumentError.value(
stars, 'stars', 'must be ${Rating.minStars}..${Rating.maxStars}');
}
return _publish(subjectPubkey,
stars: stars, comment: comment, retracted: false);
}
@override
Future<void> retract({required String subjectPubkey}) =>
_publish(subjectPubkey, stars: Rating.minStars, comment: '',
retracted: true);
Future<void> _publish(
String subject, {
required int stars,
required String comment,
required bool retracted,
}) async {
final event = Event.from(
kind: kindRating,
content: comment,
tags: [
['p', subject],
['d', subject],
['rating', '$stars'],
['status', retracted ? 'retracted' : 'valid'],
],
secretKey: _conn.privateKeyHex,
);
final r = await _conn.publish(event);
if (!r.accepted) throw StateError('relay rejected rating: ${r.message}');
}
@override
Future<List<Rating>> ratingsOf(String subjectPubkey) async {
final events = await _conn.reqOnce(
Filter(kinds: const [kindRating], pTags: [subjectPubkey]),
);
// One rating per rater: keep the newest event should a relay return
// several, then drop retractions.
final newestByRater = <String, Event>{};
for (final e in events) {
final prev = newestByRater[e.pubkey];
if (prev == null || _newer(e, prev)) newestByRater[e.pubkey] = e;
}
return newestByRater.values
.map(_toRating)
.whereType<Rating>()
.where((r) => r.subject == subjectPubkey && r.isActive)
.toList();
}
@override
Future<Rating?> myRatingOf(String subjectPubkey) async {
final mine = await _conn.reqOnce(Filter(
kinds: const [kindRating],
authors: [_conn.publicKeyHex],
pTags: [subjectPubkey],
));
Event? newest;
for (final e in mine) {
if (newest == null || _newer(e, newest)) newest = e;
}
final rating = newest == null ? null : _toRating(newest);
return (rating != null && rating.isActive) ? rating : null;
}
/// NIP-01 replacement order: newer created_at wins; on a tie the
/// lexicographically smallest id does.
static bool _newer(Event a, Event b) =>
a.createdAt != b.createdAt
? a.createdAt > b.createdAt
: a.id.compareTo(b.id) < 0;
/// Null for malformed events (missing/out-of-range stars).
Rating? _toRating(Event e) {
String? tag(String name) {
for (final t in e.tags) {
if (t.isNotEmpty && t[0] == name && t.length > 1) return t[1];
}
return null;
}
final stars = int.tryParse(tag('rating') ?? '');
if (stars == null || stars < Rating.minStars || stars > Rating.maxStars) {
return null;
}
return Rating(
rater: e.pubkey,
subject: tag('p') ?? tag('d') ?? '',
stars: stars,
ratedAt: DateTime.fromMillisecondsSinceEpoch(e.createdAt * 1000),
comment: e.content,
retracted: tag('status') == 'retracted',
);
}
@override
Future<void> close() => _conn.close();
}

View file

@ -0,0 +1,44 @@
import 'package:equatable/equatable.dart';
/// One "A rates B" review: stars plus an optional short comment. Public by
/// design and signed by the rater. One live rating per (rater, subject) pair
/// re-rating replaces, so a single author can never pile up reviews. Generic
/// core type no seed specifics.
class Rating extends Equatable {
Rating({
required this.rater,
required this.subject,
required this.stars,
required this.ratedAt,
this.comment = '',
this.retracted = false,
}) {
if (stars < minStars || stars > maxStars) {
throw ArgumentError.value(stars, 'stars', 'must be $minStars..$maxStars');
}
}
static const minStars = 1;
static const maxStars = 5;
/// Rater public key (hex).
final String rater;
/// Rated public key (hex).
final String subject;
/// 1..5, Wallapop style.
final int stars;
final DateTime ratedAt;
final String comment;
/// A rater can take their rating back; a retracted rating doesn't count.
final bool retracted;
bool get isActive => !retracted;
@override
List<Object?> get props =>
[rater, subject, stars, ratedAt, comment, retracted];
}

View file

@ -0,0 +1,25 @@
import 'rating.dart';
/// The reputation contract over the shared connection: rate / retract / read
/// ratings. Ratings are public signed events (like certifications), so this
/// transport publishes and discovers rather than encrypts. One live rating per
/// (rater, subject) pair rating again replaces the previous one.
abstract interface class RatingTransport {
/// Publishes (or replaces) this identity's rating of [subjectPubkey].
Future<void> rate({
required String subjectPubkey,
required int stars,
String comment = '',
});
/// Takes back this identity's rating of [subjectPubkey].
Future<void> retract({required String subjectPubkey});
/// Active ratings of [subjectPubkey], one per rater.
Future<List<Rating>> ratingsOf(String subjectPubkey);
/// This identity's active rating of [subjectPubkey], or null.
Future<Rating?> myRatingOf(String subjectPubkey);
Future<void> close();
}

View file

@ -237,4 +237,75 @@ void main() {
await trust.close();
});
});
group('ratings (custom kind)', () {
test('published by raters, read back with stars and comments', () async {
final alice = await idFor(1);
final bob = await idFor(2);
final carol = await idFor(3);
final aliceR = NostrRatingTransport(await connFor(alice));
await aliceR.rate(
subjectPubkey: carol.publicKeyHex,
stars: 5,
comment: 'Lovely seeds',
);
await aliceR.close();
final bobR = NostrRatingTransport(await connFor(bob));
await bobR.rate(subjectPubkey: carol.publicKeyHex, stars: 3);
await bobR.close();
final reader = NostrRatingTransport(await connFor(await idFor(99)));
final ratings = await reader.ratingsOf(carol.publicKeyHex);
expect(ratings, hasLength(2));
final byRater = {for (final r in ratings) r.rater: r};
expect(byRater[alice.publicKeyHex]!.stars, 5);
expect(byRater[alice.publicKeyHex]!.comment, 'Lovely seeds');
expect(byRater[bob.publicKeyHex]!.stars, 3);
await reader.close();
});
test('re-rating replaces (one live rating per pair); retract removes',
() async {
final alice = await idFor(1);
final carol = await idFor(3);
final aliceR = NostrRatingTransport(await connFor(alice));
await aliceR.rate(subjectPubkey: carol.publicKeyHex, stars: 2);
// createdAt has second granularity; cross the boundary so the
// replacement is unambiguously newer.
await Future<void>.delayed(const Duration(milliseconds: 1100));
await aliceR.rate(
subjectPubkey: carol.publicKeyHex,
stars: 4,
comment: 'Better this season',
);
final mine = await aliceR.myRatingOf(carol.publicKeyHex);
expect(mine!.stars, 4);
final all = await aliceR.ratingsOf(carol.publicKeyHex);
expect(all, hasLength(1));
expect(all.single.stars, 4);
await Future<void>.delayed(const Duration(milliseconds: 1100));
await aliceR.retract(subjectPubkey: carol.publicKeyHex);
expect(await aliceR.myRatingOf(carol.publicKeyHex), isNull);
expect(await aliceR.ratingsOf(carol.publicKeyHex), isEmpty);
await aliceR.close();
});
test('rejects out-of-range stars before touching the relay', () async {
final alice = await idFor(1);
final aliceR = NostrRatingTransport(await connFor(alice));
expect(
() => aliceR.rate(subjectPubkey: 'ff' * 32, stars: 0),
throwsArgumentError,
);
expect(
() => aliceR.rate(subjectPubkey: 'ff' * 32, stars: 6),
throwsArgumentError,
);
await aliceR.close();
});
});
}

View file

@ -0,0 +1,29 @@
import 'package:commons_core/commons_core.dart';
import 'package:test/test.dart';
void main() {
Rating make(int stars, {bool retracted = false}) => Rating(
rater: 'a',
subject: 'b',
stars: stars,
ratedAt: DateTime(2026),
retracted: retracted,
);
test('accepts 1..5 stars', () {
for (var s = Rating.minStars; s <= Rating.maxStars; s++) {
expect(make(s).stars, s);
}
});
test('rejects out-of-range stars', () {
expect(() => make(0), throwsArgumentError);
expect(() => make(6), throwsArgumentError);
expect(() => make(-1), throwsArgumentError);
});
test('a retracted rating is not active', () {
expect(make(5).isActive, isTrue);
expect(make(5, retracted: true).isActive, isFalse);
});
}