fix(mobile): order confirmed profile writes with shared cache authority

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-09 14:01:10 -04:00
parent 1db724109c
commit e9c8be29e2
4 changed files with 159 additions and 66 deletions

View File

@@ -5,7 +5,6 @@ import 'dart:typed_data';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/crypto/nip_oa.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../../shared/profile/user_profile.dart';
import '../../shared/relay/relay.dart';
@@ -69,9 +68,7 @@ final profileAvatarHandoffProvider =
/// WebSocket. Returns null when no nsec is configured or when the user has
/// not yet published a profile.
class ProfileNotifier extends AsyncNotifier<UserProfile?> {
Map<String, dynamic> _metadata = {};
bool _hasHydrated = false;
int _lastCreatedAt = 0;
Future<void> _patchQueue = Future.value();
@override
@@ -92,37 +89,19 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
final myPk = context.pubkey;
if (myPk == null) {
_requireCurrentWriteContext(context);
_metadata = {};
_lastCreatedAt = 0;
_hasHydrated = true;
return null;
}
final session = context.session;
final events = await session.fetchHistory(NostrFilters.profile(myPk));
if (events.isEmpty) {
_requireCurrentWriteContext(context);
_metadata = {};
_lastCreatedAt = 0;
_hasHydrated = true;
return null;
}
final latest = _latestProfileEvent(events)!;
final metadata = _decodeProfileMetadata(latest);
final data = ProfileData.fromEvent(latest);
final profile = UserProfile(
pubkey: data.pubkey,
displayName: data.displayName,
avatarUrl: data.avatarUrl,
about: data.about,
nip05Handle: data.nip05,
ownerPubkey: verifiedOaOwnerPubkey(latest),
);
_requireCurrentWriteContext(context);
_metadata = metadata;
_lastCreatedAt = latest.createdAt;
final cache = ref.read(userCacheProvider.notifier);
for (final event in events) {
cache.cacheProfileEvent(event);
}
_hasHydrated = true;
return profile;
return ref.read(userCacheProvider)[myPk.toLowerCase()];
}
Future<void> refresh() async {
@@ -182,9 +161,12 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
NostrFilters.profile(pubkey),
);
_requireCurrentWriteContext(context);
final currentHead = _latestProfileEvent(currentEvents);
if (_lastCreatedAt > 0 &&
(currentHead == null || currentHead.createdAt < _lastCreatedAt)) {
final cache = ref.read(userCacheProvider.notifier);
for (final event in currentEvents) {
cache.cacheProfileEvent(event);
}
final currentHead = cache.profileEvent(pubkey);
if (currentHead?.id != _latestProfileEvent(currentEvents)?.id) {
throw StateError('Cannot confirm the latest profile metadata.');
}
final currentMetadata = currentHead == null
@@ -199,10 +181,7 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
final relay = SignedEventRelay(session: session, nsec: context.config.nsec);
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final currentCreatedAt = currentHead?.createdAt ?? 0;
final previousCreatedAt = currentCreatedAt > _lastCreatedAt
? currentCreatedAt
: _lastCreatedAt;
final createdAt = now > previousCreatedAt ? now : previousCreatedAt + 1;
final createdAt = now > currentCreatedAt ? now : currentCreatedAt + 1;
NostrEvent? signedEvent;
await relay.submit(
kind: EventKind.profile,
@@ -216,27 +195,20 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
if (submittedEvent == null) {
throw StateError('Profile update was not signed.');
}
final verifiedHead = _latestProfileEvent(
await session.fetchHistory(NostrFilters.profile(pubkey)),
final verification = await session.fetchHistory(
NostrFilters.profile(pubkey),
);
_requireCurrentWriteContext(context);
for (final event in verification) {
cache.cacheProfileEvent(event);
}
final verifiedHead = _latestProfileEvent(verification);
if (verifiedHead?.id != submittedEvent.id) {
throw StateError('Profile changed before the update could be confirmed.');
}
_metadata = nextMetadata;
_lastCreatedAt = createdAt;
final profile = UserProfile(
pubkey: pubkey,
displayName:
_metadata['display_name'] as String? ?? _metadata['name'] as String?,
avatarUrl: _metadata['picture'] as String?,
about: _metadata['about'] as String?,
nip05Handle: _metadata['nip05'] as String?,
ownerPubkey: verifiedOaOwnerPubkey(submittedEvent),
);
state = AsyncData(profile);
ref.read(userCacheProvider.notifier).put(profile);
cache.cacheProfileEvent(submittedEvent);
state = AsyncData(ref.read(userCacheProvider)[pubkey.toLowerCase()]);
}
void _requireCurrentWriteContext(_ProfileWriteContext context) {

View File

@@ -15,7 +15,7 @@ import 'user_profile.dart';
class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
final Set<String> _pending = {};
int _generation = 0;
final Map<String, ({int createdAt, String eventId})> _profileEventOrders = {};
final Map<String, NostrEvent> _profileEvents = {};
Timer? _batchTimer;
Completer<bool>? _batchCompleter;
@@ -25,7 +25,7 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
ref.watch(myPubkeyProvider);
_generation++;
_pending.clear();
_profileEventOrders.clear();
_profileEvents.clear();
ref.onDispose(() {
_generation++;
_batchTimer?.cancel();
@@ -45,11 +45,19 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
return null;
}
/// Stores a profile that was fetched or updated outside the batch loader.
/// Seeds an absent profile for local display before signed evidence arrives.
/// Never replaces existing state; fetched/confirmed profiles must use
/// [cacheProfileEvent] so ownership and event metadata advance together.
void put(UserProfile profile) {
state = {...state, profile.pubkey.toLowerCase(): profile};
final pubkey = profile.pubkey.toLowerCase();
if (state.containsKey(pubkey)) return;
state = {...state, pubkey: profile};
}
/// The governing kind:0 event, including metadata needed for profile edits.
NostrEvent? profileEvent(String pubkey) =>
_profileEvents[pubkey.toLowerCase()];
/// Preload profiles for a list of pubkeys (e.g. channel members).
/// Returns whether the batch completed successfully.
Future<bool> preload(List<String> pubkeys) {
@@ -85,13 +93,11 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
);
if (generation != _generation) return false;
final updated = Map<String, UserProfile>.from(state);
final updatedOrders = Map<String, ({int createdAt, String eventId})>.from(
_profileEventOrders,
);
final updatedOrders = Map<String, NostrEvent>.from(_profileEvents);
for (final event in events) {
_cacheProfileEvent(event, updated, updatedOrders);
}
_profileEventOrders
_profileEvents
..clear()
..addAll(updatedOrders);
state = updated;
@@ -138,14 +144,12 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
if (generation != _generation) return;
final updated = Map<String, UserProfile>.from(state);
final updatedOrders = Map<String, ({int createdAt, String eventId})>.from(
_profileEventOrders,
);
final updatedOrders = Map<String, NostrEvent>.from(_profileEvents);
for (final event in events) {
_cacheProfileEvent(event, updated, updatedOrders);
}
_profileEventOrders
_profileEvents
..clear()
..addAll(updatedOrders);
state = updated;
@@ -163,21 +167,21 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
bool _cacheProfileEvent(
NostrEvent event,
Map<String, UserProfile> profiles, [
Map<String, ({int createdAt, String eventId})>? orders,
Map<String, NostrEvent>? orders,
]) {
if (event.kind != 0) return false;
final eventOrders = orders ?? _profileEventOrders;
final eventOrders = orders ?? _profileEvents;
final pubkey = event.pubkey.toLowerCase();
final current = eventOrders[pubkey];
final isNewer =
current == null ||
event.createdAt > current.createdAt ||
(event.createdAt == current.createdAt &&
event.id.compareTo(current.eventId) < 0);
event.id.compareTo(current.id) < 0);
if (!isNewer) return false;
profiles[pubkey] = _profileFromEvent(event);
eventOrders[pubkey] = (createdAt: event.createdAt, eventId: event.id);
eventOrders[pubkey] = event;
return true;
}

View File

@@ -77,6 +77,89 @@ void main() {
);
});
test(
'late confirmed publication retains newer cache authority and metadata',
() async {
final keys = nostr.Keys.generate();
final owner = nostr.Keys.generate();
final initial = NostrEvent.fromJson(
nostr.Event.from(
secretKey: keys.secret,
createdAt: 1,
kind: 0,
tags: [_authTag(owner, keys.public)],
content: jsonEncode({'display_name': 'Owned'}),
).toMap(),
);
final session = _ProfileRelaySession(initial);
final container = _profileContainer(keys.nsec, session);
addTearDown(container.dispose);
final cache = container.read(userCacheProvider.notifier);
await container.read(profileProvider.future);
expect(cache.profileEvent(keys.public)?.id, initial.id);
NostrEvent? revoked;
session.beforeHistoryReturn = () {
if (session.published.length != 1) return;
revoked = NostrEvent.fromJson(
nostr.Event.from(
secretKey: keys.secret,
createdAt: session.published.single.createdAt + 1,
kind: 0,
tags: const [],
content: jsonEncode({'display_name': 'Revoked', 'custom': 'new'}),
).toMap(),
);
cache.cacheProfileEvent(revoked!);
};
await container.read(profileProvider.notifier).updateAbout('Late');
expect(
container.read(profileProvider).requireValue?.displayName,
'Revoked',
);
final submitted = session.published.single;
cache.put(
UserProfile(
pubkey: keys.public,
displayName: 'Unordered',
ownerPubkey: owner.public,
),
);
cache.cacheProfileEvent(submitted);
cache.cacheProfileEvent(revoked!);
expect(cache.profileEvent(keys.public)?.id, revoked!.id);
expect(
container.read(userCacheProvider)[keys.public]?.ownerPubkey,
isNull,
);
expect(
container.read(profileProvider).requireValue?.displayName,
'Revoked',
);
// A stale hydration response must use the same governing event, not
// recreate detached state or lower the metadata used by the next edit.
session.beforeHistoryReturn = null;
await container.read(profileProvider.notifier).refresh();
expect(
container.read(profileProvider).requireValue?.displayName,
'Revoked',
);
await expectLater(
container.read(profileProvider.notifier).updateAbout('Unsafe'),
throwsStateError,
);
expect(session.published, hasLength(1));
session.profile = revoked!;
await container.read(profileProvider.notifier).updateAbout('Safe');
final next = session.published.last;
expect(next.createdAt, greaterThan(revoked!.createdAt));
expect(next.tags, isEmpty);
expect(jsonDecode(next.content)['custom'], 'new');
expect(cache.profileEvent(keys.public)?.id, next.id);
cache.cacheProfileEvent(revoked!);
expect(cache.profileEvent(keys.public)?.id, next.id);
},
);
test('clearing a display name restores the pubkey label fallback', () async {
final keys = nostr.Keys.generate();
final relaySession = _ProfileRelaySession(
@@ -607,7 +690,8 @@ class _MutableRelayConfigNotifier extends RelayConfigNotifier {
class _ProfileRelaySession extends RelaySessionNotifier {
_ProfileRelaySession(this.profile);
final NostrEvent profile;
NostrEvent profile;
void Function()? beforeHistoryReturn;
final List<NostrEvent> published = [];
@override
@@ -617,7 +701,11 @@ class _ProfileRelaySession extends RelaySessionNotifier {
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async => [profile, ...published];
}) async {
final result = [profile, ...published];
beforeHistoryReturn?.call();
return result;
}
@override
Future<NostrEvent> publish(

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'dart:typed_data';
import 'package:buzz/shared/profile/user_cache_provider.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -176,6 +177,34 @@ void main() {
expect(cache.state['agent']?.displayName, 'Valid');
});
test(
'unordered seed is first hydration only and cannot replace an event',
() {
final container = ProviderContainer();
addTearDown(container.dispose);
final cache = container.read(userCacheProvider.notifier);
cache.put(const UserProfile(pubkey: 'AGENT', displayName: 'Local'));
expect(cache.state['agent']?.displayName, 'Local');
expect(cache.profileEvent('agent'), isNull);
cache.cacheProfileEvent(
_profileEvent(id: 'b', createdAt: 2, name: 'New'),
);
cache.put(
const UserProfile(
pubkey: 'agent',
displayName: 'Late',
ownerPubkey: 'owner',
),
);
cache.cacheProfileEvent(
_profileEvent(id: 'a', createdAt: 1, name: 'Old'),
);
expect(cache.state['agent']?.displayName, 'New');
expect(cache.state['agent']?.ownerPubkey, isNull);
expect(cache.profileEvent('AGENT')?.id, 'b');
},
);
test('same-second profile tie keeps the lowest event id', () {
final container = ProviderContainer();
addTearDown(container.dispose);