fix(mobile): size push leases without allocating and show Solo overflow

The lease fit check built a placeholder as long as the relay's advertised
endpoint limit and checked only plaintext. It now adds the limit
arithmetically and checks NIP-44's exact payload length against the content
limit and NIP-44's own plaintext ceiling, so a selected lease always passes
publication's guards.

When the lease has no room for some Solo chats, Notifications settings now
says how many notify only on mention instead of logging it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-26 22:42:03 -07:00
parent 6eca1831c2
commit 5efb40b53d
8 changed files with 230 additions and 135 deletions

View File

@@ -15,6 +15,7 @@ import '../../shared/clipboard_utils.dart';
import '../../shared/success_haptic.dart';
import '../../shared/push/push_relay_capability_provider.dart';
import '../../shared/push/push_bootstrap.dart';
import '../../shared/push/push_subscription_provider.dart';
import '../../shared/relay/relay.dart';
import '../../shared/utils/string_utils.dart';
import '../pairing/pairing_provider.dart';

View File

@@ -25,6 +25,9 @@ class _NotificationsSection extends ConsumerWidget {
return const SizedBox.shrink();
}
final canToggle = hasCapability || community.pushNotificationsEnabled;
final omittedSoloChats = community.pushNotificationsEnabled
? ref.watch(pushSubscriptionSyncProvider).value ?? 0
: 0;
void setEnabled(bool enabled) {
unawaited(
@@ -72,6 +75,14 @@ class _NotificationsSection extends ConsumerWidget {
],
),
),
if (omittedSoloChats > 0)
AppListRow(
key: const ValueKey('push-solo-coverage'),
title: 'Older Solo chats notify only on mention',
subtitle: omittedSoloChats == 1
? '1 chat is past this relay’s notification limit.'
: '$omittedSoloChats chats are past this relay’s notification limit.',
),
],
);
}

View File

