Files
buzz/mobile/lib/shared/read_state
Tom Brow 237d794acb 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>
2026-11-04 16:24:13 -08:00
..