Files
buzz/mobile/test/shared/utils/string_utils_test.dart
Tom Brow 7ac16b2917 perf(mobile): memoize npub labels (#9137)
Opening a big channel on mobile stalls while member labels resolve. On
an iPhone 12 mini (wired Profile build of main 120d75d976), opening a
5,892-member channel had frames of 217, 113 and 101 ms. The channel
identity scope recomputed 13 times during the open as profiles arrived,
25 to 35 ms each: 235 ms of 1,755 ms UI CPU, 190 ms of it bech32
encoding. Every recompute labels each member without a profile name by
npub (`missingOwnerProfiles` → `IdentityNameSources.factFor` →
`shortPubkey` → `fullNpub`), and encoded the same keys again. The same
chain cost 152 ms in a 15 s resume with the channel open.

`fullNpub` now memoizes encodings by hex key, bounded at 8,192 entries.
Once full it keeps what it has, so a roster larger than the limit still
reuses most of its encodings. Labels are unchanged.

### Validation

- Added tests: `fullNpub reuses the encoding of a key it has already
encoded` fails without the memo; `a roster larger than the cache still
reuses its encodings` fails if the cache clears when full.
- Host benchmark, `shortPubkey` over 5,900 random keys: 53.5 ms on the
first pass, about 3.1 ms on later passes.
- Full mobile suite passes except the 10 known notification/push
failures and `push_lease_revocation_outbox_test`, which also fail on
main. Analyze clean.
- Automated simulator run of this branch (debug build, Builderlab
community): searched "studio", opened a channel result and came back;
people results show the same short npub labels as main (for example
`npub194z...aq3k`).
- No post-change device capture. The scope still recomputes as profiles
arrive; this removes the encoding cost from each recompute.

🤖 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>
2026-11-05 13:49:09 -08:00

145 lines
5.6 KiB
Dart

import 'package:buzz/features/invites/invite_create_provider.dart';
import 'package:buzz/shared/utils/string_utils.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
/// Tests for the npub identity helpers shared by every mobile surface.
///
/// The canonical vector comes from the NIP-19 specification itself, so these
/// tests fail if the encoding (or the truncation policy) drifts — they never
/// re-derive expectations from the codec under test.
void main() {
const canonicalHex =
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d';
const canonicalNpub =
'npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6';
const canonicalCompact = 'npub180c\u2026h6w6';
group('fullNpub', () {
test('reuses the encoding of a key it has already encoded', () {
// Identity views relabel a large roster on every rebuild; each key's
// bech32 encoding must happen once, not once per rebuild.
final first = fullNpub(canonicalHex);
expect(first, canonicalNpub);
expect(identical(fullNpub(canonicalHex.toUpperCase()), first), isTrue);
});
test('a roster larger than the cache still reuses its encodings', () {
String key(int i) => i.toRadixString(16).padLeft(64, '0');
final firstPass = [
for (var i = 0; i <= npubCacheLimit; i++) fullNpub(key(i)),
];
// A second scan in the same order finds the early keys still cached.
expect(identical(fullNpub(key(1)), firstPass[1]), isTrue);
});
test('canonicalizes every accepted input wrapper', () {
// Every accepted wrapper — lowercase hex, uppercase hex, whitespace-
// padded, and an already-npub key — converges on the canonical npub.
final inputs = <String>[
canonicalHex,
canonicalHex.toUpperCase(),
' $canonicalHex \n',
canonicalNpub,
];
for (final input in inputs) {
expect(fullNpub(input), canonicalNpub, reason: input);
}
});
test('rejects malformed identities without echoing them back', () {
expect(fullNpub(''), isNull);
expect(fullNpub('unknown'), isNull);
// Wrong-length hex.
expect(fullNpub('a' * 63), isNull);
expect(fullNpub('a' * 65), isNull);
// Hex-length but not hex.
expect(fullNpub('z' * 64), isNull);
// Not decodable bech32.
expect(fullNpub('npub1garbage'), isNull);
// Mixed-case bech32 is invalid by checksum.
final mixedCase =
'${canonicalNpub.substring(0, 20)}'
'${canonicalNpub.substring(20).toUpperCase()}';
expect(fullNpub(mixedCase), isNull);
// Valid NIP-19 forms that are not public keys stay out of the npub
// contract instead of leaking their payload.
final nsec = nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.nsec,
data: canonicalHex,
);
expect(fullNpub(nsec), isNull);
final note = nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.note,
data: canonicalHex,
);
expect(fullNpub(note), isNull);
});
});
group('shortPubkey', () {
test('renders the compact npub label — first 8 … last 4', () {
expect(shortPubkey(canonicalHex), canonicalCompact);
});
test('is idempotent for already-npub input', () {
expect(shortPubkey(canonicalNpub), canonicalCompact);
});
test('returns the neutral label for invalid identities', () {
expect(shortPubkey(''), unknownIdentityLabel);
expect(shortPubkey('not-a-key'), unknownIdentityLabel);
expect(shortPubkey('npub1garbage'), unknownIdentityLabel);
expect(shortPubkey('n' * 64), unknownIdentityLabel);
});
});
group('isHexPubkey', () {
test('accepts exactly 64 hex characters in either case', () {
expect(isHexPubkey(canonicalHex), isTrue);
expect(isHexPubkey(canonicalHex.toUpperCase()), isTrue);
expect(isHexPubkey('0' * 64), isTrue);
expect(isHexPubkey('F' * 64), isTrue);
});
test('rejects other lengths and characters', () {
expect(isHexPubkey(''), isFalse);
expect(isHexPubkey(canonicalHex.substring(1)), isFalse);
expect(isHexPubkey('${canonicalHex}0'), isFalse);
expect(isHexPubkey(' ${canonicalHex.substring(1)}'), isFalse);
// Characters just outside each accepted range.
for (final c in ['/', ':', '@', 'G', '`', 'g', 'n']) {
expect(isHexPubkey('$c${'0' * 63}'), isFalse, reason: c);
}
expect(isHexPubkey(canonicalNpub), isFalse);
});
test('rejects an invalid character at any position', () {
// Every index, so a check that only inspects some positions fails.
for (final c in ['g', 'G', ':', '/', ' ']) {
for (var i = 0; i < 64; i++) {
final value = '${'a' * i}$c${'b' * (63 - i)}';
expect(isHexPubkey(value), isFalse, reason: '"$c" at index $i');
}
}
// Middle and final positions of an otherwise valid key.
final middle = canonicalHex.replaceRange(32, 33, 'z');
final last = canonicalHex.replaceRange(63, 64, 'z');
expect(isHexPubkey(middle), isFalse);
expect(isHexPubkey(last), isFalse);
expect(isHexPubkey(canonicalHex), isTrue);
});
});
group('copy to paste roundtrip', () {
test('a copied npub parses back to the original hex key', () {
final copied = fullNpub(canonicalHex);
// The invite input accepts what the profile/settings copy actions put
// on the clipboard — the mobile copy/paste contract.
expect(parseCommunityInvitePubkey(copied!), canonicalHex);
// Hex input keeps working unchanged.
expect(parseCommunityInvitePubkey(canonicalHex), canonicalHex);
});
});
}