@@ -88,6 +88,15 @@ String nip44Decrypt(Uint8List conversationKey, String payloadBase64) {
return _unpad(padded);
}
/// Length of the base64 payload [nip44Encrypt] returns for a plaintext of
/// [plaintextBytes] UTF-8 bytes, or null when NIP-44 cannot encrypt it.
int? nip44PayloadLength(int plaintextBytes) {
if (plaintextBytes < 1 || plaintextBytes > 65535) return null;
// version(1) || nonce(32) || length(2) || padded || mac(32)
final payload = 1 + 32 + 2 + _calcPaddedLen(plaintextBytes) + 32;
return 4 * ((payload + 2) ~/ 3);
}
// ── Padding ─────────────────────────────────────────────────────────────────
int _calcPaddedLen(int unpaddedLen) {

View File

@@ -18,19 +18,28 @@ const _lowercaseHex64Pattern = r'^[0-9a-f]{64}$';
const _installationIdPattern = r'^[0-9a-f]{32}$';
/// Whether an active lease carrying [subscriptions] fits [descriptor]'s
/// plaintext limit with any endpoint grant and generation it accepts.
/// Publication still checks the ciphertext limit.
/// plaintext and content limits with any endpoint grant and generation it
/// accepts. Sizes are computed, not built, since the limits come from the relay.
bool buzzPushSubscriptionsFitLease(
BuzzPushLeaseDescriptor descriptor,
List<BuzzPushSubscription> subscriptions,
) {
final plaintext = _activeLeasePlaintext(
descriptor: descriptor,
endpoint: 'x' * descriptor.maxEndpointLength,
generation: _maxSafeJsonInteger,
subscriptions: subscriptions,
);
return utf8.encode(plaintext).length <= descriptor.maxPlaintextLength;
final withoutEndpoint = utf8
.encode(
_activeLeasePlaintext(
descriptor: descriptor,
endpoint: '',
generation: _maxSafeJsonInteger,
subscriptions: subscriptions,
),
)
.length;
// A grant at the endpoint limit that needs no JSON escaping adds exactly it.
final plaintext = withoutEndpoint + descriptor.maxEndpointLength;
final content = nip44PayloadLength(plaintext);
return plaintext <= descriptor.maxPlaintextLength &&
content != null &&
content <= descriptor.maxContentLength;
}
String _activeLeasePlaintext({

View File

@@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../features/channels/channel.dart';
@@ -15,24 +14,26 @@ import 'push_setup_recovery.dart';
/// Keeps the persisted desired lease and the App Group snapshot aligned with
/// the active identity, joined channels, and mute state. This is desired client
/// policy only. Relay-accepted authority is introduced by lease publication.
final pushSubscriptionSyncProvider = FutureProvider.autoDispose<void>((
///
/// Yields how many Solo chats the lease had no room for.
final pushSubscriptionSyncProvider = FutureProvider.autoDispose<int>((
ref,
) async {
try {
if (ref.watch(authProvider).value?.status != AuthStatus.authenticated) {
return;
return 0;
}
final active = ref.watch(activeCommunityProvider).value;
final descriptor = ref.watch(currentRelayPushDescriptorProvider).value;
if (active == null ||
!active.pushNotificationsEnabled ||
descriptor == null) {
return;
return 0;
}
final channels = ref.watch(channelsProvider).value;
final mutes = ref.watch(channelMutesProvider);
if (channels == null || !mutes.isReady) {
return;
return 0;
}
// Legacy Solo chats are listed only in the Solo record. Wait while it
@@ -40,9 +41,9 @@ final pushSubscriptionSyncProvider = FutureProvider.autoDispose<void>((
// cached copy, keep DMs and mentions current; the Solo tab cannot list
// those chats either, and recovery republishes them.
final soloRecord = ref.watch(meGroupsProvider);
if (soloRecord.isLoading && !soloRecord.hasValue) return;
if (soloRecord.isLoading && !soloRecord.hasValue) return 0;
final subscriptions = desiredBuzzPushSubscriptions(
final desired = desiredBuzzPushSubscriptions(
community: active,
descriptor: descriptor,
channels: channels,
@@ -52,17 +53,20 @@ final pushSubscriptionSyncProvider = FutureProvider.autoDispose<void>((
if (entry.value.muted) entry.key,
],
);
if (subscriptions == null) return;
if (desired == null) return 0;
await ref
.read(communityListProvider.notifier)
.updateDesiredPushSubscriptions(active.id, subscriptions);
.updateDesiredPushSubscriptions(active.id, desired.subscriptions);
return desired.omittedSoloChats;
} catch (error, stack) {
reportPushSetupError('subscription synchronization', error, stack);
rethrow;
}
}, retry: pushSetupRetry);
List<BuzzPushSubscription>? desiredBuzzPushSubscriptions({
/// The desired lease, and how many Solo chats it had no room for.
({List<BuzzPushSubscription> subscriptions, int omittedSoloChats})?
desiredBuzzPushSubscriptions({
required Community community,
required BuzzPushLeaseDescriptor descriptor,
required Iterable<Channel> channels,
@@ -100,13 +104,10 @@ List<BuzzPushSubscription>? desiredBuzzPushSubscriptions({
}
fitting = low;
}
final omitted = candidates.omitted + candidates.solos.length - fitting;
if (omitted > 0) {
debugPrint(
'Push lease is full; $omitted older Solo chats push only on mention.',
);
}
return lease(fitting);
return (
subscriptions: lease(fitting),
omittedSoloChats: candidates.omitted + candidates.solos.length - fitting,
);
}
/// Channel-wide traffic pushes only for joined DMs and the viewer's own Solo

View File

@@ -12,6 +12,7 @@ import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/push/push_bridge.dart';
import 'package:buzz/shared/push/dev_push_lease.dart';
import 'package:buzz/shared/push/push_relay_capability_provider.dart';
import 'package:buzz/shared/push/push_subscription_provider.dart';
import 'package:buzz/shared/relay/app_lifecycle_provider.dart';
import 'package:buzz/shared/widgets/app_list.dart';
import 'package:buzz/shared/widgets/app_list_card.dart';
@@ -170,6 +171,8 @@ void main() {
savedPrefsProvider.overrideWithValue(prefs),
activeCommunityProvider.overrideWith((ref) async => community),
currentRelayPushDescriptorProvider.overrideWith((ref) => capability),
// The lease had no room for 12 older Solo chats.
pushSubscriptionSyncProvider.overrideWith((ref) async => 12),
appLifecycleProvider.overrideWith(_SettingsLifecycleNotifier.new),
buzzPushAuthorizationStatusReaderProvider.overrideWithValue(
() async => BuzzPushAuthorizationStatus.authorized,
@@ -191,6 +194,14 @@ void main() {
findsOneWidget,
);
expect(tester.widget<Switch>(find.byType(Switch)).value, isTrue);
expect(
find.text('Older Solo chats notify only on mention'),
findsOneWidget,
);
expect(
find.text('12 chats are past this relay’s notification limit.'),
findsOneWidget,
);
debugDefaultTargetPlatformOverride = null;
});

View File

@@ -18,4 +18,22 @@ void main() {
'{"version":1,"theme":"catppuccin-latte","accent":"#f97316","followSystem":false}',
);
});
test('predicts the encrypted payload length at every padding boundary', () {
final key = getConversationKey(
'0000000000000000000000000000000000000000000000000000000000000001',
'79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798',
);
for (final length in [
1, 32, 33, 256, 257, 4096, 4097, 32768, 32769, 65535, //
]) {
expect(
nip44PayloadLength(length),
nip44Encrypt(key, 'x' * length).length,
reason: '$length bytes',
);
}
expect(nip44PayloadLength(0), isNull);
expect(nip44PayloadLength(65536), isNull);
});
}

View File

@@ -6,7 +6,9 @@ import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/shared/auth/auth.dart';
import 'package:buzz/shared/me/me_groups.dart';
import 'package:buzz/shared/me/me_sync.dart';
import 'package:buzz/shared/push/push_bridge.dart';
import 'package:buzz/shared/push/push_subscription.dart';
import 'package:buzz/shared/relay/nostr_models.dart';
import 'package:buzz/shared/push/dev_push_lease.dart';
import 'package:buzz/shared/push/push_relay_capability_provider.dart';
import 'package:buzz/shared/push/push_subscription_provider.dart';
@@ -24,7 +26,7 @@ void main() {
() {
final nsec = nostr.Keys.generate().nsec;
final subscriptions = desiredBuzzPushSubscriptions(
descriptor: _relayDescriptor,
descriptor: _descriptor(),
community: Community.create(
name: 'Team',
relayUrl: 'https://relay.example.com',
@@ -36,7 +38,7 @@ void main() {
channel(nonMemberID, isMember: false),
],
mutedChannelIds: const [activeID, archivedID],
);
)?.subscriptions;
expect(subscriptions, isNotNull);
expect(subscriptions, hasLength(1));
@@ -66,7 +68,7 @@ void main() {
archived: archived,
);
final subscriptions = desiredBuzzPushSubscriptions(
descriptor: _relayDescriptor,
descriptor: _descriptor(),
community: Community.create(
name: 'Team',
relayUrl: 'https://relay.example.com',
@@ -83,7 +85,7 @@ void main() {
],
legacySoloChannelIds: const [legacySoloID],
mutedChannelIds: const [mutedSoloID],
);
)?.subscriptions;
expect(subscriptions, hasLength(2));
expect(subscriptions!.first.ignore.last.hTags, [mutedSoloID]);
@@ -91,109 +93,130 @@ void main() {
expect(subscriptions.last.filter.hTags, [dmID, soloID, legacySoloID]);
});
test('fills lease capacity with the most recent Solo chats', () {
test('fills the lease channel count with the most recent Solo chats', () {
final keys = nostr.Keys.generate();
String id(int n) =>
'123e4567-e89b-42d3-a456-${n.toRadixString(16).padLeft(12, '0')}';
Channel solo(int n) => channel(
id(n),
channelType: 'stream',
visibility: 'private',
labels: const ['buzz.solo'],
createdBy: keys.public,
lastMessageAt: DateTime(2026).add(Duration(minutes: n)),
);
const dmCount = 10;
const unmutedSolos = buzzPushMaxChannelWideChannels;
const mutedSolos = buzzPushMaxMutedChannels + 5;
const soloCount = buzzPushMaxChannelWideChannels;
final channels = [
for (var n = 0; n < dmCount; n++) channel(id(n)),
for (var n = dmCount; n < dmCount + unmutedSolos + mutedSolos; n++)
solo(n),
];
final muted = [
for (var n = dmCount + unmutedSolos; n < channels.length; n++) id(n),
for (var n = 0; n < dmCount; n++) channel(_id(n)),
for (var n = dmCount; n < dmCount + soloCount; n++) _solo(n, keys.public),
];
final subscriptions = desiredBuzzPushSubscriptions(
descriptor: _roomyDescriptor,
community: Community.create(
name: 'Team',
relayUrl: 'https://relay.example.com',
nsec: keys.nsec,
),
final desired = desiredBuzzPushSubscriptions(
descriptor: _descriptor(),
community: _community(keys),
channels: channels,
mutedChannelIds: muted,
mutedChannelIds: const [],
)!;
final followed = {
for (final subscription in subscriptions.skip(1))
for (final subscription in desired.subscriptions.skip(1))
...?subscription.filter.hTags,
};
final ignoredMutes = {
for (final ignore in subscriptions.first.ignore.skip(1)) ...?ignore.hTags,
};
expect(subscriptions, hasLength(buzzPushMaxSubscriptions));
expect(desired.subscriptions, hasLength(buzzPushMaxSubscriptions));
expect(followed, hasLength(buzzPushMaxChannelWideChannels));
expect(followed, containsAll([for (var n = 0; n < dmCount; n++) id(n)]));
expect(followed, contains(id(dmCount + unmutedSolos - 1)));
expect(followed, isNot(contains(id(dmCount + dmCount - 1))));
expect(followed, contains(id(dmCount + dmCount)));
expect(ignoredMutes, hasLength(buzzPushMaxMutedChannels));
expect(ignoredMutes, contains(id(channels.length - 1)));
expect(ignoredMutes, isNot(contains(id(dmCount + unmutedSolos))));
});
expect(followed, containsAll([for (var n = 0; n < dmCount; n++) _id(n)]));
expect(followed, contains(_id(channels.length - 1)));
expect(followed, isNot(contains(_id(dmCount + dmCount - 1))));
expect(desired.omittedSoloChats, dmCount);
test('keeps the most recent Solo chats that fit the lease plaintext', () {
final keys = nostr.Keys.generate();
String id(int n) =>
'123e4567-e89b-42d3-a456-${n.toRadixString(16).padLeft(12, '0')}';
const dmCount = 10;
const soloCount = 400;
final channels = [
for (var n = 0; n < dmCount; n++) channel(id(n)),
for (var n = dmCount; n < dmCount + soloCount; n++)
channel(
id(n),
channelType: 'stream',
visibility: 'private',
labels: const ['buzz.solo'],
createdBy: keys.public,
lastMessageAt: DateTime(2026).add(Duration(minutes: n)),
),
];
// The newest 100 Solo chats are muted, so every lease filter ignores them.
final muted = [
for (var n = dmCount + soloCount - 100; n < dmCount + soloCount; n++)
id(n),
];
final subscriptions = desiredBuzzPushSubscriptions(
community: Community.create(
name: 'Team',
relayUrl: 'https://relay.example.com',
nsec: keys.nsec,
),
descriptor: _relayDescriptor,
// A relay may advertise any endpoint limit; sizing must not allocate it.
final unbounded = desiredBuzzPushSubscriptions(
descriptor: _descriptor(endpointLength: 1 << 50),
community: _community(keys),
channels: channels,
mutedChannelIds: muted,
mutedChannelIds: const [],
)!;
final kept = {
for (final subscription in subscriptions.skip(1))
...?subscription.filter.hTags,
for (final ignore in subscriptions.first.ignore.skip(1)) ...?ignore.hTags,
};
expect(
buzzPushSubscriptionsFitLease(_relayDescriptor, subscriptions),
isTrue,
);
expect(kept, containsAll([for (var n = 0; n < dmCount; n++) id(n)]));
expect(kept, containsAll(muted));
expect(kept.length, lessThan(dmCount + soloCount));
expect(kept, isNot(contains(id(dmCount))));
expect(unbounded.omittedSoloChats, soloCount);
});
for (final (limit, descriptor, soloCount, mutedSolos) in [
(
'plaintext',
_descriptor(contentLength: 65536, plaintextLength: 32768),
400,
100,
),
(
'content',
_descriptor(contentLength: 24000, plaintextLength: 32768),
400,
100,
),
('muted channel count', _descriptor(), 400, 400),
('NIP-44 plaintext', _descriptor(), 600, 200),
]) {
test('keeps the newest Solo chats within the $limit limit', () async {
final keys = nostr.Keys.generate();
const dmCount = 10;
final channels = [
for (var n = 0; n < dmCount; n++) channel(_id(n)),
for (var n = dmCount; n < dmCount + soloCount; n++)
_solo(n, keys.public),
];
// Muted chats are the newest, so every lease filter ignores them.
final muted = [
for (var n = channels.length - mutedSolos; n < channels.length; n++)
_id(n),
];
final desired = desiredBuzzPushSubscriptions(
community: _community(keys),
descriptor: descriptor,
channels: channels,
mutedChannelIds: muted,
)!;
final kept = {
for (final subscription in desired.subscriptions.skip(1))
...?subscription.filter.hTags,
for (final ignore in desired.subscriptions.first.ignore.skip(1))
...?ignore.hTags,
};
final keptSolos = soloCount - desired.omittedSoloChats;
expect(keptSolos, inInclusiveRange(1, soloCount - 1));
expect(kept, {
for (var n = 0; n < dmCount; n++) _id(n),
for (var n = channels.length - keptSolos; n < channels.length; n++)
_id(n),
});
// The relay accepts any grant up to its endpoint limit.
final publication = await publishBuzzDevPushLease(
grant: BuzzPushEndpointGrant(
relayOrigin: descriptor.origin,
relayPubkey: descriptor.executorPubkey,
installationId: 'c' * 32,
endpointGrant: 'x' * descriptor.maxEndpointLength,
endpointHash: 'd' * 64,
endpointEpoch: 1,
generation: 9007199254740991,
expiresAt: 1756212000,
),
descriptor: descriptor,
nsec: keys.nsec,
memberPubkey: keys.public,
subscriptions: desired.subscriptions,
now: () => DateTime.fromMillisecondsSinceEpoch(1752620000 * 1000),
submit:
({
required kind,
required content,
required tags,
createdAt,
}) async => const NostrEvent(
id: 'accepted-id',
pubkey: '',
createdAt: 0,
kind: 0,
tags: [],
content: '',
sig: '',
),
);
expect(publication.eventId, 'accepted-id');
});
}
test('waits for the Solo record but not past its failure', () async {
const legacySoloID = '123e4567-e89b-42d3-a456-426614174009';
final community = Community.create(
@@ -209,7 +232,7 @@ void main() {
overrides: [
authProvider.overrideWith(_Authenticated.new),
currentRelayPushDescriptorProvider.overrideWith(
(ref) async => _relayDescriptor,
(ref) async => _descriptor(),
),
activeCommunityProvider.overrideWith((ref) async => community),
communityListProvider.overrideWith(() => communities),
@@ -251,7 +274,7 @@ void main() {
test('returns no desired subscriptions without a signing identity', () {
final subscriptions = desiredBuzzPushSubscriptions(
descriptor: _relayDescriptor,
descriptor: _descriptor(),
community: Community.create(
name: 'Team',
relayUrl: 'https://relay.example.com',
@@ -321,28 +344,40 @@ class _Authenticated extends AuthNotifier {
const AuthState(status: AuthStatus.authenticated);
}
/// Limits the relay advertises in NIP-11 (`crates/buzz-relay/src/nip11.rs`).
const _relayDescriptor = BuzzPushLeaseDescriptor(
origin: 'wss://relay.example',
executorKeyId: 'key',
executorPubkey: 'pubkey',
transport: 'apns',
maxLeaseTtlSeconds: 3600,
maxContentLength: 65536,
maxPlaintextLength: 32768,
maxEndpointLength: 4096,
maxStringLength: 512,
final _relayKeys = nostr.Keys.generate();
String _id(int n) =>
'123e4567-e89b-42d3-a456-${n.toRadixString(16).padLeft(12, '0')}';
Channel _solo(int n, String owner) => channel(
_id(n),
channelType: 'stream',
visibility: 'private',
labels: const ['buzz.solo'],
createdBy: owner,
lastMessageAt: DateTime(2026).add(Duration(minutes: n)),
);
/// Room for any lease the channel counts allow.
const _roomyDescriptor = BuzzPushLeaseDescriptor(
Community _community(nostr.Keys keys) => Community.create(
name: 'Team',
relayUrl: 'https://relay.example.com',
nsec: keys.nsec,
);
/// Defaults to no limit beyond NIP-44's own. The relay advertises 65536 and
/// 32768 in NIP-11 (`crates/buzz-relay/src/nip11.rs`).
BuzzPushLeaseDescriptor _descriptor({
int contentLength = 1 << 22,
int plaintextLength = 1 << 21,
int endpointLength = 4096,
}) => BuzzPushLeaseDescriptor(
origin: 'wss://relay.example',
executorKeyId: 'key',
executorPubkey: 'pubkey',
executorPubkey: _relayKeys.public,
transport: 'apns',
maxLeaseTtlSeconds: 3600,
maxContentLength: 1 << 22,
maxPlaintextLength: 1 << 21,
maxEndpointLength: 4096,
maxContentLength: contentLength,
maxPlaintextLength: plaintextLength,
maxEndpointLength: endpointLength,
maxStringLength: 512,
);