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:
parent
edb480b21b
commit
2b58fe40ce
10 changed files with 791 additions and 3 deletions
172
packages/commons_core/test/social/nostr_transports_test.dart
Normal file
172
packages/commons_core/test/social/nostr_transports_test.dart
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue