Skip to content

feat: support paykit receiver paths#620

Open
ben-kaufman wants to merge 3 commits into
masterfrom
codex/paykit-rc33-receivers
Open

feat: support paykit receiver paths#620
ben-kaufman wants to merge 3 commits into
masterfrom
codex/paykit-rc33-receivers

Conversation

@ben-kaufman

@ben-kaufman ben-kaufman commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

This PR:

  1. Updates Paykit to v0.1.0-rc33 and configures the mobile app as the bitkit/wallet receiver.
  2. Discovers and saves supported bitkit/wallet and bitkit/server receivers while keeping one visible contact per Pubky identity.
  3. Scopes private links, payment details, reservations, retries, and cleanup to the exact receiver path.
  4. Publishes the wallet receiver marker with the app's current public/private capabilities and removes it when Paykit sharing is disabled.
  5. Refreshes receiver discovery when an existing contact is scanned or pasted, while normal send-to-contact continues to target bitkit/wallet explicitly.

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/server receiver can be discovered and linked without receiving wallet invoices or addresses. Public contact payments and fallback remain scoped to bitkit/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

  • 1. Contacts → add a Paykit-enabled Bitkit user: one contact is saved and its bitkit/wallet receiver is available for payment.
  • 2. Send → Contact → select the saved contact: private payment resolves through bitkit/wallet; public fallback still works when private payment is unavailable.
  • 3. Existing contact → scan or paste the same Pubky key after a bitkit/server receiver is published: the contact remains one row and the new receiver is saved in the background.
  • 4. Contact with a bitkit/server marker that cannot send payments: Bitkit may maintain its private link but does not publish wallet invoices or addresses to that receiver.
  • 5. Settings → Payment Preferences → disable contact payment sharing: receiver-scoped payment details and the local receiver marker are cleaned up.

Automated Checks

  • PrivatePaykitServiceTests.swift covers receiver-scoped publication, cleanup, reservations, and wallet/server capability behavior.
  • ContactsManagerTests.swift covers receiver discovery refresh for an already-saved contact.
  • Local iOS simulator build passed; 25 focused public/private Paykit tests and the touched contact receiver-refresh test passed.
  • SwiftFormat lint and git diff --check passed on the changed files.
  • A full ContactsManagerTests class run remains blocked locally by the existing app-group/keychain entitlement failure in testDeleteAllContactsThrowsWithoutActiveSession; the changed receiver-refresh test passes independently.

@ben-kaufman ben-kaufman marked this pull request as ready for review July 10, 2026 10:55
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Repo admins can enable using credits for code reviews in their settings.

@greptile-apps

greptile-apps Bot commented Jul 10, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds receiver-path support for Paykit contacts. The main changes are:

  • Paykit is upgraded and configured for the bitkit/wallet receiver.
  • Contacts can store supported bitkit/wallet and bitkit/server receiver paths.
  • Private links, reservations, invoices, retries, and cleanup are scoped per receiver path.
  • The wallet receiver marker is published or removed from sharing flows.
  • Scanner, paste, and contact flows refresh receiver metadata for existing contacts.

Confidence Score: 4/5

The sign-out, profile deletion, and old private-state decode paths need fixes before merging.

  • Private cleanup failures can stop profile deletion and sign-out before mandatory cleanup runs.
  • Existing private Paykit state can decode with invoices and publication flags missing after the schema change.
  • The receiver-path publication flow is otherwise scoped consistently in the changed code reviewed.

Bitkit/Managers/PubkyProfileManager.swift; Bitkit/Services/PrivatePaykitService+Models.swift

Security Review

Sign-out and profile deletion can remain incomplete when private Paykit cleanup fails, leaving receiver/public state active until the cleanup path is fixed.

Important Files Changed

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]
Loading
%%{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]
Loading

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")

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 security 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.

Suggested change
try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.deleteProfile")
await Self.removePrivatePaykitEndpointsBestEffort(context: "PubkyProfileManager.deleteProfile")

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.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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")

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 security 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.

Suggested change
try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.signOut")
await Self.removePrivatePaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut")

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.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment on lines +12 to +14
var localInvoicesByReceiverPath: [String: StoredInvoice] = [:]
var receivedInvoicePaymentHashes: [String] = []
var hasPublishedPrivatePaymentList = false

init() {}
var publishedPrivatePaymentReceiverPaths: [String] = []

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 Old Private State Is Dropped

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.

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.

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.

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.

1 participant