feat(block2): Nostr transport backend in commons_core (all 3 contracts)

Second production slice: the concrete Nostr backend on the shared connection,
using the vetted nostr package's crypto (NIP-44 spec-vector tested, gift wrap).

- NostrConnection: one dart:io WebSocket + identity + EVENT/OK, REQ/EOSE via
  the library's Event/Filter/Request. Pure Dart, no Flutter.
- NostrOfferTransport (NIP-99 kind 30402) + Nip99Codec: geohash coarsened to a
  NIP-52 prefix ladder on the wire; Offer stays inventory/location-agnostic.
- NostrMessageTransport (NIP-17): send/inbox via the library's DirectMessage
  gift-wrap onion.
- NostrTrustTransport (custom addressable kind 30777): certify/revoke/discover.
- MiniRelay test support + integration tests: offers, messaging (metadata
  privacy), WoT membership + revoke, and trust-filters-offers — all over ONE
  shared connection, hermetic in-process relay.

Renamed the core offer lifecycle enum OfferStatus -> OfferLifecycle to avoid
colliding with app_seeds' sharing-intent OfferStatus (no Block 1 code touched;
app_seeds analyzes clean, 0 errors). commons_core: 55 tests green.
This commit is contained in:
vjrj 2026-07-10 02:38:50 +02:00
parent 1253f0c632
commit f50a4737cb
10 changed files with 791 additions and 3 deletions

View file

@ -13,6 +13,10 @@ export 'src/identity/nostr_key_derivation.dart';
export 'src/ids/id_gen.dart';
export 'src/social/certification.dart';
export 'src/social/message_transport.dart';
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_trust_transport.dart';
export 'src/social/offer.dart';
export 'src/social/offer_transport.dart';
export 'src/social/trust_transport.dart';

View file

@ -0,0 +1,97 @@
import 'package:nostr/nostr.dart';
import '../offer.dart';
/// Maps [Offer] Nostr NIP-99 "Classified Listing" events (kind 30402).
///
/// This codec enforces the privacy contract on the wire: the only location that
/// ever leaves the device is a coarse geohash, truncated here to
/// [maxGeohashChars] and emitted as a NIP-52 prefix ladder so area queries match
/// by exact tag. [Offer] itself has no field that could carry inventory or an
/// exact address the seam does the work.
class Nip99Codec {
/// NIP-99 addressable classified listing.
static const kindActive = 30402;
/// A 5-char geohash ±2.4 km cell the coarse "near you" of the mocks.
static const maxGeohashChars = 5;
/// Builds the signed tags + content for [offer]. Signing happens in the
/// transport via `Event.from`.
({List<List<String>> tags, String content}) encode(Offer offer) {
final coarse = _coarsen(offer.approxGeohash);
final tags = <List<String>>[
['d', offer.id],
['title', offer.summary],
['status', offer.status == OfferLifecycle.active ? 'active' : 'sold'],
['offer_type', offer.type.name],
];
for (var i = 1; i <= coarse.length; i++) {
tags.add(['g', coarse.substring(0, i)]); // NIP-52 prefix ladder, coarse only
}
if (offer.category != null) tags.add(['t', offer.category!]);
if (offer.radiusKm != null) tags.add(['radius_km', '${offer.radiusKm}']);
if (offer.type == OfferType.sale && offer.priceAmount != null) {
tags.add(['price', '${offer.priceAmount}', offer.priceCurrency ?? 'EUR']);
}
if (offer.type == OfferType.exchange && offer.exchangeTerms != null) {
tags.add(['exchange_terms', offer.exchangeTerms!]);
}
if (offer.imageUrl != null) tags.add(['image', offer.imageUrl!]);
if (offer.expiresAt != null) {
tags.add(['expiration', '${offer.expiresAt!.millisecondsSinceEpoch ~/ 1000}']);
}
return (tags: tags, content: offer.summary);
}
/// Reconstructs an [Offer] from a received event.
Offer decode(Event event) {
final price = _tag(event, 'price');
return Offer(
id: _tagValue(event, 'd') ?? event.id,
authorPubkeyHex: event.pubkey,
summary: _tagValue(event, 'title') ?? event.content,
type: _parseType(_tagValue(event, 'offer_type')),
status: _tagValue(event, 'status') == 'sold'
? OfferLifecycle.closed
: OfferLifecycle.active,
category: _tagValue(event, 't'),
approxGeohash: _longestGeohash(event),
radiusKm: int.tryParse(_tagValue(event, 'radius_km') ?? ''),
priceAmount: price != null && price.length > 1 ? num.tryParse(price[1]) : null,
priceCurrency: price != null && price.length > 2 ? price[2] : null,
exchangeTerms: _tagValue(event, 'exchange_terms'),
imageUrl: _tagValue(event, 'image'),
);
}
String _coarsen(String g) =>
g.length > maxGeohashChars ? g.substring(0, maxGeohashChars) : g;
String _longestGeohash(Event e) {
var best = '';
for (final t in e.tags) {
if (t.isNotEmpty && t[0] == 'g' && t.length > 1 && t[1].length > best.length) {
best = t[1];
}
}
return best;
}
String? _tagValue(Event e, String name) {
for (final t in e.tags) {
if (t.isNotEmpty && t[0] == name && t.length > 1) return t[1];
}
return null;
}
List<String>? _tag(Event e, String name) {
for (final t in e.tags) {
if (t.isNotEmpty && t[0] == name) return t;
}
return null;
}
OfferType _parseType(String? name) =>
OfferType.values.firstWhere((t) => t.name == name, orElse: () => OfferType.gift);
}

View file

@ -0,0 +1,107 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:nostr/nostr.dart';
import '../../identity/nostr_key_derivation.dart';
/// The ONE piece shared by every Nostr-backed transport: a single relay socket
/// plus the signing identity and the EVENT/OK, REQ/EOSE lifecycle.
/// [OfferTransport], [MessageTransport] and [TrustTransport] each sit on top of
/// this they share this *connection*, not a *contract*.
///
/// Pure Dart (`dart:io` WebSocket) no Flutter. A production relay *pool*
/// (multiple relays, reconnection, backoff) would wrap several of these; the
/// spike/foundation uses one relay.
class NostrConnection {
NostrConnection._(this._socket, this.identity) {
_socket.listen(_onData, onDone: _onDone, onError: (_) => _onDone());
}
final WebSocket _socket;
/// The derived identity this connection signs with.
final NostrIdentity identity;
String get publicKeyHex => identity.publicKeyHex;
String get privateKeyHex => identity.privateKeyHex;
final _incoming = StreamController<List<dynamic>>.broadcast();
int _subCounter = 0;
static Future<NostrConnection> connect(
String relayUrl, {
required NostrIdentity identity,
}) async {
final socket = await WebSocket.connect(relayUrl);
return NostrConnection._(socket, identity);
}
void _onData(dynamic d) {
if (!_incoming.isClosed) _incoming.add(jsonDecode(d as String) as List);
}
void _onDone() {
if (!_incoming.isClosed) _incoming.close();
}
/// Publishes an already-signed [event]; resolves with the relay's OK verdict.
Future<({bool accepted, String message})> publish(Event event) async {
final ok = _incoming.stream.firstWhere(
(m) => m[0] == 'OK' && m[1] == event.id,
);
_socket.add(jsonEncode(['EVENT', event.toMap()]));
final r = await ok.timeout(const Duration(seconds: 10));
return (accepted: r[2] as bool, message: r.length > 3 ? r[3] as String : '');
}
/// Streams events matching [filter] (live), until the caller cancels.
Stream<Event> subscribe(Filter filter) {
final subId = 'sub${_subCounter++}';
late StreamController<Event> controller;
StreamSubscription? sub;
controller = StreamController<Event>(
onListen: () {
sub = _incoming.stream.listen((m) {
if (m[0] == 'EVENT' && m[1] == subId) {
controller.add(
Event.fromMap(m[2] as Map<String, dynamic>, verify: false),
);
}
});
_socket.add(Request(subscriptionId: subId, filters: [filter]).serialize());
},
onCancel: () async {
_socket.add(jsonEncode(['CLOSE', subId]));
await sub?.cancel();
},
);
return controller.stream;
}
/// Collects stored events matching [filter] up to EOSE, then closes the sub.
Future<List<Event>> reqOnce(Filter filter) async {
final subId = 'once${_subCounter++}';
final results = <Event>[];
final done = Completer<void>();
final sub = _incoming.stream.listen((m) {
if (m[1] != subId) return;
if (m[0] == 'EVENT') {
results.add(Event.fromMap(m[2] as Map<String, dynamic>, verify: false));
} else if (m[0] == 'EOSE' && !done.isCompleted) {
done.complete();
}
});
_socket.add(Request(subscriptionId: subId, filters: [filter]).serialize());
await done.future.timeout(const Duration(seconds: 10));
_socket.add(jsonEncode(['CLOSE', subId]));
await sub.cancel();
return results;
}
Future<void> close() async {
await _socket.close();
if (!_incoming.isClosed) await _incoming.close();
}
}

