perf(mobile): skip re-decrypting read-state blobs (#8966)

## 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 <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tom Brow
2026-11-04 16:24:13 -08:00
committed by GitHub
parent 9c5032b63b
commit 237d794acb
2 changed files with 358 additions and 4 deletions

View File

@@ -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<String, String> _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 }

View File

@@ -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 = <String>[
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 = <String>[
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<NostrEvent> history = [];
void Function(NostrEvent)? onEvent;
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async => history;
@override
Future<List<NostrEvent>> fetchReadStateSnapshot({
Duration timeout = const Duration(seconds: 8),
}) async => history;
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async {
this.onEvent = onEvent;
return () {};
}
}
class _RecordingSignedEventRelay implements SignedEventRelay {
final List<String> contents = [];
@override
String? get pubkey => null;
@override
Future<NostrEvent> submit({
required int kind,
required String content,
required List<List<String>> 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,
);