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

@ -0,0 +1,172 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:commons_core/commons_core.dart';
import 'package:test/test.dart';
import '../support/mini_relay.dart';
/// Integration: the three transports on one shared connection, over a hermetic
/// in-process relay, using the vetted `nostr` crypto. Mirrors the Block 2 spike
/// but on the production commons_core code.
void main() {
late MiniRelay relay;
setUp(() async => relay = await MiniRelay.start());
tearDown(() async => relay.stop());
Future<NostrIdentity> idFor(int fill) => NostrKeyDerivation.deriveFromSeed(
Uint8List(32)..fillRange(0, 32, fill),
);
Future<NostrConnection> connFor(NostrIdentity id) =>
NostrConnection.connect(relay.url, identity: id);
group('offers (NIP-99)', () {
test('published by one identity, discovered by another via geohash',
() async {
final alice = await idFor(1);
final bob = await idFor(2);
final aliceT = NostrOfferTransport(await connFor(alice));
final bobT = NostrOfferTransport(await connFor(bob));
final published = await aliceT.publish(Offer(
id: 'tomate-1',
authorPubkeyHex: alice.publicKeyHex,
summary: 'Tomate rosa de Barbastro',
type: OfferType.gift,
category: 'Solanaceae',
approxGeohash: 'sp3e9xyz',
));
expect(published.accepted, isTrue, reason: published.message);
final found = await bobT
.discoverUntilEose(const DiscoveryQuery(geohashPrefix: 'sp3'));
expect(found, hasLength(1));
expect(found.single.summary, 'Tomate rosa de Barbastro');
expect(found.single.authorPubkeyHex, alice.publicKeyHex);
await aliceT.close();
await bobT.close();
});
test('exact geohash never leaves the device (coarsened on the wire)',
() async {
final alice = await idFor(1);
final t = NostrOfferTransport(await connFor(alice));
await t.publish(Offer(
id: 'x',
authorPubkeyHex: alice.publicKeyHex,
summary: 'Pimiento',
type: OfferType.gift,
approxGeohash: 'sp3e9tunqmXY', // precise
));
final wire = jsonEncode(relay.eventsOfKind(30402));
expect(wire, isNot(contains('sp3e9tunqmXY')));
expect(wire, contains('sp3e9'));
await t.close();
});
});
group('messaging (NIP-17)', () {
test('Alice → Bob: arrives, sender authenticated, relay sees only cipher',
() async {
final alice = await idFor(1);
final bob = await idFor(2);
final aliceM = NostrMessageTransport(await connFor(alice));
final bobM = NostrMessageTransport(await connFor(bob));
await aliceM.send(
toPubkey: bob.publicKeyHex,
text: '¿cambiamos semilla de calabaza?',
);
final inbox = await bobM.inboxUntilEose();
expect(inbox, hasLength(1));
expect(inbox.single.text, '¿cambiamos semilla de calabaza?');
expect(inbox.single.fromPubkey, alice.publicKeyHex);
// Eavesdropper view: only kind-1059 ciphertext, ephemeral author, p=bob.
final wire = jsonEncode(relay.eventsOfKind(1059));
expect(wire, isNot(contains('calabaza')));
final wrap = relay.eventsOfKind(1059).single;
expect(wrap['pubkey'], isNot(alice.publicKeyHex));
await aliceM.close();
await bobM.close();
});
});
group('web of trust (custom kind)', () {
test('3 seeds certify a newcomer → known; revoke drops', () async {
final seeds = [for (var i = 1; i <= 3; i++) await idFor(i)];
final newcomer = await idFor(50);
for (final s in seeds) {
final t = NostrTrustTransport(await connFor(s));
await t.certify(subjectPubkey: newcomer.publicKeyHex);
await t.close();
}
final reader = NostrTrustTransport(await connFor(await idFor(99)));
expect(await reader.certifiersOf(newcomer.publicKeyHex), hasLength(3));
final wot = WebOfTrust.fromCertifications(
await reader.allCertifications(),
now: DateTime.now(),
);
final seedSet = seeds.map((s) => s.publicKeyHex).toSet();
expect(
wot.isMember(newcomer.publicKeyHex,
seeds: seedSet, threshold: 3, maxDistance: 5),
isTrue,
);
// One seed revokes certifier count drops.
final revoker = NostrTrustTransport(await connFor(seeds.first));
await revoker.revoke(subjectPubkey: newcomer.publicKeyHex);
expect(await reader.certifiersOf(newcomer.publicKeyHex), hasLength(2));
await reader.close();
await revoker.close();
});
test('trust filters offers: known author vs unknown stranger', () async {
final seeds = [for (var i = 1; i <= 3; i++) await idFor(i)];
final alice = await idFor(20);
final eve = await idFor(66);
for (final s in seeds) {
final t = NostrTrustTransport(await connFor(s));
await t.certify(subjectPubkey: alice.publicKeyHex);
await t.close();
}
for (final who in [alice, eve]) {
final o = NostrOfferTransport(await connFor(who));
await o.publish(Offer(
id: 'o-${who.publicKeyHex.substring(0, 6)}',
authorPubkeyHex: who.publicKeyHex,
summary: 'Semilla',
type: OfferType.gift,
approxGeohash: 'sp3e9',
));
await o.close();
}
// Bob: discover offers AND read trust over ONE shared connection.
final bobConn = await connFor(await idFor(7));
final found = await NostrOfferTransport(bobConn)
.discoverUntilEose(const DiscoveryQuery(geohashPrefix: 'sp3e9'));
final trust = NostrTrustTransport(bobConn);
final wot = WebOfTrust.fromCertifications(
await trust.allCertifications(),
now: DateTime.now(),
);
final seedSet = seeds.map((s) => s.publicKeyHex).toSet();
bool known(String pk) =>
wot.isMember(pk, seeds: seedSet, threshold: 3, maxDistance: 5);
expect(found, hasLength(2));
expect(known(alice.publicKeyHex), isTrue);
expect(known(eve.publicKeyHex), isFalse);
await trust.close();
});
});
}

View file

@ -0,0 +1,131 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:nostr/nostr.dart';
/// A tiny in-process Nostr relay (NIP-01 subset) for tests: EVENT / REQ / EOSE /
/// CLOSE, filtering by `kinds` / `authors` / `ids` / `#x` tags, with addressable
/// (3000039999) replacement by (kind, pubkey, `d`). Hermetic no network so
/// transport tests are CI-safe. Not a real relay.
class MiniRelay {
MiniRelay._(this._server);
final HttpServer _server;
final List<Map<String, dynamic>> _events = [];
final Set<_Sub> _subs = {};
int get port => _server.port;
String get url => 'ws://127.0.0.1:$port';
int get storedCount => _events.length;
/// All stored events of a given [kind] (for eavesdropper assertions).
List<Map<String, dynamic>> eventsOfKind(int kind) =>
_events.where((e) => e['kind'] == kind).toList();
static Future<MiniRelay> start() async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final relay = MiniRelay._(server);
relay._accept();
return relay;
}
void _accept() {
_server.listen((req) async {
if (!WebSocketTransformer.isUpgradeRequest(req)) {
req.response.statusCode = HttpStatus.badRequest;
await req.response.close();
return;
}
final ws = await WebSocketTransformer.upgrade(req);
final socketSubs = <_Sub>{};
ws.listen(
(data) => _onMessage(ws, socketSubs, data as String),
onDone: () => _subs.removeAll(socketSubs),
onError: (_) => _subs.removeAll(socketSubs),
);
});
}
void _onMessage(WebSocket ws, Set<_Sub> socketSubs, String data) {
final msg = jsonDecode(data) as List;
switch (msg[0]) {
case 'EVENT':
_handleEvent(ws, (msg[1] as Map).cast<String, dynamic>());
case 'REQ':
final subId = msg[1] as String;
final filters = msg.sublist(2).cast<Map<String, dynamic>>();
final sub = _Sub(ws, subId, filters);
_subs.add(sub);
socketSubs.add(sub);
for (final e in _events) {
if (sub.matches(e)) _send(ws, ['EVENT', subId, e]);
}
_send(ws, ['EOSE', subId]);
case 'CLOSE':
final subId = msg[1] as String;
socketSubs.removeWhere((s) => s.id == subId);
_subs.removeWhere((s) => s.ws == ws && s.id == subId);
}
}
void _handleEvent(WebSocket ws, Map<String, dynamic> event) {
final id = event['id'] as String;
try {
Event.fromMap(event, verify: true); // reject bad signatures like a relay
} catch (_) {
_send(ws, ['OK', id, false, 'invalid: bad signature']);
return;
}
final kind = event['kind'] as int;
if (kind >= 30000 && kind < 40000) {
final d = _tag(event, 'd') ?? '';
_events.removeWhere((e) =>
e['kind'] == kind && e['pubkey'] == event['pubkey'] && (_tag(e, 'd') ?? '') == d);
}
_events.add(event);
_send(ws, ['OK', id, true, '']);
for (final sub in _subs) {
if (sub.matches(event)) _send(sub.ws, ['EVENT', sub.id, event]);
}
}
void _send(WebSocket ws, Object message) => ws.add(jsonEncode(message));
Future<void> stop() => _server.close(force: true);
}
String? _tag(Map<String, dynamic> event, String name) {
for (final t in (event['tags'] as List)) {
final row = (t as List).cast<String>();
if (row.isNotEmpty && row[0] == name && row.length > 1) return row[1];
}
return null;
}
class _Sub {
_Sub(this.ws, this.id, this.filters);
final WebSocket ws;
final String id;
final List<Map<String, dynamic>> filters;
bool matches(Map<String, dynamic> e) => filters.any((f) => _matches(e, f));
bool _matches(Map<String, dynamic> e, Map<String, dynamic> f) {
if (f['kinds'] != null && !(f['kinds'] as List).contains(e['kind'])) return false;
if (f['authors'] != null && !(f['authors'] as List).contains(e['pubkey'])) return false;
if (f['ids'] != null && !(f['ids'] as List).contains(e['id'])) return false;
for (final entry in f.entries) {
if (!entry.key.startsWith('#')) continue;
final tagName = entry.key.substring(1);
final wanted = (entry.value as List).cast<String>();
final present = (e['tags'] as List)
.map((t) => (t as List).cast<String>())
.where((t) => t.isNotEmpty && t[0] == tagName && t.length > 1)
.map((t) => t[1]);
if (!wanted.any(present.contains)) return false;
}
return true;
}
}