feat(block2): relay pool — publish to many relays, discover deduped, tolerate failures
Relay-strategy hardening (network-trust §3): don't hang on one relay. - NostrChannel interface: what the transports need (sign, publish, subscribe, reqOnce). Implemented by NostrConnection (single relay) and the new RelayPool. - RelayPool: connects to all configured relays (skips unreachable ones, throws only when none are reachable), publishes to every live relay (accepted if any accepts), merges discovery deduped by event id, and survives a relay dropping. - Transports now depend on NostrChannel, not a concrete connection. - SocialService.openSession takes ALL configured relays (was relays.first); createOffersCubit passes the whole list. commons_core 63 tests green (3 new pool tests over two in-process relays); app_seeds social/market 18 green.
This commit is contained in:
parent
492bc62025
commit
2a6b4b0947
10 changed files with 246 additions and 24 deletions
|
|
@ -14,10 +14,12 @@ export 'src/ids/id_gen.dart';
|
|||
export 'src/social/certification.dart';
|
||||
export 'src/social/geohash.dart';
|
||||
export 'src/social/message_transport.dart';
|
||||
export 'src/social/nostr/nostr_channel.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/nostr/relay_pool.dart';
|
||||
export 'src/social/offer.dart';
|
||||
export 'src/social/offer_transport.dart';
|
||||
export 'src/social/trust_transport.dart';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import 'package:nostr/nostr.dart';
|
||||
|
||||
/// What the three transports need from "the network": sign with an identity,
|
||||
/// publish an event, and run subscriptions. Implemented by a single
|
||||
/// [NostrConnection] and by a fault-tolerant [RelayPool] over several relays, so
|
||||
/// the transports don't care whether they talk to one relay or many.
|
||||
abstract interface class NostrChannel {
|
||||
String get privateKeyHex;
|
||||
String get publicKeyHex;
|
||||
|
||||
/// Publishes [event]; accepted if at least one relay accepts it.
|
||||
Future<({bool accepted, String message})> publish(Event event);
|
||||
|
||||
/// Streams events matching [filter] from every relay, de-duplicated by id.
|
||||
Stream<Event> subscribe(Filter filter);
|
||||
|
||||
/// Collects stored events matching [filter] up to EOSE, de-duplicated by id.
|
||||
Future<List<Event>> reqOnce(Filter filter);
|
||||
|
||||
Future<void> close();
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import 'dart:io';
|
|||
import 'package:nostr/nostr.dart';
|
||||
|
||||
import '../../identity/nostr_key_derivation.dart';
|
||||
import 'nostr_channel.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.
|
||||
|
|
@ -14,7 +15,7 @@ import '../../identity/nostr_key_derivation.dart';
|
|||
/// 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 {
|
||||
class NostrConnection implements NostrChannel {
|
||||
NostrConnection._(this._socket, this.identity) {
|
||||
_socket.listen(_onData, onDone: _onDone, onError: (_) => _onDone());
|
||||
}
|
||||
|
|
@ -24,7 +25,9 @@ class NostrConnection {
|
|||
/// The derived identity this connection signs with.
|
||||
final NostrIdentity identity;
|
||||
|
||||
@override
|
||||
String get publicKeyHex => identity.publicKeyHex;
|
||||
@override
|
||||
String get privateKeyHex => identity.privateKeyHex;
|
||||
|
||||
final _incoming = StreamController<List<dynamic>>.broadcast();
|
||||
|
|
@ -47,6 +50,7 @@ class NostrConnection {
|
|||
}
|
||||
|
||||
/// Publishes an already-signed [event]; resolves with the relay's OK verdict.
|
||||
@override
|
||||
Future<({bool accepted, String message})> publish(Event event) async {
|
||||
final ok = _incoming.stream.firstWhere(
|
||||
(m) => m[0] == 'OK' && m[1] == event.id,
|
||||
|
|
@ -57,6 +61,7 @@ class NostrConnection {
|
|||
}
|
||||
|
||||
/// Streams events matching [filter] (live), until the caller cancels.
|
||||
@override
|
||||
Stream<Event> subscribe(Filter filter) {
|
||||
final subId = 'sub${_subCounter++}';
|
||||
late StreamController<Event> controller;
|
||||
|
|
@ -81,6 +86,7 @@ class NostrConnection {
|
|||
}
|
||||
|
||||
/// Collects stored events matching [filter] up to EOSE, then closes the sub.
|
||||
@override
|
||||
Future<List<Event>> reqOnce(Filter filter) async {
|
||||
final subId = 'once${_subCounter++}';
|
||||
final results = <Event>[];
|
||||
|
|
@ -100,6 +106,7 @@ class NostrConnection {
|
|||
return results;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _socket.close();
|
||||
if (!_incoming.isClosed) await _incoming.close();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import 'dart:async';
|
|||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import '../message_transport.dart';
|
||||
import 'nostr_connection.dart';
|
||||
import 'nostr_channel.dart';
|
||||
|
||||
/// NIP-17 private-DM backend for [MessageTransport], on the shared
|
||||
/// [NostrConnection]. The gift-wrap onion (rumor kind 14 → seal kind 13 → wrap
|
||||
|
|
@ -12,7 +12,7 @@ import 'nostr_connection.dart';
|
|||
class NostrMessageTransport implements MessageTransport {
|
||||
NostrMessageTransport(this._conn);
|
||||
|
||||
final NostrConnection _conn;
|
||||
final NostrChannel _conn;
|
||||
|
||||
static const kindGiftWrap = 1059;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,13 +3,14 @@ import 'package:nostr/nostr.dart';
|
|||
import '../offer.dart';
|
||||
import '../offer_transport.dart';
|
||||
import 'nip99_codec.dart';
|
||||
import 'nostr_connection.dart';
|
||||
import 'nostr_channel.dart';
|
||||
|
||||
/// Nostr NIP-99 backend for [OfferTransport], on a shared [NostrConnection].
|
||||
/// Nostr NIP-99 backend for [OfferTransport], on a shared channel (one relay or
|
||||
/// a [RelayPool]).
|
||||
class NostrOfferTransport implements OfferTransport {
|
||||
NostrOfferTransport(this._conn);
|
||||
|
||||
final NostrConnection _conn;
|
||||
final NostrChannel _conn;
|
||||
final Nip99Codec _codec = Nip99Codec();
|
||||
|
||||
Filter _filter(DiscoveryQuery q) => Filter(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import 'package:nostr/nostr.dart';
|
|||
|
||||
import '../certification.dart';
|
||||
import '../trust_transport.dart';
|
||||
import 'nostr_connection.dart';
|
||||
import 'nostr_channel.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
|
||||
|
|
@ -12,7 +12,7 @@ import 'nostr_connection.dart';
|
|||
class NostrTrustTransport implements TrustTransport {
|
||||
NostrTrustTransport(this._conn);
|
||||
|
||||
final NostrConnection _conn;
|
||||
final NostrChannel _conn;
|
||||
|
||||
/// Custom Tanemaki certification kind (addressable range).
|
||||
static const kindCertification = 30777;
|
||||
|
|
|
|||
123
packages/commons_core/lib/src/social/nostr/relay_pool.dart
Normal file
123
packages/commons_core/lib/src/social/nostr/relay_pool.dart
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:nostr/nostr.dart';
|
||||
|
||||
import '../../identity/nostr_key_derivation.dart';
|
||||
import 'nostr_channel.dart';
|
||||
import 'nostr_connection.dart';
|
||||
|
||||
/// A fault-tolerant [NostrChannel] over several relays — the relay-strategy
|
||||
/// hardening (network-trust.md §3: a few community relays for reliability).
|
||||
///
|
||||
/// Publishes to every live relay (accepted if any accepts), discovers from all
|
||||
/// of them de-duplicated by event id, and simply skips relays that are down —
|
||||
/// so one flaky community relay never breaks sharing.
|
||||
class RelayPool implements NostrChannel {
|
||||
RelayPool._(this._connections, this.identity);
|
||||
|
||||
final List<NostrConnection> _connections;
|
||||
final NostrIdentity identity;
|
||||
|
||||
@override
|
||||
String get privateKeyHex => identity.privateKeyHex;
|
||||
@override
|
||||
String get publicKeyHex => identity.publicKeyHex;
|
||||
|
||||
/// Number of relays currently connected (for diagnostics/tests).
|
||||
int get relayCount => _connections.length;
|
||||
|
||||
/// Connects to each of [relayUrls], skipping any that fail. Throws
|
||||
/// [StateError] only when NONE are reachable, so the caller can treat that as
|
||||
/// "offline".
|
||||
static Future<RelayPool> connect(
|
||||
List<String> relayUrls, {
|
||||
required NostrIdentity identity,
|
||||
}) async {
|
||||
final connections = <NostrConnection>[];
|
||||
await Future.wait(
|
||||
relayUrls.map((url) async {
|
||||
try {
|
||||
connections.add(
|
||||
await NostrConnection.connect(url, identity: identity),
|
||||
);
|
||||
} catch (_) {
|
||||
// Unreachable relay — skip it, keep the rest.
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (connections.isEmpty) {
|
||||
throw StateError('no relays reachable');
|
||||
}
|
||||
return RelayPool._(connections, identity);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<({bool accepted, String message})> publish(Event event) async {
|
||||
final results = await Future.wait(
|
||||
_connections.map((c) async {
|
||||
try {
|
||||
return await c.publish(event);
|
||||
} catch (_) {
|
||||
return (accepted: false, message: 'relay error');
|
||||
}
|
||||
}),
|
||||
);
|
||||
final accepted = results.any((r) => r.accepted);
|
||||
return (accepted: accepted, message: accepted ? '' : 'no relay accepted');
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Event> subscribe(Filter filter) {
|
||||
final seen = <String>{};
|
||||
final subs = <StreamSubscription<Event>>[];
|
||||
late StreamController<Event> controller;
|
||||
controller = StreamController<Event>(
|
||||
onListen: () {
|
||||
for (final c in _connections) {
|
||||
subs.add(
|
||||
c.subscribe(filter).listen(
|
||||
(e) {
|
||||
if (seen.add(e.id)) controller.add(e);
|
||||
},
|
||||
onError: (_) {}, // one relay erroring shouldn't kill the merge
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onCancel: () async {
|
||||
for (final s in subs) {
|
||||
await s.cancel();
|
||||
}
|
||||
},
|
||||
);
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Event>> reqOnce(Filter filter) async {
|
||||
final lists = await Future.wait(
|
||||
_connections.map((c) async {
|
||||
try {
|
||||
return await c.reqOnce(filter);
|
||||
} catch (_) {
|
||||
return <Event>[];
|
||||
}
|
||||
}),
|
||||
);
|
||||
final seen = <String>{};
|
||||
final out = <Event>[];
|
||||
for (final list in lists) {
|
||||
for (final e in list) {
|
||||
if (seen.add(e.id)) out.add(e);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
for (final c in _connections) {
|
||||
await c.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue