mirror of
https://github.com/block/buzz.git
synced 2026-11-06 17:31:19 +00:00
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>
145 lines
5.6 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|