Queue device private events through local drafts and 20-bit PoW with restart-safe pending markers

This commit is contained in:
franzap
2026-07-15 13:41:48 -03:00
parent f1e97925df
commit 5f8b3eeee6
9 changed files with 668 additions and 45 deletions
+14 -11
View File
@@ -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<void> _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',
+1 -1
View File
@@ -144,7 +144,7 @@ Future<void> _writeBookmarks(
platform: platform,
);
partial.event.createdAt = createdAt;
await ref.read(devicePrivateEventServiceProvider).signAndSave(partial);
await ref.read(devicePrivateEventServiceProvider).saveDraftAndQueue(partial);
}
Future<bool> toggleBookmark(WidgetRef ref, App app) {
+358 -15
View File
@@ -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<String, dynamic> 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<Set<PendingDeviceEvent>> load(String devicePubkey);
Future<void> save(String devicePubkey, Set<PendingDeviceEvent> 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<Set<PendingDeviceEvent>> 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<void> save(String devicePubkey, Set<PendingDeviceEvent> 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<IsolateProofOfWorkExecutor>((
ref,
@@ -20,6 +117,7 @@ final devicePrivateEventServiceProvider = Provider<DevicePrivateEventService>((
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<void>? _draftWriteQueue;
Future<void>? _processingQueue;
bool _disposed = false;
String get devicePubkey {
final pubkey = ref.read(devicePubkeyProvider);
@@ -74,13 +186,244 @@ class DevicePrivateEventService {
Future<String> encryptFor(String plaintext, String recipientPubkey) =>
deviceSigner.nip44Encrypt(plaintext, recipientPubkey);
Future<E> signAndSave<E extends Model<dynamic>>(
PartialModel<E> 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<Model<dynamic>> saveDraftAndQueue(PartialModel<dynamic> partial) {
final previous = _draftWriteQueue ?? Future<void>.value();
final operation = previous.then((_) => _saveDraftAndQueue(partial));
_draftWriteQueue = operation.then<void>(
(_) {},
onError: (Object _, StackTrace __) {},
);
return operation;
}
Future<Model<dynamic>> _saveDraftAndQueue(
PartialModel<dynamic> 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<void> queueExistingDraft(Model<dynamic> 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<void> processPendingEvents() {
if (_disposed) {
LogService.I.debug(
'queue trigger ignored after disposal',
tag: 'device-event-queue',
);
return Future<void>.value();
}
if (ref.read(devicePubkeyProvider) == null) {
LogService.I.debug(
'queue trigger ignored without device key',
tag: 'device-event-queue',
);
return Future<void>.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<void> processing;
processing = _processPendingEvents().whenComplete(() {
if (identical(_processingQueue, processing)) {
_processingQueue = null;
}
});
_processingQueue = processing;
return processing;
}
Future<void> _processPendingEvents() async {
final attempted = <PendingDeviceEvent>{};
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<void> _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<Set<PendingDeviceEvent>> _loadPendingEvents() =>
_pendingEventsStore.load(devicePubkey);
Future<void> _updatePendingEvents(
Set<PendingDeviceEvent> Function(Set<PendingDeviceEvent>) update,
) async {
final current = await _loadPendingEvents();
await _pendingEventsStore.save(devicePubkey, update({...current}));
}
PendingDeviceEvent _pendingFor(PartialModel<dynamic> 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<Model<dynamic>?> _loadLatestDraft(PendingDeviceEvent pending) async {
final events = await ref
.read(storageNotifierProvider.notifier)
.query(
RequestFilter<Model<dynamic>>(
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<String, Object?> _pendingEventFields(PendingDeviceEvent event) => {
'kind': event.kind,
if (event.identifier != null) 'identifier': event.identifier,
};
Future<Model<dynamic>> _signAndSave(
PartialModel<dynamic> 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<dynamic>;
_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.',
);
}
}
}
+3 -1
View File
@@ -231,7 +231,9 @@ Future<void> _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) {
+1 -1
View File
@@ -324,7 +324,7 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
await ref
.read(devicePrivateEventServiceProvider)
.signAndSave(partialStack);
.saveDraftAndQueue(partialStack);
_lastBackedUpIds = appIds;
} catch (e, st) {
LogService.I.warn(
+40
View File
@@ -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.
+35
View File
@@ -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.
@@ -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.
@@ -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<String, Set<PendingDeviceEvent>> values = {};
@override
Future<Set<PendingDeviceEvent>> load(String devicePubkey) async => {
...?values[devicePubkey],
};
@override
Future<void> save(String devicePubkey, Set<PendingDeviceEvent> events) async {
if (events.isEmpty) {
values.remove(devicePubkey);
} else {
values[devicePubkey] = {...events};
}
}
}
class _AcceptingStorageNotifier extends DummyStorageNotifier {
_AcceptingStorageNotifier(super.ref);
@override
Future<PublishResponse> publish(
Set<Model<dynamic>> 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<ProofOfWorkResult> mine<E extends Model<dynamic>>(
PartialEvent<E> event, {
required String pubkey,
required ProofOfWorkOptions options,
}) => Future<ProofOfWorkResult>.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<CustomData>(
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<CustomData>(
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<DevicePrivateEventException>()),
);
});
test('rejects unknown private CustomData identifiers', () async {
await expectLater(
service.signAndSave(
service.saveDraftAndQueue(
PartialCustomData(identifier: 'unknown', content: 'secret'),
publish: false,
),
throwsA(isA<DevicePrivateEventException>()),
);