mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +00:00
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 base3d26ed7the 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:
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
Reference in New Issue
Block a user