mirror of
https://github.com/block/buzz.git
synced 2026-11-03 16:01:35 +00:00
## Summary Every identity-name source change (a profile batch, a verified name) rebuilds a channel's naming scope over its whole member list. In a 4,000-member channel, each rebuild derived every key's naming fact more than once: owner loading and resolution each derived it, and so did each label for a key outside the scope. It also checked every key against a regular expression several times, and iOS AOT builds run regular expressions in an interpreter. - `IdentityNames` derives each key's naming fact once per scope. - `isHexPubkey`: a character-loop hex check, used by the identity-name code instead of the regular expressions. Labels are unchanged. ## Measurements These were taken with an earlier version of this PR that also cached npub encoding. That cache was removed to keep the change small, so the remaining gain is smaller than shown. I haven't re-measured it. - **Synthetic** (Mac, `flutter test`, 4,391 members, 60% with profiles; scope, `missingOwnerProfiles` and every label for each new sources object): a warm rebuild took 63 ms before and 12 ms with the cache. The first rebuild took 120 ms before and 91 ms with the cache. - **Device** (iPhone 12 mini, Profile build, first 5 s after opening #announcements with 4,391 members; runs not matched): `channelIdentityNamesProvider` plus `watchChannelIdentityLabel` inclusive CPU was 148 ms before and 81 ms with the cache. Of that, `fullNpub` (npub encoding, the cached part) went from 62 ms to 22 ms. The identity path no longer calls `RegExp.hasMatch`. ## Testing - A new test counts profile lookups. Owner loading, every label and an outside key read each member's profile once. Without the memo, each is read 3 times. - New `isHexPubkey` tests cover both cases, wrong lengths, characters just outside each range, and a bad character at every position. - `just mobile-check mobile-test` passes. 🤖 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>
128 lines
4.9 KiB
Dart
128 lines
4.9 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('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);
|
|
});
|
|
});
|
|
}
|