From 237d794acbce5645b0bc7b2ff4856fb1969a70fe Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 4 Nov 2026 16:24:13 -0800 Subject: [PATCH] perf(mobile): skip re-decrypting read-state blobs (#8966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Read-state blobs are NIP-44 encrypted, and the app decrypts them with pure-Dart ChaCha20 and HMAC-SHA256 on the UI isolate. Most of those decrypts repeat earlier work: - Every live subscription (each launch, resume and reconnect) replays all of the account's read-state slots, and `_handleIncomingEvent` decrypts each one again. The measured account has 27 slots of about 55 KB ciphertext each. - Before every publish, `_publishOnce` fetches the read-state snapshot and decrypts it, and decodes our own slots a second time to check they're readable. - The relay echoes each publish back, and our own blob is decrypted again. `ReadStateCrypto` now keeps a bounded memo from ciphertext to plaintext, one per conversation key. `decrypt` checks it first, and `encrypt` records the ciphertext it produced. NIP-44 payloads carry a random nonce and a MAC, so under one key the same ciphertext always decrypts to the same plaintext. Failed decrypts aren't cached and still throw every time. The memo is least recently used: a hit moves the entry to the newest end, so our own superseded blobs are evicted before live ones. It holds at most 8,000,000 code units across keys and values (about 8 MB, about 80 full slots). The first version used oldest-first eviction with a 2M bound, which saved nothing on the device: the 27 slots total about 2.6M, and a replay in slot order evicted each entry just before its next use. ## Measurements iPhone 12 mini, Profile/AOT, Tom's Block community. Each run resumes the app twice from the home screen and samples UI-isolate CPU for 15 s from each resume. | Build | `ReadStateCrypto.decrypt` per resume | | --- | --- | | `main` 14037e3 | 73 ms, 78 ms | | `main` + this PR + [#8965](https://github.com/block/buzz/pull/8965) | 9 ms, 4 ms | On the older base 3d26ed7 the first version of this PR measured 74/70 ms before and 73/71 ms after (no gain). An instrumented build showed 27 decrypts per resume with 0 memo hits, all on one `ReadStateCrypto` instance. With the LRU fix, the same base measured 7/3 ms. ## Testing - New tests: the same ciphertext twice runs the cipher once. A ciphertext from `encrypt` runs it zero times. Failed decrypts throw every time. The bound caps retained size. - Regression tests for the device finding: replaying 27 full-size slots twice decrypts 27 times, not 54, and a hit keeps a live entry ahead of superseded blobs under a small bound. Both fail on the first version's oldest-first memo. - Manager-level test: after the first sync, three publishes plus the echo and snapshot fetches add no decrypts (3 total, instead of 6 with the memo bypassed). - `just mobile-check mobile-test` passes (pre-push hook), after merging current `main`. - Automated device run in place of manual testing: the build above on the 12 mini, launched and resumed twice into a signed-in Block community, loading the channel list and syncing read state (snapshot fetch and live replay of all 27 slots). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: studio Co-authored-by: Claude Opus 5.5 --- .../shared/read_state/read_state_manager.dart | 85 +++++- .../read_state_crypto_memo_test.dart | 277 ++++++++++++++++++ 2 files changed, 358 insertions(+), 4 deletions(-) create mode 100644 mobile/test/features/channels/read_state/read_state_crypto_memo_test.dart diff --git a/mobile/lib/shared/read_state/read_state_manager.dart b/mobile/lib/shared/read_state/read_state_manager.dart index 8452074671..5e4fc01553 100644 --- a/mobile/lib/shared/read_state/read_state_manager.dart +++ b/mobile/lib/shared/read_state/read_state_manager.dart @@ -17,8 +17,50 @@ import '../theme/community_theme_preference.dart'; class ReadStateCrypto { final Uint8List conversationKey; + final String Function(Uint8List conversationKey, String ciphertext) _decrypt; + final int _maxRetainedLength; - const ReadStateCrypto._(this.conversationKey); + /// Plaintext by ciphertext, least recently used first. NIP-44 payloads + /// carry a random nonce and a MAC, so under this one conversation key the + /// same ciphertext always decrypts to the same plaintext. Every publish + /// re-reads up to [readStateFetchLimit] of our own blobs, every resume + /// replays them on the live subscription, and the relay echoes each + /// publish back, so most decrypts here repeat a ciphertext this instance + /// already decrypted or produced. Decrypting is pure-Dart ChaCha20 and HMAC + /// on the UI isolate, so repeats are served from this memo. + final Map _plaintextByCiphertext = {}; + int _retainedLength = 0; + + /// Upper bound on the code units the memo retains, counting each ciphertext + /// key and its plaintext, so it cannot grow without limit however large the + /// blobs get (about 8 MB of one-byte strings at the default). Each slot's + /// current blob costs up to about 95,000 (a 40 KiB plaintext and its + /// base64 ciphertext), so this holds about 80 slots. The replay walks every + /// slot in turn, so a bound below the live slots' total evicts each entry + /// before its next use and nothing hits; an account with 27 slots measured + /// about 2.6M. + static const defaultMaxRetainedLength = 8000000; + + ReadStateCrypto._( + this.conversationKey, { + String Function(Uint8List conversationKey, String ciphertext) decrypt = + nip44Decrypt, + int maxRetainedLength = defaultMaxRetainedLength, + }) : _decrypt = decrypt, + _maxRetainedLength = maxRetainedLength; + + /// Builds a crypto around a known key with a counting or bounded memo. + @visibleForTesting + ReadStateCrypto.forTesting( + Uint8List conversationKey, { + String Function(Uint8List conversationKey, String ciphertext) decrypt = + nip44Decrypt, + int maxRetainedLength = defaultMaxRetainedLength, + }) : this._( + conversationKey, + decrypt: decrypt, + maxRetainedLength: maxRetainedLength, + ); static ReadStateCrypto? tryCreate({ required String nsec, @@ -36,10 +78,45 @@ class ReadStateCrypto { } } - String encrypt(String plaintext) => nip44Encrypt(conversationKey, plaintext); + String encrypt(String plaintext) { + final ciphertext = nip44Encrypt(conversationKey, plaintext); + // The relay echoes this blob back, and the next publish fetches it. + _remember(ciphertext, plaintext); + return ciphertext; + } - String decrypt(String ciphertext) => - nip44Decrypt(conversationKey, ciphertext); + /// Throws on a ciphertext that does not decrypt, every time: failures are + /// never memoized. + String decrypt(String ciphertext) { + final known = _plaintextByCiphertext.remove(ciphertext); + if (known != null) { + // Most recently used goes last, so superseded blobs are evicted first. + _plaintextByCiphertext[ciphertext] = known; + return known; + } + final plaintext = _decrypt(conversationKey, ciphertext); + _remember(ciphertext, plaintext); + return plaintext; + } + + void _remember(String ciphertext, String plaintext) { + final cost = ciphertext.length + plaintext.length; + if (cost > _maxRetainedLength) return; + final replaced = _plaintextByCiphertext.remove(ciphertext); + if (replaced != null) { + _retainedLength -= ciphertext.length + replaced.length; + } + _plaintextByCiphertext[ciphertext] = plaintext; + _retainedLength += cost; + while (_retainedLength > _maxRetainedLength) { + final oldest = _plaintextByCiphertext.keys.first; + final dropped = _plaintextByCiphertext.remove(oldest)!; + _retainedLength -= oldest.length + dropped.length; + } + } + + @visibleForTesting + int get retainedLength => _retainedLength; } enum _ApplyRemoteContextResult { unchanged, advanced } diff --git a/mobile/test/features/channels/read_state/read_state_crypto_memo_test.dart b/mobile/test/features/channels/read_state/read_state_crypto_memo_test.dart new file mode 100644 index 0000000000..e7770d8d4a --- /dev/null +++ b/mobile/test/features/channels/read_state/read_state_crypto_memo_test.dart @@ -0,0 +1,277 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:buzz/shared/crypto/nip44.dart'; +import 'package:buzz/shared/read_state/read_state_format.dart'; +import 'package:buzz/shared/read_state/read_state_manager.dart'; +import 'package:buzz/shared/read_state/read_state_storage.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:shared_preferences/shared_preferences.dart'; + +const _relayUrl = 'https://relay.example'; + +/// A crypto whose cipher work is counted: [decrypts] is how many times the +/// real NIP-44 decrypt ran. +class _CountingCrypto { + _CountingCrypto(nostr.Keys keys, {int? maxRetainedLength}) { + final key = _key(keys); + crypto = ReadStateCrypto.forTesting( + key, + decrypt: (conversationKey, ciphertext) { + decrypts++; + return nip44Decrypt(conversationKey, ciphertext); + }, + maxRetainedLength: + maxRetainedLength ?? ReadStateCrypto.defaultMaxRetainedLength, + ); + } + + late final ReadStateCrypto crypto; + int decrypts = 0; +} + +void main() { + late nostr.Keys keys; + setUp(() => keys = nostr.Keys.generate()); + + group('ReadStateCrypto memo', () { + test('decrypting the same ciphertext twice runs the cipher once', () { + final counting = _CountingCrypto(keys); + // Another device's blob: this instance never encrypted it. + final foreign = ReadStateCrypto.forTesting(_key(keys)).encrypt('{"a":1}'); + + expect(counting.crypto.decrypt(foreign), '{"a":1}'); + expect(counting.crypto.decrypt(foreign), '{"a":1}'); + expect(counting.decrypts, 1); + }); + + test('decrypting a ciphertext it produced runs the cipher zero times', () { + final counting = _CountingCrypto(keys); + final ciphertext = counting.crypto.encrypt('{"own":2}'); + + expect(counting.crypto.decrypt(ciphertext), '{"own":2}'); + expect(counting.decrypts, 0); + }); + + test('a failed decrypt throws every time and is not memoized', () { + final counting = _CountingCrypto(keys); + final ciphertext = counting.crypto.encrypt('{"own":2}'); + // Flip the last base64 characters: the MAC no longer matches. + final tampered = '${ciphertext.substring(0, ciphertext.length - 4)}AAAA'; + + expect(() => counting.crypto.decrypt(tampered), throwsA(anything)); + expect(() => counting.crypto.decrypt(tampered), throwsA(anything)); + expect(counting.decrypts, 2); + }); + + test('the bound evicts the oldest entries and caps retained memory', () { + const bound = 3000; + final counting = _CountingCrypto(keys, maxRetainedLength: bound); + final plain = 'x' * 400; + final ciphertexts = [ + for (var i = 0; i < 20; i++) counting.crypto.encrypt('$i$plain'), + ]; + + expect(counting.crypto.retainedLength, lessThanOrEqualTo(bound)); + expect(counting.crypto.retainedLength, greaterThan(0)); + + // Newest is retained, oldest was evicted and decrypts again. + counting.crypto.decrypt(ciphertexts.last); + expect(counting.decrypts, 0); + expect(counting.crypto.decrypt(ciphertexts.first), '0$plain'); + expect(counting.decrypts, 1); + expect(counting.crypto.retainedLength, lessThanOrEqualTo(bound)); + }); + + test('replaying 27 full slots twice decrypts each slot once', () { + // A measured account: 27 slots near the 40 KiB plaintext budget, all + // replayed on every resume. Their total (about 2.6M code units) is + // more than an oldest-first memo of 2M can hold, which made every + // replay miss. + final counting = _CountingCrypto(keys); + final otherDevice = ReadStateCrypto.forTesting(_key(keys)); + final slots = [ + for (var i = 0; i < 27; i++) + otherDevice.encrypt('$i${'z' * (readStatePlaintextBytes - 8)}'), + ]; + + for (var pass = 0; pass < 2; pass++) { + for (final ciphertext in slots) { + counting.crypto.decrypt(ciphertext); + } + } + expect(counting.decrypts, slots.length); + }); + + test('a hit keeps an entry ahead of unused ones for eviction', () { + const bound = 3000; + final counting = _CountingCrypto(keys, maxRetainedLength: bound); + final plain = 'x' * 400; + final live = counting.crypto.encrypt('live$plain'); + // Our own superseded blobs: each publish adds one and never reads it. + for (var i = 0; i < 20; i++) { + counting.crypto.encrypt('$i$plain'); + expect(counting.crypto.decrypt(live), 'live$plain'); + } + expect(counting.decrypts, 0); + }); + + test('a blob larger than the whole bound is not retained', () { + final counting = _CountingCrypto(keys, maxRetainedLength: 100); + final ciphertext = counting.crypto.encrypt('y' * 200); + + expect(counting.crypto.retainedLength, 0); + expect(counting.crypto.decrypt(ciphertext), 'y' * 200); + expect(counting.decrypts, 1); + }); + }); + + group('ReadStateManager decrypt work', () { + test('echo and later pre-publish fetches do not re-decrypt', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final counting = _CountingCrypto(keys); + final otherDevice = ReadStateCrypto.forTesting(_key(keys)); + final session = _CapturingRelaySession(); + final relay = _RecordingSignedEventRelay(); + final manager = ReadStateManager( + pubkey: keys.public, + relayUrl: _relayUrl, + prefs: prefs, + crypto: counting.crypto, + relaySession: session, + signedEventRelay: relay, + remoteEnabled: true, + onChanged: () {}, + ); + final slotId = ReadStateStorage( + prefs, + ).getOrCreateSlotId(keys.public, _relayUrl); + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + NostrEvent event(String content, String id, int createdAt, String slot) => + NostrEvent( + id: id, + pubkey: keys.public, + createdAt: createdAt, + kind: EventKind.readState, + tags: [ + ['d', '$readStateDTagPrefix$slot'], + ['t', 'read-state'], + ], + content: content, + sig: 'sig', + ); + + // Three blobs from other devices, stored before we ever publish. + const foreignCount = 3; + session.history = [ + for (var i = 0; i < foreignCount; i++) + event( + otherDevice.encrypt( + jsonEncode( + ReadStateBlob( + clientId: 'device-$i', + contexts: {'channel-$i': 10 + i}, + ).toJson(), + ), + ), + 'foreign-event-$i', + now - 100 + i, + 'other-$i', + ), + ]; + await manager.initialize(); + expect(counting.decrypts, foreignCount); + + // Publish 1: its pre-fetch re-reads the foreign blobs. + manager.markContextRead('channel-a', now); + await manager.flush(); + expect(relay.contents, hasLength(1)); + expect(counting.decrypts, foreignCount); + + // The relay echoes our own blob on the live subscription. + final echo = event(relay.contents.last, 'own-event-1', now + 1, slotId); + session.onEvent!(echo); + expect(counting.decrypts, foreignCount, reason: 'echo of our own blob'); + + // Publish 2 and 3 pre-fetch the same events plus our own blobs. + session.history = [...session.history, echo]; + manager.markContextRead('channel-b', now); + await manager.flush(); + expect(relay.contents, hasLength(2)); + final echo2 = event(relay.contents.last, 'own-event-2', now + 2, slotId); + session.history = [...session.history, echo2]; + manager.markContextRead('channel-c', now); + await manager.flush(); + expect(relay.contents, hasLength(3)); + + expect( + counting.decrypts, + foreignCount, + reason: 'each ciphertext is decrypted at most once', + ); + manager.dispose(flushPending: false); + }); + }); +} + +class _CapturingRelaySession extends RelaySessionNotifier { + List history = []; + void Function(NostrEvent)? onEvent; + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => history; + + @override + Future> fetchReadStateSnapshot({ + Duration timeout = const Duration(seconds: 8), + }) async => history; + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async { + this.onEvent = onEvent; + return () {}; + } +} + +class _RecordingSignedEventRelay implements SignedEventRelay { + final List contents = []; + + @override + String? get pubkey => null; + + @override + Future submit({ + required int kind, + required String content, + required List> tags, + int? createdAt, + void Function(NostrEvent event)? onSigned, + }) async { + contents.add(content); + return NostrEvent( + id: 'ack-${contents.length}', + pubkey: '', + createdAt: createdAt ?? 0, + kind: kind, + tags: tags, + content: content, + sig: '', + ); + } +} + +Uint8List _key(nostr.Keys keys) => getConversationKey( + nostr.Nip19.decode(payload: keys.nsec).data, + keys.public, +);