Skip to content

fix: speed up pubky profile loading - #854

Open
Jasonvdb wants to merge 82 commits into
masterfrom
claude/flow-vibe-pubky-profile-load-lag
Open

Jasonvdb wants to merge 82 commits into
masterfrom
claude/flow-vibe-pubky-profile-load-lag

Conversation

@Jasonvdb

@Jasonvdb Jasonvdb commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Twin: synonymdev/bitkit-android#1399

Fixes the lag when loading pubky profiles and setting up a profile with contacts, which got much worse with the shared Pubky Ring keychain from #774.

Where the lag came from. All Paykit calls go through one lock, and each call kept it for its whole network round trip. That included simple public reads, like fetching a profile name or avatar, or checking how a contact can be paid. So lookups queued behind each other, behind sign-in, and behind background Paykit work. A pubky that was never published takes 2–7 s to fail, and everything else waited for it. On top of that:

  • The Pubky Ring choice screen and adopting a Ring identity added slow, repeated lookups to that queue. Adopting also waited about 4 s for an identity refresh while holding the lock, then fetched the profile again.
  • Before the import screen appears, every follow's profile is looked up. Those lookups ran one at a time, each retried once, with nothing moving on screen.
  • The Contacts screen waited for every contact's profile before showing anything.

How this fixes it. Public reads skip the lock and run side by side, up to six at once. Background reads (the contact list, private payment setup, payment checks) may use at most four of those six, so what you are looking at (your avatar, your profile, the Ring rows, the import screen) never waits behind them. Anything that touches your session, keys or saved Paykit data still goes through the lock. The choice screen shows its rows straight away with a spinner on each, and keeps found profiles for the session. Adopting reuses the profile its row already found, and sign-in no longer waits for the identity refresh. Contacts shows your saved contacts at once and fills in names and avatars as they load. Saving the imported contacts without new lookups came from #851, which this branch now includes from master.

Simulator, staging network, one take per build Before After
Published name visible 9.7 s 1.9 s
Tap a Ring row → Pay Contacts (identity with no follows) 9.6 s, nothing on screen moves 4.6 s, spinner on the row
Tap a Ring row → import screen (8 follows, 6 never published) 60.1 s, nothing on screen moves 8.8 s, spinner on the row

With QA's 61-follow Pubky Ring profile (simulator, staging network; 54 follows have profiles, 7 were never published). The before column is this PR before the Contacts and Delete fixes:

Before Now
Tap a Ring row → import screen 22–23 s 22 s
Continue on "Let your contacts pay you" 104–106 s 86 s
Contacts: profile lookups per visit 61 7 (only contacts with no profile)
Contacts: list updates per refresh 54 0
Fetches of each avatar that fails 5 per run 1
Delete Profile after 5 Contacts visits 645 s 100 s

Description

  • Public profile, avatar, follows and payment-route reads no longer wait for the Paykit lock. Up to six run at once, at most four of them for background work, and a freed slot goes to what you are looking at before queued background reads
  • Sign-in starts the identity refresh in the background instead of waiting up to 5 s for it; auth approvals still wait for it, including a refresh that is already running
  • The Pubky Ring choice screen shows its rows at once, with a spinner on each row while its profile loads and on the row being adopted, and stays up until the adoption finishes
  • Ring row profiles are kept for the session; rows with no profile are looked up again when the app returns to the foreground
  • Adopting a Ring identity reuses the profile its row already found, and goes to Create Profile if that profile turns out to have been removed
  • The follows shown on the import screen are looked up side by side, with no retry, instead of one at a time
  • Contacts shows saved contacts at once, using an edited profile, a stored profile, a profile found earlier this session, or the saved name, then fills in profiles as they load. Opening a contact whose profile hasn't loaded yet looks it up at once, ahead of the background refresh, and editing or tagging it waits for that one lookup, so an edit can't save over a profile that is still arriving. If the lookup fails, the edit saves as it does on master
  • A contact import that finishes after you leave it no longer takes you to Pay Contacts, and an import that a sign-out overtakes stops saving
  • The Profile screen shows your cached name and avatar while it loads
  • Contact lookups retry only when adding a contact, and never after "not found"
  • Leaving a screen while it loads contacts keeps the contacts already loaded and shows no error, including while the load waits for the Paykit lock; leaving Settings > General still finishes turning on contact payments, unless you sign out of Pubky first
  • Turning contact payments on or off from Settings > General or Pay Contacts stops when you sign out of Pubky meanwhile: it writes no settings, publishes nothing after sign-out removed the endpoints, and leaves sign-out's cleanup marks in place
  • Avatar cache writes that finish after sign-out are dropped. An avatar that failed because it is missing or too large is not fetched again until the avatar cache is cleared. Other failures are tried again after 60 s
  • Contacts does not look up again a profile it found in the last 10 minutes. It applies refreshed profiles at most once every 300 ms, and skips any that haven't changed
  • Opening Contacts no longer restarts private payment link setup for every contact. The app now announces a saved-contacts change only when contacts are added or removed, and Import announces it once. Before, each Contacts visit queued a walk over every contact, and Delete Profile waited behind all of them
  • Removing a contact stops its pending profile lookup, and deleting all contacts stops the refresh
  • A tag change queued while a contact's profile loads is dropped if you sign out or switch identity before it saves
  • Public reads are refused during a wallet wipe, and a read the wipe overtakes is dropped

Out of Scope

  • Bitkit/Services/PubkyService.swift: app launch still restores the Paykit session twice; removing that needs Paykit input
  • Bitkit/MainNavView.swift: opening Profile before initialization finishes still shows the full-screen loading view
  • Bitkit/AppScene.swift: the private link burst still holds the Paykit lock for each link attempt; changing its timing needs Paykit input
  • Continue on "Let your contacts pay you" and Delete Profile still wait for private payment link walks over every contact (about 90–120 s each with 61 contacts); feat: share paykit state across apps #856 rewrites that code
  • The import screen has no per-follow deadline on iOS (Android has one). The Paykit Swift bindings can't cancel a read once it has started, so a deadline couldn't free that read's slot
  • Contacts still looks up, on each visit, every contact without a profile (for example a never-published follow)
  • Bitkit/Views/Profile/CreateProfileView.swift: the form still waits for the existing-profile lookup
  • paykit-rs: a never-published pubky still takes 2–7 s to fail its lookup, because pkarr NotFound surfaces as a transport error. That is most of the remaining 8.8 s before the import screen
  • Contacts are still saved one at a time, because Paykit has no batch save
  • Bitkit/Views/Contacts/EditContactView.swift: editing a contact whose profile lookup failed still saves an empty bio, avatar and links over the published ones, as on master; saving only the changed fields needs a new override format

How to review

Production code is about a quarter of this PR; most of the rest is tests (about 2,470 lines, with near-duplicate cases merged into case tables). Suggested order, one area at a time:

  1. Paykit read path (the core fix): Bitkit/Services/PubkyService.swift, with withPublicRead, PaykitSdkReadLimiter and the background identity refresh. Tests: PaykitSdkReadLimiterTests, PaykitPublicReadLaneTests, PaykitSdkOperationLockTests, PaykitSdkClientConfigTests, PubkyIdentityRepublishTests
  2. Profile and Ring choice screen: PubkyProfileManager.swift, PubkyChoiceView.swift, PubkyChoiceRow.swift, ProfileView.swift, PubkyImage.swift. Tests: PubkyProfileManagerTests, PubkyChoiceViewTests, PubkyImageCacheTests
  3. Contacts: ContactsManager.swift (the list shown at once, the per-identity session profile cache with its freshness window, the batched background refresh, the saved-contacts announcement, queued tag changes bound to their session, and a screen's lookup taking over a contact's background lookup), then the views that call it: ContactDetailView.swift, EditContactView.swift, the import views and GeneralSettingsView.swift. Tests: ContactsManagerTests, ContactImportUITests
  4. Payment checks: PaykitPaymentRequestService.swift and PrivatePaykitService+Contacts.swift, which only choose a read lane
  5. Journeys: journeys/pubky-profile/

What to look for: reads no longer queue behind the Paykit lock, so a result can now arrive late, after a sign-out, an identity switch, a newer save or a wallet wipe. Most of the new code drops those late results (owner checks, generation counters, the wipe check). Each of those guards has a test that holds a fake Paykit call halfway, runs the competing action, then releases the call.

Design

N/A — no design available. The spinners reuse existing components.

Preview

Choosing a Pubky Ring identity

ios-before-after.mp4

Left: master (ab88d1c, plus a two-line compile fix it needed then). Right: this PR (ad6b5d5). Same simulator, same taps, fresh app data, staging network, one take each. On master the rows show bare keys for about 10 s, and after the tap the screen does not change for about 9 s. With this PR each row shows a spinner until its profile loads, and the tapped row spins while it is adopted.

Setting up a profile and importing contacts

ios-import-before-after.mp4

Left: master (e33dc08). Right: this PR (54e13d0). Same simulator, same taps, staging network, one take each, aligned on the Ring row tap. Each take chooses the Pubky Ring identity, taps Import All, then opens Contacts and Profile. The test identity has no real pubky.app follows, so both builds use the same debug-only hook that returns 8 follows: 2 published and 6 never published, all created for this test. On master the screen does not change for about 60 s after the tap. With this PR the tapped row spins and the import screen opens in about 9 s. Import All, Contacts and Profile are quick on both (thanks to #851), but master shows a "Failed to load contacts" error after you leave Contacts.

QA Notes

Journeys

  • new cached-profile-header.xml — Profile opened while loading shows the cached name and avatar without edit actions, then the full profile
  • new ring-choice-rows.xml — Pubky Ring rows show at once with a spinner each until their profile loads; adopting spins only the tapped row and disables the rest
  • new contact-import-after-leaving.xml — leaving the import screen right after Import All stays on Home, never opens Pay Contacts, and Contacts later lists every follow
  • new contacts-list-loading.xml — saved contacts show at once with their stored names, profiles fill in, and the full-screen spinner shows only until the first load

Manual Tests

  • With Pubky Ring holding a signed-up pubky and a never-published one, open the profile choice screen → both rows show at once with spinners, and the signed-up row resolves without waiting for the other — Pubky Ring not in Capabilities
  • Tap a Ring row → only that row spins, the other rows are disabled, and Create Profile or Pay Contacts opens — Pubky Ring not in Capabilities
  • Leave the choice screen and come back, then background and foreground the app → found rows are not looked up again, missing rows are — Pubky Ring not in Capabilities
  • Adopt a Ring identity that follows several pubkys, some never published → the tapped row spins and the import screen opens within seconds — Pubky Ring not in Capabilities

Automated Checks

  • added PaykitSdkReadLimiterTests.swift — the read limiter keeps its cap and FIFO order, keeps two slots for what you are looking at, gives a freed slot to an interactive read before queued bulk reads, and releases cancelled waiters
  • added PaykitPublicReadLaneTests.swift — background lookups leave room for interactive reads, receiver and payment-route reads don't hold the lock, and public reads are refused during a wallet wipe and dropped when a wipe overtakes them
  • updated PaykitSdkOperationLockTests.swift — unlocked work still goes through wipe admission
  • updated PaykitSdkClientConfigTests.swift — sign-in returns while the identity refresh is still running
  • updated PubkyIdentityRepublishTests.swift — approvals wait for a refresh sign-in started, up to the cap
  • updated PubkyProfileManagerTests.swift — Ring row cache, adopt reuse, stale profile loads after a write, cached header owner check, row lookup spinners, and lookups shared by two choice screens
  • updated PubkyChoiceViewTests.swift — when a row shows its lookup spinner
  • updated ContactsManagerTests.swift — tag changes queued during a contact's lookup keep the profile it finds and each other, a contacts load cancelled while its record read waits for the lock stays quiet, a screen's lookup taking over the contact's background lookup, retry policy per caller, import screen lookups on the interactive lane, the Contacts list publishing before lookups finish and reusing profiles found this session, an import that a sign-out overtakes, opening Pay Contacts only while the import is still pending, skipping lookups for profiles found in the last 10 minutes, batched refresh publishes that skip unchanged profiles, announcing saved-contact changes only when the keys change, stopping lookups for removed contacts, and dropping queued tag changes after a sign-out or identity switch
  • updated ContactImportUITests.swift — an import finishing after you leave the overview or the selection screen does not open Pay Contacts
  • updated ContactPaymentsServiceTests.swift — a contact payments change stops when Pubky signs out during its contacts load or its publication, and writes nothing once the session changed
  • updated PaykitPaymentRequestServiceTests.swift — a contact's payment check uses the interactive lane
  • updated PubkyImageCacheTests.swift — avatar stores that started before a clear are dropped, and failed fetches are remembered until a clear (missing or too large) or for 60 s (other errors)

Profile, avatar and follows lookups are unauthenticated public reads, so they now run through a bounded, cancellable limiter (six at a time) instead of the Paykit SDK operation lock, taking the lock only when no SDK exists yet. Sign-in, sign-up and session import start the identity republish in the background rather than holding the lock while it waits on the DHT.
The choice screen now keeps found Ring profiles for the session and remembers misses until the app returns to the foreground, dedupes lookups in flight, and runs one cancellable load that stops when the screen goes away or an adopt starts. Adopting reuses the row's profile for that key instead of fetching it again, shows a spinner on the tapped row and keeps the other rows disabled. A profile load that started before a save, adopt or sign-out no longer overwrites the newer profile.
A missing profile is never retried, and bulk contact loads and follow discovery no longer retry a failed lookup, so each contact without a published profile costs one lookup instead of two. Adding a contact and importing contacts still retry a failed lookup once, and a lookup cancelled during that wait no longer starts the retry.
Adopting a Pubky Ring identity now stops only the other rows' lookups, so the tapped row's result can still land in time to be reused, and a failed adopt reloads the rows instead of leaving bare keys. Adoption looks the row profile up by the normalized pubky, and when it reuses one it refreshes the profile in the background without holding up navigation or deciding profile setup. The ring lookup tests now fail at a deadline instead of hanging when a request is never answered.
@greptile-apps

greptile-apps Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 3/5

[Medium risk] Optimizes profile loading with caching and concurrency limits.

The PR is not ready to merge because Ring adoption can bypass required profile setup and sign-out can leave an old avatar in memory.

Findings

  1. P1 Stale profile skips setup ▶
  2. P1 Disk read restores cleared avatar ▶
  3. P2 Old Ring profiles remain visible ▶

Summary

This PR moves public Pubky reads to a bounded concurrent lane, reuses Ring row profiles during adoption, and adds cached profile presentation and cancellation-aware loading.

  • Ring adoption can treat a removed remote profile as still present.
  • Ring row display and image-cache clearing have stale-cache paths that need attention.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Ring[Ring identities] --> Rows[Bounded public profile reads]
  Rows --> Cache[Session row-profile cache]
  Cache --> Adopt[Adopt selected identity]
  Adopt --> Route[Profile-setup routing]
  Adopt --> Refresh[Background profile refresh]
  Image[Avatar disk read] --> Memory[Image memory cache]
  SignOut[Sign-out clear] --> Memory
Loading

Reviews (1) · Last reviewed commit: "test: add pubky profile loading journeys"

Comment on lines 333 to 340
var adoptedProfile = PubkyPublicKeyFormat.normalized(adoptedPublicKey).flatMap { ringIdentityProfiles[$0] }
let reusesRowProfile = PubkyPublicKeyFormat.matches(adoptedProfile?.publicKey, adoptedPublicKey)
if !reusesRowProfile {
adoptedProfile = await fetchRemoteProfile(publicKey: adoptedPublicKey)
}
clearRingIdentityProfiles()

setProfileSetupPending(adoptedProfile == nil)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stale profile skips setup

If a Ring profile is removed after its row was loaded, adoption reuses the cached profile, marks setup complete, and routes past Create Profile. A later not-found result from the background refresh does not clear the profile or restore the setup requirement, so the user is not prompted to repair the missing profile.

Knowledge Base Used: Contacts and Pubky identity

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in bc65361. When adoption reuses a Ring row profile, the follow-up refresh now treats a definitive not found for the adopted key as removed: it clears the profile and its cached metadata and sets profile setup pending, so Create Profile is shown. A transport error leaves the reused profile in place. Covered by testRingAdoptionStartsProfileSetupWhenTheReusedRowProfileWasRemoved and testRingAdoptionKeepsTheReusedRowProfileWhenItsRefreshFails.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The missing-profile state is now cleared, but contact discovery can still dismiss Create Profile. If this refresh returns not-found while destinationAfterAuthentication is running, MainNav opens setup, then adopt replaces it with the import or Pay Contacts screen when discovery finishes. The setup resume flag is already set, so it does not reopen. Could we recheck isProfileSetupPending after discovery before replacing the navigation path, and cover that ordering?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — guard the navigation replacement after contact discovery completes. Otherwise the discovery result can overwrite the Create Profile route opened by MainNavView when the refresh reports the profile was removed.

Suggested change
var adoptedProfile = PubkyPublicKeyFormat.normalized(adoptedPublicKey).flatMap { ringIdentityProfiles[$0] }
let reusesRowProfile = PubkyPublicKeyFormat.matches(adoptedProfile?.publicKey, adoptedPublicKey)
if !reusesRowProfile {
adoptedProfile = await fetchRemoteProfile(publicKey: adoptedPublicKey)
}
clearRingIdentityProfiles()
setProfileSetupPending(adoptedProfile == nil)
let destination = await contactsManager.destinationAfterAuthentication(
profile: pubkyProfile.profile,
publicKey: adopted.publicKey
)
guard !pubkyProfile.isProfileSetupPending else {
return
}
navigation.path = [destination]

Please also add a regression test covering this ordering: discovery produces an import or Pay Contacts destination, then isProfileSetupPending becomes true, and the destination is not applied. The pending-setup observer should then keep the user on Create Profile.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4f63883. After contact discovery, adoption checks isProfileSetupPending again. While setup is pending it drops the discovered route and its pending import, so Create Profile stays open. Covered by testContactDiscoveryFinishingAfterProfileSetupStartsDoesNotReplaceCreateProfile.

Comment on lines +78 to +81
.onReceive(pubkyProfile.$ringIdentityProfiles) { found in
// Only ever add: adopting clears the manager's cache while this screen is still up, and rows must not
// flash back to bare keys before navigation.
ringProfiles.merge(found) { _, latest in latest }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Old Ring profiles remain visible

This merge adds or updates profiles but never removes them. If adoption clears the manager’s cache and the choice screen later reloads, a lookup that finds no profile cannot remove the old name and avatar from ringProfiles. The row can therefore keep showing outdated identity details.

Knowledge Base Used: Contacts and Pubky identity

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in bc65361. The rows now mirror the manager's found profiles exactly whenever no adoption is running, so a row whose lookup finds nothing drops the old name and avatar. Entries are only kept without removal while an adoption clears the cache before navigating. Covered by testMirroredRingProfilesEqualTheFoundProfilesWhenNotAdopting and testMirroredRingProfilesOnlyAddWhileAdopting.

Comment on lines 263 to +266

private func clearMemoryCache() {
memoryLock.lock()
clearGeneration &+= 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Disk read restores cleared avatar

If a disk-cache read is queued before sign-out but runs after clear() bumps the generation, it can still copy the old image into memory before the queued disk deletion. That memory write has no generation check, so the previous identity’s avatar remains cached after sign-out.

Knowledge Base Used: Contacts and Pubky identity

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 44b3460. The image loader now captures the cache generation before its disk lookup, and the disk-to-memory promotion is gated by it (the generation parameter is no longer optional), so a read queued before sign-out can't put the old avatar back. Covered by testDiskPromotionCapturedBeforeClearIsNotKeptInMemory.

Resolve conflicts in ContactsManager and PubkyProfileManager, and drop
the duplicated sdkFactory declaration that master carries.
…ght row lookup

Adopting a Pubky Ring key writes its reference before sign-in finishes, so a row result landing mid-sign-in swapped the profile destination to an empty profile. The destination now keeps the choice screen while adoption is in progress, and returns to it when adoption fails. Adoption awaits the tapped row's lookup that is still in flight instead of fetching the same key again, and automatic session recovery while signed out no longer discards the choice rows and their lookups.
…tact lookups on tap

A disk hit promoted to the memory cache skipped the clear generation check, so a lookup that raced a sign-out could put the previous identity's avatar back in memory. The generation is now read once before the lookup and required for every memory store. The contact detail Retry button retries a transient failure once, and activation is covered returning while the identity republish is still running.
…r row profiles exactly

The refresh after adoption reuses a Ring row profile now treats a definitive
not-found for the adopted key as a removed profile: it clears the profile and
its cached metadata and marks profile setup pending, unless the identity
changed or a newer profile write landed. Transport failures keep the reused
profile. The choice screen's row profiles now equal the manager's cache
outside an adoption and only merge while one runs.
A Ring row now shows a spinner in its avatar slot until its profile
lookup finishes, whatever the outcome, so a row still looking up no
longer looks like one whose identity has no profile. The adopting row
keeps only its key-icon spinner, and stopped lookups drop their spinner
straight away.
…ng and share ring row lookups between choice screens
…bky-profile-load-lag

# Conflicts:
#	Bitkit/Views/Profile/CreateProfileView.swift
#	BitkitTests/PaykitSdkClientConfigTests.swift
Public reads now have a bulk lane: contact list and import profile lookups
take one of four bulk slots before one of the six read slots, so they can
never hold more than four. Avatars, the own profile, Ring rows and
single-contact lookups take only a read slot, so at least two stay free for
them while a large contact list loads.
Receiver path and marker reads are public reads, so contact receiver
discovery, private receiver path selection and payment request receiver
checks now run on the public read lane instead of holding the global SDK
lock for every network round trip. Private sync, import and payment request
checks use the bulk lane; adding or opening a single contact stays
interactive. Saved records are still read and written under the lock, and
selection still protects every saved path when no SDK can be built.
Import no longer looks every follow's profile up again, with a retry, before
saving it. It saves the profiles the import screens already show. A follow
whose lookup failed is still imported as a placeholder, on the wallet
receiver path and without receiver discovery, since private sync discovers
and merges a saved contact's receivers before it uses them. Only a failed
save skips a contact, and an import stops saving once the Pubky session is
reset by a sign-out or identity change.
Contacts used to show a spinner over an empty list until every contact's
profile lookup had finished, which takes seconds per contact with no
published profile. A load now publishes the saved records at once, using a
contact's profile override, its stored Paykit profile, a profile resolved
earlier this session for the same identity, or its saved label, then looks
the rest up in the background on the bulk read lane and updates rows as they
resolve. A failed lookup leaves its row as it is. Profiles resolved while
preparing an import, importing or adding a contact seed that session cache,
which a reset or another identity's load clears. Profile refreshes do not
count as a change to the saved contacts, so private Paykit sync does not
rerun for every row that fills in.
The import already runs on its own task, so leaving the import screens does
not stop it. Select stayed enabled while Import All ran, though, and each
screen only knew about its own import, so a second import of the same
follows could start alongside the first. The contacts manager now reports
whether an import is running, and both import screens disable Select,
Import All and Continue until it finishes.
Adds journeys for importing a Ring identity's follows and for Contacts
showing saved contacts while their profiles load, documents them in the
pubky-profile suite README, and extends the changelog fragment to cover the
faster import and contacts list.

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA review

Scope: Follow-up review of the delta since 5164485, at ed6b97a. The previous full review is this review. Comparison base and merge base are still 0cdb704. Inherited baseline coverage is the public read lane and limiter, identity republish before approval, Ring row lookup and adoption, cached profile header, contact load/import/edit/tag paths, and payment-check lane selection. This pass inspected read-slot priority, the cancelled contact-record read, and the contact-payments session check.

1 actionable finding — resolve or provide an evidence-backed rebuttal.

A freed read slot now goes to the oldest interactive waiter before any queued bulk read, and a contacts load returns quietly when a cancelled record read throws. That matches the interactive-read thread and the cancelled-load thread. The new session check covers a sign-out during the contacts load, which that thread asked for. Publication that has already passed the check is still open.

Run Tests and Run Integration Tests are still pending on this revision. The new limiter, contacts, and contact-payments tests were inspected and were not run here. Device testing was not performed. The journeys/pubky-profile/ walkthroughs were not executed. bitkit-android#1399 was not reviewed.

Suggested additional test cases

  • iOS, signed in with a local Pubky secret, first visit to Settings > General so contact payments turn on after the contacts load: while private or public endpoint publication is still in progress, sign out of Pubky. The confirmed preference and both publishing flags stay off, wallet endpoints are not published after sign-out removes them, and a removal that failed stays marked for cleanup.

Device testing: not performed in this review.

Findings

  • [MEDIUM] Contact payment enable can publish after Pubky sign-out — inline at Bitkit/Services/ContactPaymentsService.swift:96.

}
guard pubkyProfile.currentSession == session else { return }

try await setEnabled(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Contact payment enable can publish after Pubky sign-out

On a first visit to Settings > General, contact payments turn on in a task that outlives the screen. Sign out of Pubky after the contacts load has returned and the session check has passed, while private or public endpoint publication is still in progress.

setEnabled(_:pubkyProfile:contactsManager:operations:) keeps currentSession and checks it again when the load finishes. It then awaits the inner setEnabled, which writes the confirmed preference and both publishing flags before awaiting preparePrivateEndpoints and syncPublicEndpoints(true). The comment says nothing suspends between that check and the preference writes. The await of the inner call is a suspension point, and so are the publication awaits after the writes, so sign-out can run once the last session check has passed. Sign-out removes endpoints and then calls PubkyService.signOut() as separate steps, and it does not remove endpoints again. A publication that takes the endpoint lock after that removal and before SDK sign-out writes the wallet endpoints back. On success the enable then calls setPublicCleanupPending(false) and, when private payments are available, setPrivateCleanupPending(false), clearing a cleanup mark sign-out left behind. If sign-out finishes during the suspension before the inner setEnabled starts, that function still writes the preference and publishing flags with no further session check.

Wallet receive endpoints can remain published for a Pubky that has signed out, with cleanup no longer pending, and the confirmed preference can be turned back on. testContactPaymentsChangeStopsWhenPubkySignsOutDuringTheContactsLoad signs out only while the contacts load is held, before this publication starts.

The enable should stop once the Pubky session has ended or changed, including while publication is in flight: the confirmed preference and publishing flags stay off, endpoints are not published after sign-out removes them, and a failed removal stays marked for cleanup.

Evidence basis: source analysis at ed6b97a. This path was not executed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, the inner call and the publication awaits are real suspension points, so a sign-out could land after the last check. Fixed in 93a3e31: the enable now checks the session right before it writes the preference and flags and right before it clears a cleanup mark, skips the restore once the session has changed, and hands the same check to both publications, which check it again under the public endpoint lock and the private publication lock that sign-out's removals also take. Sign-out changes the session before it removes anything, so a publication that gets its lock after the removal now writes nothing (the receiver marker moved under the public lock too), and 0a5d47a sends Pay Contacts through the same session-checked enable. testContactPaymentsEnableStopsWhenPubkySignsOutDuringPublication holds the public publication, then the private one, before its lock through a sign-out whose public removal fails, and holds a public publication inside its lock through a sign-out that fails: nothing is published after the removal, the flags stay off after the sign-out, nothing is restored, and the cleanup mark stays set. testContactPaymentsChangeWritesNothingOnceThePubkySessionChanged covers a sign-out landing between the last check and the writes, and each guard has a negative control that fails without it.

@piotr-iohk piotr-iohk added this to the 2.6.0 milestone Oct 2, 2026

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA review

Scope: Follow-up review of the delta since ed6b97a, at 0a5d47a. The previous review is this review. Comparison base and merge base are still 0cdb704. Inherited baseline coverage is the public read lane and limiter, identity republish before approval, Ring row lookup and adoption, cached profile header, contact load/import/edit/tag paths, and payment-check lane selection. This pass inspected contact-payments enable and disable across sign-out, the publication locks, and Pay Contacts continue.

1 actionable finding — resolve or provide an evidence-backed rebuttal.

A contact-payments change rechecks the Pubky session before it writes the preference, publishing flags, or a cleared cleanup mark, and both endpoint publications check that session again under the locks sign-out uses to remove endpoints. That closes the publication-after-sign-out thread. Pay Contacts now uses that session-aware enable, and a quiet stop is still treated as success: Continue then replaces the navigation stack with Profile. The read-slot and cancelled contact-load fixes were not changed in this delta.

Run Integration Tests succeeded on this revision. Run Tests was still in progress. The new contact-payments tests were inspected and were not run here. Device testing was not performed. The journeys/pubky-profile/ walkthroughs were not executed. bitkit-android#1399 was not reviewed.

Suggested additional test cases

  • iOS, Pay Contacts on screen: tap Continue, leave, and sign out of Pubky before endpoint publication finishes. Stay on the screen you opened after leaving. Contact payments stay off, and wallet endpoints stay removed after sign-out.

Recommended before device testing

Wait for Run Tests on this revision.

Device testing: not performed in this review.

Findings

  • [LOW] Pay Contacts opens Profile when sign-out stops the enable — inline at Bitkit/Views/Profile/PayContactsView.swift:70.

contactsManager: contactsManager,
operations: .live(wallet: wallet)
)
navigation.path = [.profile]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] Pay Contacts opens Profile when sign-out stops the enable

On Pay Contacts, tap Continue, leave the screen, and sign out of Pubky while endpoint publication is still running.

Continue runs in an unstructured task. ContactPaymentsService.setEnabled returns without throwing once the captured Pubky session ends or changes, including while publication is in flight. continueFlow treats that return as success and sets navigation.path to [.profile].

The app then leaves the screen the user opened after Pay Contacts and shows the profile route, while contact payments stay off.

Stay on the screen the user navigated to, and leave contact payments off, matching an import that finishes after you leave it.

Evidence basis: source analysis at 0a5d47a. This path was not executed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed in 9d0a526: the session-checked ContactPaymentsService.setEnabled now returns whether the change was applied, and returns false once the Pubky session ends or changes, including while a publication is in flight. Pay Contacts opens Profile only for an applied enable while Pay Contacts is still the current route, so a sign-out that stops the enable leaves contact payments off with no navigation or toast, and an enable that finishes after you leave keeps you where you are, like an import that finishes after you leave it. testContactPaymentsEnableStopsWhenPubkySignsOutDuringPublication and testContactPaymentsChangeStopsWhenPubkySignsOutDuringTheContactsLoad now check the returned value, testPayContactsOpensProfileOnlyForAnAppliedEnableWhilePayContactsIsShowing covers the navigation decision, and each guard has a negative control that fails without it.

The session-checked contact payments change now returns whether it was applied. Pay Contacts opens Profile only for an applied enable while it is still showing, so an enable that a Pubky sign-out stopped, or one that finishes after the user left, leaves navigation alone.

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA review

Scope: Follow-up review of the delta since 0a5d47a, at 9d0a526. The previous review is this review. Comparison base and merge base are still 0cdb704. Inherited baseline coverage is the public read lane and limiter, identity republish before approval, Ring row lookup and adoption, cached profile header, contact load/import/edit/tag paths, payment-check lane selection, and the contact-payments session checks from that review. This pass inspected the enable result and Pay Contacts navigation after Continue.

No new actionable code findings.

ContactPaymentsService.setEnabled now reports whether the change was applied. Pay Contacts opens Profile only for an applied enable while Pay Contacts is still the current route, so a sign-out that stops the enable, and an enable that finishes after the user leaves, leave the navigation stack in place. That addresses the Pay Contacts navigation thread. The read-slot, cancelled contact-load, and publication-after-sign-out fixes are unchanged in this delta.

Run Tests and Run Integration Tests were still in progress on this revision. The updated contact-payments tests were inspected and were not run here. Device testing was not performed. The journeys/pubky-profile/ walkthroughs were not executed. bitkit-android#1399 was not reviewed.

Suggested additional test cases

  • iOS, Pay Contacts on screen and signed in: tap Continue, then leave before publication finishes (back, or sign out of Pubky). Stay on the screen opened after leaving, and Profile does not open. After sign-out, contact payments stay off and wallet endpoints stay removed.

Recommended before device testing

Wait for Run Tests and Run Integration Tests on this revision.

Device testing: not performed in this review.

@piotr-iohk

Copy link
Copy Markdown
Collaborator

Same test as Android twin, import profile with ~60 contacts. Much faster here (Let your contacts pay you is the slowest), also delete is fast:

bitkit_logs_2026-10-02_09-44-48.zip

Can be compared with #861 (comment)

@piotr-iohk

Copy link
Copy Markdown
Collaborator

Importing the same profile as Android is also fast. Comparing to Android UX is much better. Bottlenecks is import last step (Let contacts pay you) and delete profile. But I think this PR is good to merge unless you want to make more optimizations here. Attaching logs and recording (delete didn't finish on recording it finished a bit after):
bitkit_logs_2026-10-02_10-00-04.zip

Screen.Recording.2026-10-02.at.11.53.43.mov

@ovi-reviewer

ovi-reviewer Bot commented Oct 2, 2026

Copy link
Copy Markdown

@Jasonvdb conflicts

@piotr-iohk piotr-iohk left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA review

Full reassessment at 9d0a5269, covering all changed paths, affected callers, tests, SDK contracts and Android twin parity. This is an independent second pass of the full PR.

1 additional actionable finding from the full reassessment.

  • [MEDIUM] Invalidate queued tag changes when the Pubky session changes. Queue a tag on a saved contact while its profile lookup is delayed, sign out, then sign in with another identity and save/import the same contact before the old lookup finishes. The new tag task waits for the lookup, but reset only cancels/discards lookup results; it neither invalidates tagChanges nor checks the originating session before the subsequent save. A canceled native read can still finish, and updateContact saves through the current SDK, whose guard accepts an existing same-key contact. That old tag action would then alter the replacement identity's contact and recreate a local override after sign-out cleared it. Bind queued work to the session/contact generation, reject it after reset, and guard save admission and override publication. Add a regression holding the lookup across an owner change with the same contact and asserting no subsequent old-task save or override. An absent contact is correctly rejected; this finding does not claim simple deletion recreates contacts.

Evidence basis: source analysis at this revision, supported by an isolated Swift probe using the exact updateContactTags, reset, forgetResolvedProfiles and updateContact methods. With a held lookup and modeled SDK guard, the probe observed the old queued tag added to the replacement owner's same-key contact. Control and absent-contact cases behaved as expected. Native SDK/UI persistence was not exercised by this probe.

The earlier interactive priority, contact-load cancellation, publication after sign-out, and Pay Contacts navigation concerns are addressed in the current source. The existing tag regression verifies successful lookup/order preservation; it does not cover reset invalidating queued saves.

Validation: all seven existing read-limiter tests passed against the exact extracted Swift limiter on the macOS host. The app's other relevant test sources were inspected; the full iOS app suite was not run locally. Current-head unit tests and integration tests succeeded in CI. The four shared profile journeys align with Android in source. Native throughput and cancellation latency were not measured.

Suggested additional test cases

  • iOS: delay a label-only contact's public profile lookup, queue two tag changes, leave and sign out. Adopt another identity and add/import the same contact, then release the old lookup. Expect no tag, label or override from the old session to reach the new contact. Repeat with no matching new contact; expect no stale-session error toast.
  • Both platforms: tap a Ring identity before the other rows finish looking up, then fail adoption offline. Expect only the selected adoption spinner, disabled other rows during adoption, and retryable rows afterward.
  • iOS: restore the cached identity/session, then delay or fail its profile fetch. Confirm the cached avatar and name appear read-only during loading. Restore connectivity and confirm the full profile replaces them, without exposing a previous owner's header.

Device testing: not performed in this review. Earlier device reports remain attributed to their tested builds. Review proceeded with the user's explicit authorization despite the README-only merge conflict; that conflict still needs resolution before merge.

let previousChange = tagChanges[key]
let change = Task {
_ = await previousChange?.result
await resolvePendingContactProfile(publicKey: publicKey)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Invalidate queued tag changes when the Pubky session changes

Queue a tag while this contact's profile lookup is delayed, then sign out, adopt another identity and add/import the same contact before the old read finishes. This manager-owned task survives reset: forgetResolvedProfiles cancels/discards lookup results, but does not invalidate tagChanges, and this await is followed by a save without checking the originating session. Once the old read completes, updateContact uses the current SDK. Its existing-contact guard accepts the new identity's same-key contact, so the old tag action would modify that contact and persist a local override after sign-out cleared it. Bind queued work to its originating session/contact generation and reject stale work before save admission and override publication. Add a held-lookup/reset/same-contact regression asserting zero old-task saves or overrides.

Evidence: source analysis at 9d0a526 plus an isolated Swift probe using the exact manager methods and a modeled SDK record guard; it observed the old queued tag applied to the replacement owner's same contact. Native SDK/UI persistence was not exercised. The absent-contact control correctly rejects the save, so this does not claim simple deletion recreates contacts.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, confirmed and fixed in afff078.

What changed. Each queued tag change now records the session it started in and the Contacts reset generation. Both are checked at four points: before its lookup, after the lookup, right before the save, and before the override and row are written. A sign-out or an owner change clears the queue. A stale change saves nothing, writes no override and throws nothing, so no toast appears. ContactDetailView binds each change to pubkyProfile.currentSession, the same way Pay Contacts does.

Tests, in ContactsManagerTests.swift. Each one fails without the fix.

  • testTagChangesQueuedBeforeASignOutSaveNothingForTheNextIdentity is your case: two tags queued behind a held lookup, then a sign-out, then a new owner with and without the same contact.
  • testQueuedTagChangeStopsOnceItsSessionEndsOrContactsReset.
  • testTagSaveThatASessionChangeOvertakesPublishesNothing.

This push also merges master (#864 and others), which clears the conflict.

…bky-profile-load-lag

# Conflicts:
#	journeys/README.md
…n restores

Since #864, Contacts opened while a saved session restores shows Profile's
loading screen before the list. The contacts list loading journey now says
to wait for the list instead of reporting that spinner.
@jvsena42 jvsena42 mentioned this pull request Oct 2, 2026
6 of 18 tasks
A tag change on a contact's screen waits for that contact's profile
lookup. If the user signed out and signed in with another identity
before the lookup finished, the change then saved through the new
session, which accepts a contact with the same key, and wrote back the
local override that sign-out had cleared.

Each tag change is now bound to the Pubky session it was queued in and
to the Contacts reset generation. A reset or owner change forgets the
queue, and a stale change stops before the lookup, before the save and
again before the override and row are written, without saving or
reporting an error. Contact edits get the same reset check around
their save.
Every Contacts load reassigned the contacts and announced a saved contacts change, and an import announced twice. Each announcement starts a private Paykit walk over every saved contact behind the publication lock, so Contacts visits queued walks that Delete Profile then waited behind. The change is now announced once per change to the set of saved contact keys, and on the first load for each owner.
A Contacts load no longer looks up again every contact whose profile this session already resolved, so returning from a contact's screen does not re-resolve the whole list. Contacts without a resolved profile are still looked up on every load, a placeholder is no longer remembered as a resolved profile, and a reset or an owner change clears the state.
A background refresh sorted and published the whole contact list once per resolved profile, even when the row already showed that profile. A profile that matches its row is no longer published, and the rest are applied at most once every 300 ms, sorting the list once per batch, with the last batch applied as soon as the refresh finishes. A batch overtaken by a reset, a sign-out or another owner's load is dropped, and a contact screen applies a profile still waiting for its batch at once.
An avatar that could not load was fetched again each time its row appeared. A missing or oversize avatar is not fetched again until the image cache is cleared, and an avatar whose fetch failed for another reason is retried after 60 seconds. Sign-out and an identity change, which clear the image cache, forget every failure, and a fetch that a clear overtook or that was cancelled leaves nothing behind.
Removing a contact, or deleting them all with Delete Profile, left the running profile refresh looking them up, so it kept issuing reads for contacts that no longer existed. Each contact's lookup is now its own task: removing a contact cancels its lookup and drops anything it found, and deleting all contacts stops the refresh first, so lookups still waiting for a read slot never read.
@Jasonvdb

Jasonvdb commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

Following up on the slow Continue and Delete Profile you saw: bcb3a92 fixes most of it on iOS. Measured on a simulator with your 61-follow list:

Before Now
Delete Profile, after 5 Contacts visits 645 s 100 s
Continue on "Let your contacts pay you" 104–106 s 86 s
Contacts: profile lookups per visit 61 7
Contacts: list updates per refresh 54 0
Fetches of each avatar that fails 5 per run 1

Why Delete was slow. Every visit to Contacts announced a "saved contacts changed" event, even when no contact had been added or removed. Each announcement queued a walk over all 61 contacts, about 110 s each. Delete then waited behind every queued walk. Import also announced the event twice, which put a duplicate walk next to Continue. The event now fires only when contacts are added or removed.

Other changes in the same push:

  • Contacts reuses a profile found in the last 10 minutes.
  • Refreshed profiles are applied in batches, and unchanged ones are skipped.
  • An avatar that failed is remembered.
  • Removing a contact stops its pending lookup.

What's still slow: Delete still waits for at most the 1–2 walks already running when you tap it. Continue still waits for its own walk. Both walks are private-link code that #856 rewrites.

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA review

Re-reviewed the full PR at bcb3a921 after merging master changed the comparison base. This includes the full reassessment at afff078 and the complete later caching/batching delta, affected callers, tests and exact SDK contracts.

2 additional actionable findings from the full reassessment.

The previous queued-tag session finding is fixed; the current session/generation guards stop stale queued tasks, and all five isolated current-method regression cases pass.

  • [MEDIUM] Bind contact Edit Save to its session before waiting for the profile. Start editing a label-only contact while its profile lookup is delayed, change fields and tap Save without selecting a new avatar. Leave, sign out, adopt another identity with the same saved contact, then let the old lookup finish. At EditContactView.swift:149–150, Save waits before updateContact captures the current generation. The button task survives leaving, and late form fill preserves the old dirty fields. The SDK accepts the replacement identity's existing contact, so the old edit overwrites its label and local metadata. Capture the originating session/generation before this wait and reject stale work before persistence and UI effects. A missing replacement contact is correctly rejected; this does not claim deleted contacts are recreated.
  • [MEDIUM] Prevent an older missing-profile refresh from reopening setup during a save. Adopt a remembered Ring profile and start saving a profile edit/tag while its background refresh is delayed. If a definitive missing response arrives before publication finishes, PubkyProfileManager.swift:583–591 still accepts that read: the generation advances only when the save commits. It persists setup pending and clears the profile, then the successful save commits the edited profile without clearing pending. MainNav can prompt Create Profile despite the successful edit. A write in progress must invalidate the earlier setup decision, and successful save must leave setup state coherent.

Evidence basis: source analysis plus executed isolated Swift probes using the exact current Save/form/manager methods with deterministic dependency gates. The edit probe reproduces a replacement-owner save and override, with same-session and absent-contact controls; the exact base nil-avatar Save has no added hydration wait. The profile probe reproduces both in-flight orderings and checks both completed-commit controls. Native SDK/UI persistence was not exercised.

Validation: 13 existing limiter/operation-lock tests passed in an extracted Swift host module; other native test sources were inspected. Syntax parsing passed all changed Swift files and four journeys. Current native CI was still running at collection. Device testing: not performed in this review; previous device evidence remains attributed to its tested builds.

Suggested additional test cases

  • iOS: hold a contact profile lookup, edit and Save, leave/sign out, adopt another identity with the same saved contact, then release the read. Expect no previous-session label, note, link or tag save, override, toast or navigation. Repeat without that replacement contact.
  • iOS: remember a Ring profile with tags, remove its remote profile, adopt the cached row and hold the follow-up lookup. Remove a tag from full Profile while publication is delayed; let the missing lookup finish, then let publication succeed. Expect the edited full profile and no Create Profile prompt or persisted setup pending, including after relaunch.

The remaining Continue/private-link delay stays tracked separately in #861 and #856; it does not resolve these session/profile-state findings.

isSaving = true
defer { isSaving = false }

await contactsManager.resolvePendingContactProfile(publicKey: publicKey)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Bind contact Edit Save to the session before this wait

Save an edited label-only contact while its profile lookup is delayed, leave/sign out, then adopt another identity with the same saved contact before the old lookup finishes. This untracked button task waits here before updateContact captures the contact generation, so it can resume with the new identity's generation. ContactEditForm preserves the old dirty fields during its late fill, and the SDK accepts the new identity's existing contact; the old edit then overwrites its label and local metadata. Capture the originating session/generation before this wait and reject stale work before persistence and UI effects, including after avatar upload. Add a held-lookup/identity-switch regression asserting no old Save or override. Evidence: exact-method Swift host probes reproduce this case; the absent-contact control rejects it, and the base nil-avatar Save has no hydration wait. Native SDK/UI persistence was not exercised.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, confirmed and fixed in 7186200.

What changed:

  • A contact edit is now bound to its Pubky session and the Contacts reset generation when you tap Save, before it waits. A new ContactsManager.saveContactEdit handles the order. It waits for the contact's lookup, checks the binding, and only then builds the edit (late form fill and avatar upload). Then it saves through updateContact, which still checks before and after the save.
  • Stale work saves nothing, writes no override and changes no row. It shows no toast and doesn't navigate.
  • EditContactView takes pubkyProfile.currentSession at the tap, as ContactDetailView does for tags. The old public updateContact captured the generation late; it had no other callers, so it's gone.

Tests, in ContactsManagerTests.swift. Each fails without the fix; the original ordering reproduces your case exactly:

  • testContactEditSavedBeforeASignOutSavesNothingForTheNextIdentity, with and without the replacement contact.
  • testContactEditStopsOnceItsSessionEndsOrContactsResetWhileItWaits, covering a session end or reset during the lookup, during an avatar upload and during a failed upload.
  • testContactEditWaitsForTheContactsLookupAndSavesOverTheProfileItFinds.

Still open: a save that has already reached the SDK when the session changes can still write the label to the next identity's same-key contact. That window is short, and the override and row are already dropped. Closing it needs an identity check under the Paykit lock for the contact save, which Android does with expectedIdentity. I'm adding that next.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up: the remaining window is now closed, in 7b4adf0, 6bb0f2d and 6d29518.

Contact saves. The SDK contact save now checks, inside the same locked operation as the write, that the expected identity is still signed in. On a mismatch it writes nothing, and updateContact drops the refusal quietly. Edit Contact's avatar upload checks the identity the same way.

  • PaykitSdkIdentityCheckTests drives the real PaykitSdkService.
  • testSignOutQueuedBehindAContactSaveCannotLandBetweenItsCheckAndItsWrite shows why the check has to sit inside the write's own locked operation.

Your own profile. Edits are now bound to the session when you tap Save, the same way. Profile publication and the avatar upload both carry the expected identity. A stale edit shows no "saved" toast, doesn't navigate and publishes nothing.

Avatar upload errors. A profile or contact avatar upload without a live session still shows the SDK's own error, not the payment-request one.

Each new test fails without its fix. The full iOS suite passes: 1633 tests, 0 failures.

if PubkyPublicKeyFormat.matches(cachedProfileOwner, adoptedPublicKey) {
clearCachedProfileMetadata()
}
setProfileSetupPending(true)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Invalidate the older refresh before a profile write starts

Adopt a remembered Ring profile and start saving an edit/tag while its follow-up refresh is delayed. If a definitive missing result arrives while publication is still in flight, isStillCurrent() accepts it because the write generation advances only at commit. This handler clears the profile and persists setup pending; the later successful saveProfile commits the edited profile without clearing that flag. MainNavView can therefore prompt Create Profile despite the successful edit, and pending remains persisted. A newer write must invalidate this older setup decision before publication starts and leave coherent setup state when it succeeds. Add a held-refresh/held-publish regression checking the final profile, pending flag and route. Evidence: exact-method Swift probes reproduce both in-flight orderings; completed-write controls keep pending false. This is source/host evidence, not a device observation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, confirmed and fixed in 0444524.

What changed:

  • saveProfile now advances the profile write generation when the write starts, not when it commits, so an older refresh that finds the profile missing can't clear it or set setup pending while the save runs.
  • A successful save clears setup pending.
  • A save that a sign-out or another identity overtakes commits no profile and leaves setup state alone. That guard keeps the new pending clear from wiping the next identity's setup.
  • I checked every path that sets setup pending. The adoption and sign-up paths are already guarded by the session revision.

Tests, in PubkyProfileManagerTests.swift. Each fails without the fix:

  • testProfileSaveKeepsTheSavedProfileWhenAnOlderRefreshFindsItMissing is your scenario. It checks the edited tags, setup not pending in memory or defaults, and a relaunched manager agreeing.
  • testProfileSaveEndsAPendingProfileSetup.
  • testProfileSaveThatAnotherIdentityOvertakesLeavesThatIdentitysSetupAlone.

@ovitrif ovitrif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: 👍 Approve

Reaudit: diff 17 files.
No new findings; the rest is in the review.
The open pair synonymdev/bitkit-android#1399 has the same changes: failed avatar fetches remembered, contact profile refresh in 300 ms batches, profiles found in the last ten minutes not looked up again, freed read slots given to interactive reads first. Android master still serializes those reads.

QA:
Tests running: 0 of 8 passed.


Reviewed by grok-4.7-xhigh via gh-pr-review-loop skill

Save on a contact's edit screen first waits for the lookup of a profile
the row is still waiting for, and then may upload a new avatar. It only
bound itself to the Contacts reset generation once those waits were
over, and never to the Pubky session. If the user left, signed out and
adopted another identity that had saved the same contact before the
lookup finished, the old edit filled its form from the new identity's
row, saved its label through the new session, wrote back the local
override sign-out had cleared, and showed a toast and navigated.

ContactsManager.saveContactEdit now binds the edit to the Pubky session
and the reset generation when Save is tapped, using the same check as
queued tag changes. A stale edit stops after the lookup, after building
the edit (including the avatar upload) and around the save, without
saving, writing an override, changing a row or reporting an error, and
the screen shows no toast and does not navigate.
Adopting a remembered Ring row reuses its cached profile and refreshes
it in the background. A profile save only bumped the profile write
generation once its publication returned, so a refresh that found the
profile missing while the save was still publishing was accepted: it
cleared the profile and persisted profile setup as pending. The save
then committed the edited profile but left setup pending, so MainNav
could prompt Create Profile after a successful edit, also after a
relaunch.

A profile save now drops every profile read that started before it as
soon as its write starts, and a published profile clears pending setup.
A save that a sign-out or another identity overtakes no longer commits
its profile or touches setup state, so it cannot clear the next
identity's pending setup or show the earlier identity's profile.

@ovitrif ovitrif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: 👍 Approve

Reaudit: diff 5 files.
No new findings; the rest is in the review.
Pair PR synonymdev/bitkit-android#1399: equivalent. This change binds an iOS contact save to its session and drops a missing-profile refresh that a profile save supersedes.

QA:
Tests wait for CI.


Reviewed by grok-4.7-xhigh via gh-pr-review-loop skill

A contact's tag change or edit checks its session right before it saves,
but the save then waits for the Paykit operation lock. A sign-out and
another identity's sign-in could take the lock first, so the save wrote
the old identity's label to the next identity's contact with the same
key. The local override and row were dropped, but the SDK write landed.
Edit Contact's avatar upload had the same window.

The contact save now carries the identity that was signed in when the
change was queued or Save was tapped, and PaykitSdkService checks it
against the SDK's signed-in identity in the same locked operation as
the write. A mismatch writes nothing and throws identityChanged, which
ContactsManager drops quietly, with no error and no toast. Edit
Contact's avatar upload passes the same identity to the upload's
existing check under the lock.
Edit Profile uploaded a new avatar and then saved the profile without
tying either to the session Save was tapped in. A save that a sign-out
and another identity's sign-in overtook changed nothing locally, but the
screen still showed the saved toast and navigated back, the upload could
land for the next identity, and the publication could overwrite the next
identity's profile while it waited for the Paykit lock.

PubkyProfileManager.saveProfile now takes the avatar image, binds the
edit to the current session before uploading, and returns whether it
saved. The upload and the profile publication both carry the session's
identity, which PaykitSdkService checks under the operation lock, so
neither writes once another identity is signed in. A stale or refused
save returns false without an error, leaves the profile and setup state
alone, and Edit Profile then shows no toast and stays put.
Binding profile and contact avatar uploads to an identity routed them
through the subscription icon upload's check, which throws the payment
request error requestUnavailable both for another identity and for no
live session. Edit Profile then showed "The payment request is no
longer available." when an upload failed for the signed-in identity.

Profile and contact avatars now use their own upload, which checks only
the identity under the Paykit lock: another identity throws
identityChanged, which Edit Profile and Edit Contact drop quietly, and
any other failure, such as no live session, is the SDK's own error, as
before. The payment request upload is unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants