diff --git a/lib/services/app_catalog_relay_service.dart b/lib/services/app_catalog_relay_service.dart index 758d465..f7fccbb 100644 --- a/lib/services/app_catalog_relay_service.dart +++ b/lib/services/app_catalog_relay_service.dart @@ -10,6 +10,7 @@ import 'package:zapstore/constants/app_constants.dart'; import 'package:zapstore/router.dart'; import 'package:zapstore/services/app_restart_service.dart'; import 'package:zapstore/services/device_key_service.dart'; +import 'package:zapstore/services/device_private_event_service.dart'; import 'package:zapstore/services/log_service.dart'; const _handoffKey = 'pending_app_catalog_relay_event'; @@ -88,7 +89,11 @@ class AppCatalogRelayService { _applyRelayGroup(relays); if (restored != null) { - unawaited(_publish(restored)); + unawaited( + ref + .read(devicePrivateEventServiceProvider) + .queueExistingDraft(restored), + ); } } @@ -102,15 +107,13 @@ class AppCatalogRelayService { if (devicePubkey == null) { throw StateError('Device key is not ready'); } - final signer = ref.read(Signer.signerProvider(devicePubkey)); - if (signer == null) { - throw StateError('Device signer is not available'); + final draft = await ref + .read(devicePrivateEventServiceProvider) + .saveDraftAndQueue(PartialAppCatalogRelayList(relays: relays)); + if (draft is! AppCatalogRelayList) { + throw StateError('Could not save the local AppCatalog relay-list draft.'); } - - final event = await PartialAppCatalogRelayList( - relays: relays, - ).signWith(signer); - await _stageAndRestart(event); + await _stageAndRestart(draft); } /// Checks only the hardcoded Zapstore relay for this device's list. @@ -383,8 +386,8 @@ class AppCatalogRelayService { Future _publish(AppCatalogRelayList event) async { try { await ref - .read(storageNotifierProvider.notifier) - .publish({event}, relays: const {kDefaultRelay}); + .read(devicePrivateEventServiceProvider) + .queueExistingDraft(event); } catch (error, stack) { LogService.I.warn( 'app catalog relay publish failed', diff --git a/lib/services/bookmarks_service.dart b/lib/services/bookmarks_service.dart index 9a2427e..9b91967 100644 --- a/lib/services/bookmarks_service.dart +++ b/lib/services/bookmarks_service.dart @@ -144,7 +144,7 @@ Future _writeBookmarks( platform: platform, ); partial.event.createdAt = createdAt; - await ref.read(devicePrivateEventServiceProvider).signAndSave(partial); + await ref.read(devicePrivateEventServiceProvider).saveDraftAndQueue(partial); } Future toggleBookmark(WidgetRef ref, App app) { diff --git a/lib/services/device_private_event_service.dart b/lib/services/device_private_event_service.dart index 23ebdb7..5d64947 100644 --- a/lib/services/device_private_event_service.dart +++ b/lib/services/device_private_event_service.dart @@ -1,10 +1,107 @@ +import 'dart:async'; +import 'dart:convert'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:models/models.dart'; import 'package:purplebase/purplebase.dart'; import 'package:zapstore/constants/app_constants.dart'; import 'package:zapstore/services/device_key_service.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:zapstore/services/log_service.dart'; -const kPrivateEventPowDifficulty = 16; +const kDeviceEventPowDifficulty = 20; +const _pendingDeviceEventsKeyPrefix = 'pending_device_events'; + +final class PendingDeviceEvent { + const PendingDeviceEvent({required this.kind, this.identifier}); + + final int kind; + final String? identifier; + + Map toJson() => { + 'kind': kind, + if (identifier != null) 'identifier': identifier, + }; + + static PendingDeviceEvent? fromJson(Object? value) { + if (value is! Map) return null; + final kind = value['kind']; + final identifier = value['identifier']; + if (kind is! int || + (identifier != null && (identifier is! String || identifier.isEmpty))) { + return null; + } + return PendingDeviceEvent(kind: kind, identifier: identifier as String?); + } + + @override + bool operator ==(Object other) => + other is PendingDeviceEvent && + other.kind == kind && + other.identifier == identifier; + + @override + int get hashCode => Object.hash(kind, identifier); +} + +abstract interface class PendingDeviceEventsStore { + Future> load(String devicePubkey); + + Future save(String devicePubkey, Set events); +} + +class SecureStoragePendingDeviceEventsStore + implements PendingDeviceEventsStore { + SecureStoragePendingDeviceEventsStore({FlutterSecureStorage? storage}) + : _storage = + storage ?? + const FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions( + accessibility: KeychainAccessibility.first_unlock, + ), + ); + + final FlutterSecureStorage _storage; + + String _keyFor(String devicePubkey) => + '$_pendingDeviceEventsKeyPrefix:$devicePubkey'; + + @override + Future> load(String devicePubkey) async { + final raw = await _storage.read(key: _keyFor(devicePubkey)); + if (raw == null || raw.isEmpty) return {}; + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return {}; + return { + for (final value in decoded) + if (PendingDeviceEvent.fromJson(value) case final event?) event, + }; + } catch (_) { + return {}; + } + } + + @override + Future save(String devicePubkey, Set events) async { + final key = _keyFor(devicePubkey); + if (events.isEmpty) { + await _storage.delete(key: key); + return; + } + final encoded = events.map((event) => event.toJson()).toList() + ..sort((a, b) { + final kind = (a['kind'] as int).compareTo(b['kind'] as int); + return kind != 0 + ? kind + : (a['identifier'] as String? ?? '').compareTo( + b['identifier'] as String? ?? '', + ); + }); + await _storage.write(key: key, value: jsonEncode(encoded)); + } +} final privateEventPowExecutorProvider = Provider(( ref, @@ -20,6 +117,7 @@ final devicePrivateEventServiceProvider = Provider(( return DevicePrivateEventService( ref, executor: ref.watch(privateEventPowExecutorProvider), + pendingEventsStore: SecureStoragePendingDeviceEventsStore(), ); }); @@ -28,18 +126,32 @@ class DevicePrivateEventService { DevicePrivateEventService( this.ref, { required ProofOfWorkExecutor executor, - this.difficulty = kPrivateEventPowDifficulty, - this.timeout = const Duration(seconds: 10), - this.maxAttempts = 1 << 21, + PendingDeviceEventsStore? pendingEventsStore, + this.startProcessing = true, + this.difficulty = kDeviceEventPowDifficulty, + this.timeout = const Duration(days: 3650), + this.maxAttempts = 1 << 62, this.batchSize = 512, - }) : _executor = executor; + }) : _executor = executor, + _pendingEventsStore = + pendingEventsStore ?? SecureStoragePendingDeviceEventsStore() { + ref.onDispose(() { + _disposed = true; + cancelMining(); + }); + } final Ref ref; final ProofOfWorkExecutor _executor; + final PendingDeviceEventsStore _pendingEventsStore; + final bool startProcessing; final int difficulty; final Duration timeout; final int maxAttempts; final int batchSize; + Future? _draftWriteQueue; + Future? _processingQueue; + bool _disposed = false; String get devicePubkey { final pubkey = ref.read(devicePubkeyProvider); @@ -74,13 +186,244 @@ class DevicePrivateEventService { Future encryptFor(String plaintext, String recipientPubkey) => deviceSigner.nip44Encrypt(plaintext, recipientPubkey); - Future signAndSave>( - PartialModel partial, { + /// Save a local-only draft first, then asynchronously queue its relay copy. + /// + /// The draft deliberately has no PoW and is never published. It survives + /// mining cancellation so [processPendingEvents] can rebuild it later. + Future> saveDraftAndQueue(PartialModel partial) { + final previous = _draftWriteQueue ?? Future.value(); + final operation = previous.then((_) => _saveDraftAndQueue(partial)); + _draftWriteQueue = operation.then( + (_) {}, + onError: (Object _, StackTrace __) {}, + ); + return operation; + } + + Future> _saveDraftAndQueue( + PartialModel partial, + ) async { + _validatePartial(partial); + final pending = _pendingFor(partial); + final existing = await _loadLatestDraft(pending); + partial.event.createdAt = nextReplaceableTimestamp( + existing?.event.createdAt, + ); + final draft = await _signAndSave(partial, publish: false); + await _updatePendingEvents((events) => {...events, pending}); + _logPendingEvent('local draft saved and queued', pending); + if (startProcessing) unawaited(processPendingEvents()); + return draft; + } + + /// Queue an already-saved local draft, such as a relay-list handoff restored + /// after an application restart. + Future queueExistingDraft(Model draft) async { + if (draft.pubkey != devicePubkey || !verifySignedEvent(ref, draft.event)) { + throw const DevicePrivateEventException( + 'Pending device event draft is invalid.', + ); + } + final pending = PendingDeviceEvent( + kind: draft.event.kind, + identifier: draft.event.getFirstTagValue('d'), + ); + await _updatePendingEvents((events) => {...events, pending}); + _logPendingEvent('restored local draft queued', pending); + unawaited(processPendingEvents()); + } + + /// Start the persistent device-event queue. Repeated calls share a drain. + Future processPendingEvents() { + if (_disposed) { + LogService.I.debug( + 'queue trigger ignored after disposal', + tag: 'device-event-queue', + ); + return Future.value(); + } + if (ref.read(devicePubkeyProvider) == null) { + LogService.I.debug( + 'queue trigger ignored without device key', + tag: 'device-event-queue', + ); + return Future.value(); + } + final existing = _processingQueue; + if (existing != null) { + LogService.I.debug( + 'queue drain already active', + tag: 'device-event-queue', + ); + return existing; + } + LogService.I.info('queue drain started', tag: 'device-event-queue'); + late final Future processing; + processing = _processPendingEvents().whenComplete(() { + if (identical(_processingQueue, processing)) { + _processingQueue = null; + } + }); + _processingQueue = processing; + return processing; + } + + Future _processPendingEvents() async { + final attempted = {}; + while (true) { + if (_disposed) return; + final pending = await _loadPendingEvents(); + if (_disposed) return; + final next = pending + .where((event) => !attempted.contains(event)) + .firstOrNull; + if (next == null) { + LogService.I.info( + 'queue drain finished', + tag: 'device-event-queue', + fields: {'attempted': attempted.length}, + ); + return; + } + attempted.add(next); + try { + await _processPendingEvent(next); + } catch (error, stack) { + // The marker remains. A future mutation, connectivity restoration, app + // resume, or startup will retry without polling. + LogService.I.warn( + 'device event queue failed', + tag: 'device-event-queue', + fields: _pendingEventFields(next), + err: error, + stack: stack, + ); + } + } + } + + Future _processPendingEvent(PendingDeviceEvent pending) async { + final draft = await _loadLatestDraft(pending); + if (draft == null) { + await _updatePendingEvents((events) => events..remove(pending)); + _logPendingEvent('marker removed because local draft is absent', pending); + return; + } + if (draft.pubkey != devicePubkey || !verifySignedEvent(ref, draft.event)) { + throw const DevicePrivateEventException( + 'Pending device event draft is invalid.', + ); + } + + _logPendingEvent('mining or publishing queued draft', pending); + final signed = Nip13.isValid(draft.event, minimumDifficulty: difficulty) + ? draft + : await _signAndSave( + switch (pending.kind) { + 10067 => PartialAppCatalogRelayList.fromMap(draft.toMap()), + 30078 => PartialCustomData.fromMap(draft.toMap()), + 30267 => PartialAppStack.fromMap(draft.toMap()), + _ => throw DevicePrivateEventException( + 'Unsupported pending device event kind: ${pending.kind}', + ), + }, + proofOfWork: proofOfWork, + publish: false, + ); + _logPendingEvent('publishing proof-of-work event', pending); + final response = await ref.read(storageNotifierProvider.notifier).publish({ + signed, + }, relays: _relayTargetFor(pending.kind)); + final accepted = + response.results[signed.event.id]?.any((result) => result.accepted) ?? + false; + if (!accepted) { + throw const DevicePrivatePublishException( + 'Saved locally, but no AppCatalog relay accepted the event.', + ); + } + + final latest = await _loadLatestDraft(pending); + if (latest != null && !latest.createdAt.isAfter(draft.createdAt)) { + await _updatePendingEvents((events) => events..remove(pending)); + _logPendingEvent('relay accepted event and marker removed', pending); + } else { + _logPendingEvent( + 'relay accepted older event; newer draft remains queued', + pending, + ); + } + } + + Future> _loadPendingEvents() => + _pendingEventsStore.load(devicePubkey); + + Future _updatePendingEvents( + Set Function(Set) update, + ) async { + final current = await _loadPendingEvents(); + await _pendingEventsStore.save(devicePubkey, update({...current})); + } + + PendingDeviceEvent _pendingFor(PartialModel partial) { + final requiresIdentifier = partial.event.kind != 10067; + final identifier = partial.event.identifier; + if (requiresIdentifier && (identifier == null || identifier.isEmpty)) { + throw const DevicePrivateEventException( + 'Private device event requires a d identifier.', + ); + } + return PendingDeviceEvent(kind: partial.event.kind, identifier: identifier); + } + + Future?> _loadLatestDraft(PendingDeviceEvent pending) async { + final events = await ref + .read(storageNotifierProvider.notifier) + .query( + RequestFilter>( + kinds: {pending.kind}, + authors: {devicePubkey}, + tags: pending.identifier == null + ? const {} + : { + '#d': {pending.identifier!}, + }, + limit: 1, + ).toRequest(), + source: const LocalSource(), + subscriptionPrefix: 'app-device-event-draft', + ); + return events.firstOrNull; + } + + dynamic _relayTargetFor(int kind) => switch (kind) { + 10067 => const {kDefaultRelay}, + _ => 'AppCatalog', + }; + + void _logPendingEvent(String message, PendingDeviceEvent event) { + LogService.I.info( + message, + tag: 'device-event-queue', + fields: _pendingEventFields(event), + ); + } + + Map _pendingEventFields(PendingDeviceEvent event) => { + 'kind': event.kind, + if (event.identifier != null) 'identifier': event.identifier, + }; + + Future> _signAndSave( + PartialModel partial, { bool publish = true, + ProofOfWorkOptions? proofOfWork, }) async { _validatePartial(partial); final signer = deviceSigner; - final signed = await partial.signWith(signer, proofOfWork: proofOfWork); + final signed = + await partial.signWith(signer, proofOfWork: proofOfWork) + as Model; _validateSigned(signed); final storage = ref.read(storageNotifierProvider.notifier); @@ -141,12 +484,17 @@ class DevicePrivateEventService { } case 30078: final identifier = event.identifier; - if (identifier != kSettingsIdentifier && - identifier != kTrustedSignersIdentifier) { + if (identifier != kDeviceStateIdentifier) { throw DevicePrivateEventException( 'Unsupported private CustomData identifier: $identifier', ); } + case 10067: + if (event.content.isNotEmpty) { + throw const DevicePrivateEventException( + 'App Catalog relay lists must not have content.', + ); + } default: throw DevicePrivateEventException( 'Unsupported private event kind: ${event.kind}', @@ -165,11 +513,6 @@ class DevicePrivateEventService { 'Private event signature or event ID is invalid.', ); } - if (!Nip13.isValid(signed.event, minimumDifficulty: difficulty)) { - throw DevicePrivateEventException( - 'Private event does not meet $difficulty-bit proof of work.', - ); - } } } diff --git a/lib/services/unmanaged_apps_service.dart b/lib/services/unmanaged_apps_service.dart index 2253eb0..9f81c91 100644 --- a/lib/services/unmanaged_apps_service.dart +++ b/lib/services/unmanaged_apps_service.dart @@ -231,7 +231,9 @@ Future _writeUnmanagedApps( ); try { - await ref.read(devicePrivateEventServiceProvider).signAndSave(partial); + await ref + .read(devicePrivateEventServiceProvider) + .saveDraftAndQueue(partial); } on DevicePrivateSaveException catch (error) { throw UnmanagedAppsSaveException(error.message); } on DevicePrivatePublishException catch (error) { diff --git a/lib/services/updates_service.dart b/lib/services/updates_service.dart index d5687db..bd06a63 100644 --- a/lib/services/updates_service.dart +++ b/lib/services/updates_service.dart @@ -324,7 +324,7 @@ class UpdatePollerNotifier extends StateNotifier { await ref .read(devicePrivateEventServiceProvider) - .signAndSave(partialStack); + .saveDraftAndQueue(partialStack); _lastBackedUpIds = appIds; } catch (e, st) { LogService.I.warn( diff --git a/spec/work/WORK-020-pow-progress.md b/spec/work/WORK-020-pow-progress.md new file mode 100644 index 0000000..6a45ba8 --- /dev/null +++ b/spec/work/WORK-020-pow-progress.md @@ -0,0 +1,40 @@ +# WORK-020 — PoW Difficulty and Progress + +**Feature:** User-requested bootstrap proof-of-work feedback +**Status:** Complete + +## Tasks + +- [x] 1. Increase bootstrap proof-of-work difficulty to 24 bits. + - Files: `lib/services/device_private_event_service.dart` +- [x] 2. Show elapsed mining time beneath the progress spinner. + - Files: `lib/services/device_state_service.dart`, `lib/screens/profile_screen.dart` +- [x] 3. Verify isolate mining and lifecycle behavior. +- [x] 4. Self-review against `INVARIANTS.md` + +## Test Coverage + +| Scenario | Expected | Status | +|----------|----------|--------| +| Bootstrap policy | Uses 24 proof-of-work bits | [x] | +| Bootstrap in progress | Spinner and elapsed time are visible | [x] | +| App backgrounded | Mining is not cancelled by the app lifecycle observer | [x] | +| Bootstrap failure | UI leaves the indefinite progress state | [x] | + +## Decisions + +### 2026-07-14 — Elapsed time source + +**Context:** The mining executor does not expose progress callbacks to the UI. +**Decision:** Store the bootstrap start time in the device-state status and derive +elapsed time in the widget. This keeps mining isolated from presentation code. + +## Spec Issues + +_None_ + +## Progress Notes + +**2026-07-14:** Reduced bootstrap difficulty to 24 bits, added elapsed mining +feedback, and confirmed the focused service tests pass. Flutter analyze passes +when run with a writable temporary `HOME` and the project package cache. diff --git a/spec/work/WORK-021-pow-26-bits.md b/spec/work/WORK-021-pow-26-bits.md new file mode 100644 index 0000000..fb222eb --- /dev/null +++ b/spec/work/WORK-021-pow-26-bits.md @@ -0,0 +1,35 @@ +# WORK-021 — Increase Bootstrap PoW to 26 Bits + +**Feature:** Bootstrap proof-of-work difficulty adjustment +**Status:** Complete + +## Tasks + +- [x] 1. Increase bootstrap proof-of-work difficulty to 26 bits. + - Files: `lib/services/device_private_event_service.dart` +- [x] 2. Update the production policy test. + - Files: `test/services/device_private_event_service_test.dart` +- [x] 3. Self-review against `INVARIANTS.md` + +## Test Coverage + +| Scenario | Expected | Status | +|----------|----------|--------| +| Bootstrap policy | Uses 26 proof-of-work bits | [x] | +| Mining lifecycle | Existing isolate and cancellation behavior remains unchanged | [x] | + +## Decisions + +### 2026-07-15 — Bootstrap difficulty + +**Context:** The bootstrap event currently uses 24 bits and should use the requested higher difficulty. +**Decision:** Set the production bootstrap policy to 26 bits. +**Rationale:** 26 bits doubles the expected work over 25 bits while retaining the existing background isolate execution and lifecycle behavior. + +## Spec Issues + +_None_ + +## Progress Notes + +**2026-07-15:** Updated bootstrap PoW policy and focused test expectation to 26 bits. diff --git a/spec/work/WORK-024-device-event-pow-queue.md b/spec/work/WORK-024-device-event-pow-queue.md new file mode 100644 index 0000000..083a8d9 --- /dev/null +++ b/spec/work/WORK-024-device-event-pow-queue.md @@ -0,0 +1,68 @@ +# WORK-024 — Device Event PoW Queue + +**Feature:** FEAT-006-device-key.md +**Status:** In Progress + +## Tasks + +- [x] 1. Add secure-storage pending markers keyed by device-event kind and `d` + tag. + - Files: `lib/services/device_private_event_service.dart` +- [x] 2. Save local-only device-event drafts before queueing PoW and relay work. + - Files: `lib/services/device_private_event_service.dart`, + `lib/services/device_state_service.dart` +- [x] 3. Process pending markers asynchronously at startup, after mutations, + and on app resume without polling. + - Files: `lib/main.dart`, `lib/services/device_private_event_service.dart` +- [x] 4. Route bookmarks, installed-app backups, and unmanaged-app writes + through the queued draft workflow. + - Files: `lib/services/bookmarks_service.dart`, + `lib/services/updates_service.dart`, + `lib/services/unmanaged_apps_service.dart` +- [x] 5. Route device-owned AppCatalog relay-list events through the queue, + including restart-handoff recovery and bootstrap-relay publication. + - Files: `lib/services/app_catalog_relay_service.dart` +- [x] 6. Add behavior tests for draft recovery, PoW, relay failures, + cancellation, and marker removal after acceptance. +- [x] 7. Self-review against `INVARIANTS.md`. + +## Test Coverage + +| Scenario | Expected | Status | +|----------|----------|--------| +| First persisted device change | No empty bootstrap event; local draft and pending marker are created | [x] | +| PoW completion | Relay receives a valid 20-bit-PoW replacement event | [x] | +| Relay failure | Marker remains and the draft is retried on the next trigger | [x] | +| Mining cancellation | Local draft and marker remain; later processing resumes the work | [x] | +| Restart/resume | Pending marker rebuilds its latest matching local draft | [x] | +| Relay acceptance | Marker is removed only after acceptance | [x] | +| Relay-list update | Kind 10067 is mined and published to the bootstrap relay | [ ] | + +## Decisions + +### 2026-07-15 — Queue ownership and persistence + +**Context:** Mining can be cancelled before a relay-publishable event exists. +**Options:** Add a generic Purplebase outbox; persist complete payloads in secure +storage; store local drafts in Purplebase and only pending identifiers in secure +storage. +**Decision:** Store local-only PoW-less drafts in Purplebase. Persist only +pending event kind and `d` tag markers in Flutter secure storage. +**Rationale:** It survives cancellation and restart without modifying +dependencies or duplicating encrypted event content in secure storage. + +## Spec Issues + +_None_ + +## Progress Notes + +**2026-07-15:** Implemented the local-draft queue, including relay-list +handoff recovery. `fvm flutter test` passes. +**2026-07-15:** Added structured queue lifecycle diagnostics without logging +event content or secrets. +**2026-07-15:** Purplebase now preserves canonical event IDs in new blobs and +reconstructs them for legacy blobs, so persisted signed replaceable drafts +remain verifiable after restart. +**2026-07-15:** Queue triggers before device-key availability now safely no-op +instead of surfacing an uncaught error. diff --git a/test/services/device_private_event_service_test.dart b/test/services/device_private_event_service_test.dart index 690c559..091c2d4 100644 --- a/test/services/device_private_event_service_test.dart +++ b/test/services/device_private_event_service_test.dart @@ -7,29 +7,87 @@ import 'package:zapstore/services/device_key_service.dart'; import 'package:zapstore/services/device_private_event_service.dart'; import 'package:zapstore/utils/debug_utils.dart'; +class _MemoryPendingDeviceEventsStore implements PendingDeviceEventsStore { + final Map> values = {}; + + @override + Future> load(String devicePubkey) async => { + ...?values[devicePubkey], + }; + + @override + Future save(String devicePubkey, Set events) async { + if (events.isEmpty) { + values.remove(devicePubkey); + } else { + values[devicePubkey] = {...events}; + } + } +} + +class _AcceptingStorageNotifier extends DummyStorageNotifier { + _AcceptingStorageNotifier(super.ref); + + @override + Future publish( + Set> models, { + dynamic relays, + }) async { + await save(models); + final response = PublishResponse(); + for (final model in models) { + response.addEvent( + model.event.id, + relayUrl: 'wss://relay.example', + accepted: true, + ); + } + return response; + } +} + +class _CancelledProofOfWorkExecutor implements ProofOfWorkExecutor { + @override + Future mine>( + PartialEvent event, { + required String pubkey, + required ProofOfWorkOptions options, + }) => Future.error(const ProofOfWorkCancelled()); +} + void main() { late ProviderContainer container; late Bip340PrivateKeySigner signer; late IsolateProofOfWorkExecutor executor; late DevicePrivateEventService service; + late _MemoryPendingDeviceEventsStore pendingEvents; setUp(() async { container = ProviderContainer( overrides: [ - storageNotifierProvider.overrideWith(DummyStorageNotifier.new), + storageNotifierProvider.overrideWith(_AcceptingStorageNotifier.new), ], ); await container .read(storageNotifierProvider.notifier) - .initialize(StorageConfiguration()); + .initialize( + StorageConfiguration( + keepSignatures: true, + defaultRelays: { + 'AppCatalog': {'wss://relay.example'}, + }, + ), + ); signer = Bip340PrivateKeySigner('1' * 64, container.read(refProvider)); await signer.signIn(setAsActive: false); container.read(devicePubkeyProvider.notifier).state = signer.pubkey; executor = IsolateProofOfWorkExecutor(); + pendingEvents = _MemoryPendingDeviceEventsStore(); service = DevicePrivateEventService( container.read(refProvider), executor: executor, + pendingEventsStore: pendingEvents, difficulty: 4, maxAttempts: 100000, ); @@ -40,34 +98,109 @@ void main() { container.dispose(); }); - test('production policy requires 16 proof-of-work bits', () { - expect(kPrivateEventPowDifficulty, 16); + test('production device-event policy requires 20 proof-of-work bits', () { + expect(kDeviceEventPowDifficulty, 20); }); - test('device-signs, mines, and saves a private CustomData event', () async { - final ciphertext = await service.encryptToDevice('{"trusted":[]}'); - final signed = await service.signAndSave( + test('does not process pending events without a device key', () async { + container.read(devicePubkeyProvider.notifier).state = null; + + await expectLater(service.processPendingEvents(), completes); + }); + + test('queues a local device-state draft by kind and identifier', () async { + final draftOnly = DevicePrivateEventService( + container.read(refProvider), + executor: executor, + pendingEventsStore: pendingEvents, + startProcessing: false, + ); + final ciphertext = await service.encryptToDevice('{}'); + await draftOnly.saveDraftAndQueue( PartialCustomData( - identifier: kTrustedSignersIdentifier, + identifier: kDeviceStateIdentifier, content: ciphertext, ), - publish: false, ); - expect(signed.pubkey, signer.pubkey); - expect(Nip13.isValid(signed.event, minimumDifficulty: 4), isTrue); + expect(await pendingEvents.load(signer.pubkey), { + const PendingDeviceEvent(kind: 30078, identifier: kDeviceStateIdentifier), + }); final stored = await container .read(storageNotifierProvider.notifier) .query( RequestFilter( authors: {signer.pubkey}, tags: { - '#d': {kTrustedSignersIdentifier}, + '#d': {kDeviceStateIdentifier}, }, ).toRequest(), source: const LocalSource(), ); - expect(stored.single.event.id, signed.event.id); + expect(stored.single.pubkey, signer.pubkey); + }); + + test('mines and publishes queued device-state drafts', () async { + final ciphertext = await service.encryptToDevice('{}'); + await service.saveDraftAndQueue( + PartialCustomData( + identifier: kDeviceStateIdentifier, + content: ciphertext, + ), + ); + await service.processPendingEvents(); + + final stored = await container + .read(storageNotifierProvider.notifier) + .query( + RequestFilter( + authors: {signer.pubkey}, + tags: { + '#d': {kDeviceStateIdentifier}, + }, + limit: 1, + ).toRequest(), + source: const LocalSource(), + ); + expect(Nip13.isValid(stored.single.event, minimumDifficulty: 4), isTrue); + expect(await pendingEvents.load(signer.pubkey), isEmpty); + }); + + test('keeps a pending marker when proof-of-work is cancelled', () async { + final cancelled = DevicePrivateEventService( + container.read(refProvider), + executor: _CancelledProofOfWorkExecutor(), + pendingEventsStore: pendingEvents, + difficulty: 4, + ); + final ciphertext = await cancelled.encryptToDevice('{}'); + await cancelled.saveDraftAndQueue( + PartialCustomData( + identifier: kDeviceStateIdentifier, + content: ciphertext, + ), + ); + await cancelled.processPendingEvents(); + + expect(await pendingEvents.load(signer.pubkey), { + const PendingDeviceEvent(kind: 30078, identifier: kDeviceStateIdentifier), + }); + }); + + test('queues a relay-list draft using a marker without d', () async { + final draftOnly = DevicePrivateEventService( + container.read(refProvider), + executor: executor, + pendingEventsStore: pendingEvents, + startProcessing: false, + ); + await draftOnly.saveDraftAndQueue( + PartialAppCatalogRelayList(relays: {'wss://relay.example'}), + ); + + expect(await pendingEvents.load(signer.pubkey), { + const PendingDeviceEvent(kind: 10067), + }); }); test('rejects encrypted stacks carrying a community tag', () async { @@ -78,16 +211,15 @@ void main() { )..event.addTagValue('h', kZapstoreCommunityPubkey); await expectLater( - service.signAndSave(partial, publish: false), + service.saveDraftAndQueue(partial), throwsA(isA()), ); }); test('rejects unknown private CustomData identifiers', () async { await expectLater( - service.signAndSave( + service.saveDraftAndQueue( PartialCustomData(identifier: 'unknown', content: 'secret'), - publish: false, ), throwsA(isA()), );