View file

@ -0,0 +1,68 @@
import 'dart:async';
import 'package:nostr/nostr.dart' as nostr;
import '../message_transport.dart';
import 'nostr_connection.dart';
/// NIP-17 private-DM backend for [MessageTransport], on the shared
/// [NostrConnection]. The gift-wrap onion (rumor kind 14 seal kind 13 wrap
/// kind 1059 signed by an ephemeral key) and the NIP-44 encryption are done by
/// the vetted `nostr` package; we only route it over the relay.
class NostrMessageTransport implements MessageTransport {
NostrMessageTransport(this._conn);
final NostrConnection _conn;
static const kindGiftWrap = 1059;
@override
Future<void> send({required String toPubkey, required String text}) async {
final wrap = await nostr.DirectMessage.create(
message: text,
authorSecretKey: _conn.privateKeyHex,
recipientPubkey: toPubkey,
);
final r = await _conn.publish(wrap);
if (!r.accepted) throw StateError('relay rejected gift wrap: ${r.message}');
}
@override
Stream<PrivateMessage> inbox() => _conn
.subscribe(nostr.Filter(kinds: const [kindGiftWrap], pTags: [_conn.publicKeyHex]))
.asyncMap(_unwrap)
.where((m) => m != null)
.cast<PrivateMessage>();
/// Collect the inbox up to EOSE (tests/one-shot).
Future<List<PrivateMessage>> inboxUntilEose() async {
final wraps = await _conn.reqOnce(
nostr.Filter(kinds: const [kindGiftWrap], pTags: [_conn.publicKeyHex]),
);
final out = <PrivateMessage>[];
for (final w in wraps) {
final m = await _unwrap(w);
if (m != null) out.add(m);
}
return out;
}
Future<PrivateMessage?> _unwrap(nostr.Event wrap) async {
try {
final rumor = await nostr.DirectMessage.parse(
giftWrap: wrap,
recipientSecretKey: _conn.privateKeyHex,
);
return PrivateMessage(
fromPubkey: rumor.pubkey,
text: rumor.content,
at: DateTime.fromMillisecondsSinceEpoch(rumor.createdAt * 1000),
);
} catch (_) {
return null; // not for us / wrong key / tampered
}
}
@override
Future<void> close() => _conn.close();
}

View file

@ -0,0 +1,69 @@
import 'package:nostr/nostr.dart';
import '../offer.dart';
import '../offer_transport.dart';
import 'nip99_codec.dart';
import 'nostr_connection.dart';
/// Nostr NIP-99 backend for [OfferTransport], on a shared [NostrConnection].
class NostrOfferTransport implements OfferTransport {
NostrOfferTransport(this._conn);
final NostrConnection _conn;
final Nip99Codec _codec = Nip99Codec();
Filter _filter(DiscoveryQuery q) => Filter(
kinds: const [Nip99Codec.kindActive],
tagFilters: {'g': [q.geohashPrefix]},
limit: q.limit,
);
@override
Future<PublishResult> publish(Offer offer) async {
final encoded = _codec.encode(offer);
final event = Event.from(
kind: Nip99Codec.kindActive,
content: encoded.content,
tags: encoded.tags,
secretKey: _conn.privateKeyHex,
);
final r = await _conn.publish(event);
return PublishResult(
accepted: r.accepted,
transportRef: event.id,
message: r.message,
);
}
@override
Stream<Offer> discover(DiscoveryQuery query) => _conn
.subscribe(_filter(query))
.map(_codec.decode)
.where((o) => query.types.isEmpty || query.types.contains(o.type));
/// Collects matches up to EOSE (tests/one-shot browse).
Future<List<Offer>> discoverUntilEose(DiscoveryQuery query) async {
final events = await _conn.reqOnce(_filter(query));
return events
.map(_codec.decode)
.where((o) => query.types.isEmpty || query.types.contains(o.type))
.toList();
}
@override
Future<void> retract(String offerId) async {
// NIP-09 deletion of the addressable coordinate; the OK path mirrors publish.
final event = Event.from(
kind: 5,
content: 'offer retracted',
tags: [
['a', '${Nip99Codec.kindActive}:${_conn.publicKeyHex}:$offerId'],
],
secretKey: _conn.privateKeyHex,
);
await _conn.publish(event);
}
@override
Future<void> close() => _conn.close();
}

View file

@ -0,0 +1,97 @@
import 'package:nostr/nostr.dart';
import '../certification.dart';
import '../trust_transport.dart';
import 'nostr_connection.dart';
/// Duniter-style certifications carried as Nostr events, on the shared
/// [NostrConnection]. No settled NIP exists for a web of trust, so this uses a
/// custom addressable kind mapped to the Duniter model (g1-integration.md "WoT
/// propia pero compatible"): one live certification per (issuer, subject), so
/// re-certifying renews and revoking replaces.
class NostrTrustTransport implements TrustTransport {
NostrTrustTransport(this._conn);
final NostrConnection _conn;
/// Custom Tanemaki certification kind (addressable range).
static const kindCertification = 30777;
int get _now => DateTime.now().millisecondsSinceEpoch ~/ 1000;
@override
Future<void> certify({
required String subjectPubkey,
Duration validity = const Duration(days: 365),
String note = '',
}) =>
_publish(subjectPubkey, note: note, validity: validity, revoked: false);
@override
Future<void> revoke({required String subjectPubkey}) =>
_publish(subjectPubkey, note: '', validity: Duration.zero, revoked: true);
Future<void> _publish(
String subject, {
required String note,
required Duration validity,
required bool revoked,
}) async {
final event = Event.from(
kind: kindCertification,
content: note,
tags: [
['p', subject],
['d', subject],
['status', revoked ? 'revoked' : 'valid'],
if (!revoked) ['expiration', '${_now + validity.inSeconds}'],
],
secretKey: _conn.privateKeyHex,
);
final r = await _conn.publish(event);
if (!r.accepted) throw StateError('relay rejected cert: ${r.message}');
}
@override
Future<List<Certification>> allCertifications() async {
final events = await _conn.reqOnce(const Filter(kinds: [kindCertification]));
return events.map(_toCertification).toList();
}
@override
Future<Set<String>> certifiersOf(String subjectPubkey) async {
final events = await _conn.reqOnce(
Filter(kinds: const [kindCertification], pTags: [subjectPubkey]),
);
final now = DateTime.now();
return events
.map(_toCertification)
.where((c) => c.subject == subjectPubkey && c.isValidAt(now))
.map((c) => c.issuer)
.toSet();
}
Certification _toCertification(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 exp = tag('expiration');
return Certification(
issuer: e.pubkey,
subject: tag('p') ?? tag('d') ?? '',
issuedAt: DateTime.fromMillisecondsSinceEpoch(e.createdAt * 1000),
expiresAt: exp == null
? null
: DateTime.fromMillisecondsSinceEpoch(int.parse(exp) * 1000),
revoked: tag('status') == 'revoked',
note: e.content,
);
}
@override
Future<void> close() => _conn.close();
}

View file

@ -2,7 +2,10 @@
/// reserves for a future "library of things".
enum OfferType { gift, exchange, sale, wanted, lend }
enum OfferStatus { active, reserved, closed }
/// The published offer's lifecycle (distinct from the domain's local
/// sharing-intent enum). Named `OfferLifecycle` to avoid colliding with
/// app-side `OfferStatus`.
enum OfferLifecycle { active, reserved, closed }
/// A publishable offer the "shop window", deliberately DECOUPLED from the
/// domain's inventory (sharing-model.md §2, core-domain-boundary.md §4.3).
@ -18,7 +21,7 @@ class Offer {
required this.summary,
required this.type,
required this.approxGeohash,
this.status = OfferStatus.active,
this.status = OfferLifecycle.active,
this.category,
this.priceAmount,
this.priceCurrency,
@ -38,7 +41,7 @@ class Offer {
final String summary;
final OfferType type;
final OfferStatus status;
final OfferLifecycle status;
/// Free-text category, prefilled in-app from the domain but opaque here.
final String? category;