diff --git a/mobile/README.md b/mobile/README.md index abddd8c57f..5cc5527107 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -83,8 +83,8 @@ runner. The internal release toolchain is configured separately in Use `shared/crypto/event_signer.dart` for signed events. iOS calls the existing CSecp256k1 library through synchronous FFI; other platforms retain the Dart -signer. Burst-producing media authentication retains its bounded worker path -because Debug Simulator bursts still exceed a frame budget. Event hashing remains in `nostr.Event.unsigned`. The native context is +signer. This change preserves existing call scheduling; worker offloading is a +separate change. Event hashing remains in `nostr.Event.unsigned`. The native context is randomized once and shared read-only; each call creates, self-verifies with, and destroys its own keypair. It never caches account secrets. @@ -94,6 +94,12 @@ cross-implementation tests additionally accept `BUZZ_SIGNER_LIBRARY` pointing to a locally compiled adapter library. `tool/benchmark_event_signer.dart` measures complete host calls; `tool/native_signing_probe.dart` is an explicit Simulator entry point for complete event, media-auth, and NIP-98 measurements. +`tool/compare_event_signers.dart` compares the existing Dart signer (with a +cached public key) and native signing in the same iOS process, on the UI isolate, +with identical inputs and alternating order. Seven warm samples on a Debug +iPhone 17 Pro Simulator measured median 64-event bursts of 209.7 ms (Dart) and +50.2 ms (native), a 76% reduction. These are synchronous event-construction +timings, not UIKit main-thread CPU measurements or rendered-frame timings. Neither benchmark represents physical-device release performance. ## Run diff --git a/mobile/lib/shared/crypto/event_signer.dart b/mobile/lib/shared/crypto/event_signer.dart index 2a867d32b3..a1115ce9f8 100644 --- a/mobile/lib/shared/crypto/event_signer.dart +++ b/mobile/lib/shared/crypto/event_signer.dart @@ -7,11 +7,13 @@ import 'package:nostr/nostr.dart' as nostr; /// Signs canonical Nostr events with libsecp256k1 on iOS. /// /// Native keypairs exist only for this call and are wiped before returning. -/// Other platforms retain the existing Dart implementation. +/// Other platforms retain the existing Dart implementation, including the +/// optional cached [pubkey]. iOS derives its public key from the native keypair. nostr.Event signEvent({ required int kind, required String content, required String secretKey, + String? pubkey, int? createdAt, List> tags = const [], bool verify = false, @@ -21,6 +23,7 @@ nostr.Event signEvent({ kind: kind, content: content, secretKey: secretKey, + pubkey: pubkey, createdAt: createdAt, tags: tags, verify: verify, diff --git a/mobile/tool/compare_event_signers.dart b/mobile/tool/compare_event_signers.dart new file mode 100644 index 0000000000..9607682808 --- /dev/null +++ b/mobile/tool/compare_event_signers.dart @@ -0,0 +1,87 @@ +import 'dart:io'; +import 'package:buzz/shared/crypto/event_signer.dart'; +import 'package:flutter/material.dart'; +import 'package:nostr/nostr.dart' as nostr; + +// Run on iOS with the same event construction on the UI isolate for both paths. +void main() { + WidgetsFlutterBinding.ensureInitialized(); + try { + if (!Platform.isIOS) throw StateError('Requires iOS'); + final results = []; + final key = '0' * 63 + '3'; + final publicKey = nostr.Keys(key).public; + nostr.Event create(bool native) { + final tags = [ + ['t', 'get'], + ['expiration', '1800000060'], + ]; + return native + ? signEvent( + kind: 24242, + content: 'Get buzz-media', + tags: tags, + createdAt: 1800000000, + secretKey: key, + pubkey: publicKey, + ) + : nostr.Event.from( + kind: 24242, + content: 'Get buzz-media', + tags: tags, + createdAt: 1800000000, + secretKey: key, + pubkey: publicKey, + ); + } + + for (final native in [false, true]) { + final watch = Stopwatch()..start(); + final event = create(native); + watch.stop(); + results.add( + 'first measured ${native ? "native" : "Dart"}: ${watch.elapsedMicroseconds} us', + ); + if (!event.isValid()) throw StateError('Invalid signature'); + } + if (create(false).id != create(true).id) { + throw StateError('Different events'); + } + for (var warm = 0; warm < 10; warm++) { + create(false); + create(true); + } + for (final count in [1, 8, 32, 64]) { + final samples = {false: [], true: []}; + for (var round = 0; round < 7; round++) { + for (final native in round.isEven ? [false, true] : [true, false]) { + final watch = Stopwatch()..start(); + for (var i = 0; i < count; i++) { + create(native); + } + watch.stop(); + samples[native]!.add(watch.elapsedMicroseconds); + } + } + for (final native in [false, true]) { + final values = samples[native]!..sort(); + results.add( + 'burst $count ${native ? "native" : "Dart"}: ' + 'median ${values[3]} min ${values.first} max ${values.last} us', + ); + } + } + for (final result in results) { + debugPrint('SIGNING_PROBE $result'); + } + debugPrint('SIGNING_PROBE COMPLETE'); + runApp( + MaterialApp( + home: Scaffold(body: SafeArea(child: Text(results.join('\n')))), + ), + ); + } catch (error) { + debugPrint('SIGNING_PROBE FAILED: $error'); + rethrow; + } +} diff --git a/mobile/tool/native_signing_probe.dart b/mobile/tool/native_signing_probe.dart index c9c99f6c36..edc166f7b4 100644 --- a/mobile/tool/native_signing_probe.dart +++ b/mobile/tool/native_signing_probe.dart @@ -61,7 +61,7 @@ Future runProbe() async { final watch = Stopwatch()..start(); final proofs = >[]; for (var i = 0; i < 64; i++) { - proofs.add(await media.headersForAsync('https://relay.example/media/$i')); + proofs.add(media.headersFor('https://relay.example/media/$i')); } results.add('64 complete media headers: ${watch.elapsedMicroseconds} us'); for (final proof in proofs) { @@ -69,9 +69,7 @@ Future runProbe() async { final event = nostr.Event.fromJson(utf8.decode(base64Decode(encoded))); if (!event.isValid()) throw StateError('Invalid media proof'); } - if ((await media.headersForAsync( - 'https://foreign.example/media/x', - )).isNotEmpty) { + if ((media.headersFor('https://foreign.example/media/x')).isNotEmpty) { throw StateError('Foreign host received credentials'); } results.add('media signatures and foreign-host isolation: PASS'); diff --git a/scripts/test-mobile-linked-signing.sh b/scripts/test-mobile-linked-signing.sh index 18b65fa724..3cb675881a 100644 --- a/scripts/test-mobile-linked-signing.sh +++ b/scripts/test-mobile-linked-signing.sh @@ -11,7 +11,7 @@ xcodebuild -workspace mobile/ios/Runner.xcworkspace -scheme Runner \ -configuration Debug -sdk iphonesimulator -destination "id=$device" \ -derivedDataPath "${BUZZ_SIGNING_DERIVED_DATA:-$fixture/build}" -jobs 2 \ BUNDLE_IDENTIFIER="$bundle" CODE_SIGNING_ALLOWED=NO ONLY_ACTIVE_ARCH=YES \ - FLUTTER_TARGET=tool/native_signing_probe.dart build > "$fixture/build.log" 2>&1 || { + FLUTTER_TARGET="${BUZZ_SIGNING_PROBE_TARGET:-tool/native_signing_probe.dart}" build > "$fixture/build.log" 2>&1 || { cat "$fixture/build.log" exit 1 }