mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
Follows #8654. Deriving an x-only public key from a secret key with `nostr.Keys(secret).public` costs about 5.6 ms in pure Dart on an iPhone SE (2nd gen, Profile). The app does it in 10 places, including relay config, the signed-event relay's `pubkey` getter, push lease checks, status updates, and the settings identity row's `build` method. This routes all of them through `derivePublicKeyHex` from #8654, which takes 16.5 µs on the same device. Behavior is unchanged for valid keys: output is the same lowercase hex. For a malformed or out-of-range key the thrown type changes from nostr's `InvalidKeyException` to `ArgumentError`; every affected site either catches all errors or rethrows its own `FormatException`/`StateError`, and nothing in `lib` or `test` matches on the old type. Sites that need a full `nostr.Keys` object (key generation, `AccountIdentityStore`) are unchanged. ### Validation - `derivePublicKeyHex` is checked against `nostr.Keys` for 64 random keys in #8654's `event_signing_test.dart`. - Full mobile analyze and test suite pass on this branch after rebasing onto main, including the package's own tests. - iPhone 12 mini, Profile build of this stack (with #8656 and #8657 on top): paired to the internal Buzz community through Desktop; relay auth, identity row, avatar, and channel/DM/inbox loading all worked under the derived key. buzz-review-completed (agent review, Codex and security review clean; device validation above; human app test not applicable under the mobile testing exception) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: george <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
134 lines
3.8 KiB
Dart
134 lines
3.8 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
|
|
import 'nostr_models.dart';
|
|
import 'relay_session.dart';
|
|
import 'relay_socket.dart';
|
|
import 'package:buzz/shared/crypto/event_signing.dart';
|
|
|
|
/// Signs and submits Nostr events through the relay WebSocket connection.
|
|
class SignedEventRelay {
|
|
final RelaySessionNotifier _session;
|
|
final String? _nsec;
|
|
|
|
SignedEventRelay({
|
|
required RelaySessionNotifier session,
|
|
required String? nsec,
|
|
}) : _session = session,
|
|
_nsec = nsec;
|
|
|
|
/// The hex pubkey derived from the signing key, or null if no key.
|
|
String? get pubkey {
|
|
final nsec = _nsec;
|
|
if (nsec == null || nsec.isEmpty) return null;
|
|
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
|
|
if (privkeyHex.isEmpty) return null;
|
|
return derivePublicKeyHex(privkeyHex);
|
|
}
|
|
|
|
/// Sign and submit an event. Returns the relay's OK response as a [NostrEvent]
|
|
/// whose `content` field contains the OK message (e.g. `"response:{...}"`
|
|
/// for command kinds).
|
|
Future<NostrEvent> submit({
|
|
required int kind,
|
|
required String content,
|
|
required List<List<String>> tags,
|
|
int? createdAt,
|
|
void Function(NostrEvent event)? onSigned,
|
|
}) async {
|
|
final nsec = _nsec;
|
|
if (nsec == null || nsec.isEmpty) {
|
|
throw Exception('Cannot submit event: no signing key available');
|
|
}
|
|
|
|
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
|
|
if (privkeyHex.isEmpty) {
|
|
throw Exception('Invalid nsec');
|
|
}
|
|
|
|
final event = signNostrEvent(
|
|
kind: kind,
|
|
content: content,
|
|
tags: tags,
|
|
secretKey: privkeyHex,
|
|
createdAt: createdAt,
|
|
);
|
|
|
|
final nostrEvent = NostrEvent.fromJson(event.toMap());
|
|
onSigned?.call(nostrEvent);
|
|
return _session.publish(nostrEvent);
|
|
}
|
|
}
|
|
|
|
/// Publishes one signed event over a short-lived authenticated NIP-42 socket.
|
|
///
|
|
/// This is used for community-removal tombstones because the community being
|
|
/// removed is not necessarily the app's active relay session.
|
|
Future<NostrEvent> submitSignedEventOnce({
|
|
required String wsUrl,
|
|
required String nsec,
|
|
required int kind,
|
|
required String content,
|
|
required List<List<String>> tags,
|
|
int? createdAt,
|
|
Duration timeout = const Duration(seconds: 12),
|
|
Map<String, String> headers = const {},
|
|
}) async {
|
|
final privateKey = nostr.Nip19.decode(payload: nsec).data;
|
|
if (privateKey.isEmpty) throw const FormatException('Invalid nsec');
|
|
final signed = signNostrEvent(
|
|
kind: kind,
|
|
content: content,
|
|
tags: tags,
|
|
secretKey: privateKey,
|
|
createdAt: createdAt,
|
|
);
|
|
final event = NostrEvent.fromJson(signed.toMap());
|
|
final result = Completer<NostrEvent>();
|
|
late final RelaySocket socket;
|
|
socket = RelaySocket(
|
|
wsUrl: wsUrl,
|
|
nsec: nsec,
|
|
onMessage: (message) {
|
|
if (message case [
|
|
'OK',
|
|
final String eventId,
|
|
final bool accepted,
|
|
final String detail,
|
|
...,
|
|
] when eventId == event.id) {
|
|
if (accepted) {
|
|
result.complete(
|
|
NostrEvent(
|
|
id: event.id,
|
|
pubkey: event.pubkey,
|
|
createdAt: event.createdAt,
|
|
kind: event.kind,
|
|
tags: event.tags,
|
|
content: detail,
|
|
sig: event.sig,
|
|
),
|
|
);
|
|
} else {
|
|
result.completeError(Exception('Relay rejected event: $detail'));
|
|
}
|
|
}
|
|
},
|
|
onConnected: () => socket.send(['EVENT', event.toJson()]),
|
|
onDisconnected: (error) {
|
|
if (!result.isCompleted) {
|
|
result.completeError(error ?? Exception('Relay disconnected'));
|
|
}
|
|
},
|
|
);
|
|
socket.headers = headers;
|
|
final resultFuture = result.future.timeout(timeout);
|
|
try {
|
|
await socket.connect();
|
|
return await resultFuture;
|
|
} finally {
|
|
await socket.disconnect();
|
|
}
|
|
}
|