feat: support paykit receiver paths#620
Conversation
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
Greptile SummaryThis PR adds receiver-path support for Paykit contacts. The main changes are:
Confidence Score: 4/5The sign-out, profile deletion, and old private-state decode paths need fixes before merging.
Bitkit/Managers/PubkyProfileManager.swift; Bitkit/Services/PrivatePaykitService+Models.swift
|
| Filename | Overview |
|---|---|
| Bitkit/Managers/PubkyProfileManager.swift | Profile deletion and sign-out now depend on throwing private Paykit cleanup, which can block required account and local-state cleanup. |
| Bitkit/Services/PrivatePaykitService+Models.swift | Private Paykit contact state was reshaped for receiver paths without migration for old persisted invoices and publication flags. |
| Bitkit/Services/PrivatePaykitService+Contacts.swift | Private payment publication, cleanup, delivery retries, and saved contact state are now keyed by receiver path. |
| Bitkit/Services/PrivatePaykitAddressReservationStore.swift | Address reservations now distinguish wallet and server receivers while preserving wallet as the legacy key. |
| Bitkit/Services/PubkyService.swift | Paykit SDK calls now pass receiver paths, receiver marker capabilities, and receiver-aware contact records. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Save or refresh contact] --> B[Discover supported receiver paths]
B --> C[Store one contact record]
C --> D[Select linkable and publishable receivers]
D --> E[Publish private payment details per receiver path]
D --> F[Clean stale receiver-path lists]
G[Sharing disabled or sign-out] --> H[Remove private lists]
G --> I[Remove public endpoints and receiver marker]
H --> J[Clear receiver-scoped local state]
I --> K[Receiver no longer discoverable]
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A[Save or refresh contact] --> B[Discover supported receiver paths]
B --> C[Store one contact record]
C --> D[Select linkable and publishable receivers]
D --> E[Publish private payment details per receiver path]
D --> F[Clean stale receiver-path lists]
G[Sharing disabled or sign-out] --> H[Remove private lists]
G --> I[Remove public endpoints and receiver marker]
H --> J[Clear receiver-scoped local state]
I --> K[Receiver no longer discoverable]
Reviews (1): Last reviewed commit: "fix: harden paykit receiver refresh" | Re-trigger Greptile
|
|
||
| func deleteProfile() async throws { | ||
| await Self.removePrivatePaykitEndpointsBestEffort(context: "PubkyProfileManager.deleteProfile") | ||
| try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.deleteProfile") |
There was a problem hiding this comment.
Private Cleanup Blocks Deletion
When private Paykit endpoint removal fails during profile deletion, this new throwing call exits before deletePaykitProfile() and signOut(cleanPrivatePaykitEndpoints: false) run. A transient cleanup error can leave the user signed in with the profile, public endpoints, and receiver marker still active instead of deleting the profile and recording cleanup for retry.
| try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.deleteProfile") | |
| await Self.removePrivatePaykitEndpointsBestEffort(context: "PubkyProfileManager.deleteProfile") |
There was a problem hiding this comment.
Keeping this blocking is intentional. If deletion continues after private cleanup fails, clearLocalState() removes the SDK state and credentials needed to retry clearing private details that may already be shared. Normal deletion keeps the profile in place so the user can retry; the forced wallet wipe path remains best effort.
There was a problem hiding this comment.
That's a solid rationale — preserving credentials for retry is more important than a clean deletion path here. Withdrawing the concern.
Shall I remember this as a rule: "prefer throwing cleanup over best-effort when failure would wipe the credentials needed to retry that same cleanup"?
Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.
| try await Task.detached { | ||
| if cleanPrivatePaykitEndpoints { | ||
| await Self.removePrivatePaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut") | ||
| try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.signOut") |
There was a problem hiding this comment.
Private Cleanup Blocks Sign-Out
When private endpoint cleanup fails here, the detached sign-out task throws before public Paykit cleanup, server sign-out, local key removal, and clearAuthenticatedState() run. A network or homeserver error in private cleanup can keep the user locally authenticated with stale receiver state, even though cleanup pending state was meant to allow retry.
| try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.signOut") | |
| await Self.removePrivatePaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut") |
There was a problem hiding this comment.
This is intentional for the same reason as deletion. Normal sign-out must keep the session and private SDK state available until the receiver-scoped clears are delivered. Otherwise local sign-out would erase the retry state and could strand shared private details. Forced wallet wipe remains the best-effort escape path.
There was a problem hiding this comment.
Understood — the throw acts as a gate to keep session and retry state intact until all receiver-scoped clears succeed, which is consistent with the deletion path. That makes sense. I'll drop this comment.
| var localInvoicesByReceiverPath: [String: StoredInvoice] = [:] | ||
| var receivedInvoicePaymentHashes: [String] = [] | ||
| var hasPublishedPrivatePaymentList = false | ||
|
|
||
| init() {} | ||
| var publishedPrivatePaymentReceiverPaths: [String] = [] |
There was a problem hiding this comment.
The persisted ContactState schema changed from localInvoice and hasPublishedPrivatePaymentList to new receiver-path fields without a decode migration. Any installed app with existing private Paykit state will decode those old keys as empty values, so cleanup can forget that a payment list was published and the wallet can rotate away from an invoice that was still published remotely.
There was a problem hiding this comment.
This state has not shipped, so there is no persisted production schema to migrate. We are intentionally treating pre-receiver-path development state as disposable and keeping the new model migration-free.
This PR:
bitkit/walletreceiver.bitkit/walletandbitkit/serverreceivers while keeping one visible contact per Pubky identity.bitkit/walletexplicitly.Description
Paykit now supports multiple apps under one Pubky identity. This change adopts that model without changing Bitkit's contact UI: a person remains one contact, while their saved SDK contact record can track multiple receiver paths.
Private links are maintained for supported receivers that advertise private capabilities. Receiving details are published independently per receiver and only when that receiver can send payments, so a
bitkit/serverreceiver can be discovered and linked without receiving wallet invoices or addresses. Public contact payments and fallback remain scoped tobitkit/wallet.Receiver-record failures are isolated per contact, successful delivery reports are still persisted, and rescanning an existing contact shows the validation result immediately while its receiver metadata refreshes.
No migration is included because the receiver-path Paykit state has not shipped and existing development state can be discarded.
Companion Android PR: synonymdev/bitkit-android#1066
Linked Issues/Tasks
Screenshot / Video
N/A
QA Notes
Manual Tests
bitkit/walletreceiver is available for payment.bitkit/wallet; public fallback still works when private payment is unavailable.bitkit/serverreceiver is published: the contact remains one row and the new receiver is saved in the background.bitkit/servermarker that cannot send payments: Bitkit may maintain its private link but does not publish wallet invoices or addresses to that receiver.Automated Checks
PrivatePaykitServiceTests.swiftcovers receiver-scoped publication, cleanup, reservations, and wallet/server capability behavior.ContactsManagerTests.swiftcovers receiver discovery refresh for an already-saved contact.git diff --checkpassed on the changed files.ContactsManagerTestsclass run remains blocked locally by the existing app-group/keychain entitlement failure intestDeleteAllContactsThrowsWithoutActiveSession; the changed receiver-refresh test passes independently.