Skip to content

refactor: integrate paykit sdk#606

Open
ben-kaufman wants to merge 4 commits into
masterfrom
codex/paykit-sdk-native-integration
Open

refactor: integrate paykit sdk#606
ben-kaufman wants to merge 4 commits into
masterfrom
codex/paykit-sdk-native-integration

Conversation

@ben-kaufman

Copy link
Copy Markdown
Contributor

Description

This PR:

  1. Replaces Bitkit's custom Paykit private/public payment plumbing with the native Paykit SDK.
  2. Pins Paykit to the published v0.1.0-rc21 Swift package release instead of local bindings.
  3. Moves Pubky profile, contact, public endpoint, private endpoint, and SDK backup state handling through SDK APIs while keeping wallet execution and UI mapping in Bitkit.
  4. Keeps public fallback, Ring/public-only capability handling, contact attribution, and receiving-detail rotation behavior covered by the app layer.

Linked Issues/Tasks

N/A

Screenshot / Video

Draft PR; proof videos can be attached after review of the SDK integration diff.

QA Notes

Manual Tests

  • 1. Profile → create/edit profile → add contact by Pubky key: contact profile resolves and remains visible after app restart.
  • 2. Send → Contact → select contact → complete payment: private Paykit is attempted first and public endpoints remain available as fallback when private capability is unavailable.
  • 3. Backup/restore → restore a wallet with Pubky state → open contacts/pay contact: SDK state restores and contact payment preparation works.
  • 4. Settings → Payment Preference → toggle public/private contact payments: endpoint publication state follows the selected preferences.

Automated Checks

  • xcodebuild ... build passed for the E2E simulator build against Paykit v0.1.0-rc21.
  • git diff --check origin/master...HEAD passed.
  • Focused iOS test execution is currently blocked by the existing StateLockerTests compile issue before Paykit tests run; the app build is clean.

@ben-kaufman ben-kaufman marked this pull request as ready for review June 24, 2026 11:42
@greptile-apps

greptile-apps Bot commented Jun 24, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces Bitkit's hand-rolled Paykit payment plumbing (noise-encrypted links, manual homeserver reads/writes, bespoke snapshot serialisation) with the native Paykit SDK (v0.1.0-rc21), delegating contact management, endpoint publication, private-payment list sync, backup export/import, and auth flows to SDK APIs while keeping wallet execution, invoice rotation, and UI mapping in the app.

  • PubkyService / PaykitSdkService: All PaykitFFI free-function calls are replaced by a new PaykitSdkService actor backed by atomic keychain blob storage (PaykitSdkStateBlobStore with revision-conflict detection), a session provider, and a custom PaykitSdkOperationLock that prevents interleaving across async suspension points.
  • Contacts & profiles: ContactsManager now reads/writes contacts via SDK contactRecords / saveContact / removeContact; richer per-contact metadata (bio, image, links, tags) is stored as local contactProfileOverrides backed up in MetadataBackupV1 rather than on the homeserver.
  • Backup/restore: WalletBackupV1 replaces the per-contact link-snapshot map with a single opaque paykitSdkBackupState string exported by the SDK; restore is deferred until after all backup categories are loaded \u2014 but the non-nil case is missing a local error guard (see inline comment).

Confidence Score: 3/5

Safe to land after fixing the BackupService error handling gap; the rest of the SDK integration is well-structured.

The restore path is missing a local error guard for the non-nil SDK backup state case. A corrupted or version-mismatched blob causes restoreBackup to throw, which propagates to the outer catch and silently skips the blocktank restore and PIN-reset steps. Because this touches the wallet restore flow — the path users depend on after loss or device migration — a failure here can leave the wallet in a partially-restored state. The rest of the SDK integration (actor isolation, atomic keychain writes with revision checking, auth cancellation via requestID, public payment fallback guarded against CancellationError) is well-structured.

Bitkit/Services/BackupService.swift lines 249-261 (missing local do/catch for non-nil SDK backup restore). Bitkit/Managers/ContactsManager.swift updateContact (richer profile fields now depend on MetadataBackupV1 surviving restore).

Important Files Changed

Filename Overview
Bitkit/Services/BackupService.swift Restore flow deferred Paykit SDK restore after wallet category; non-nil backup string throws are not caught locally, aborting blocktank and PIN-reset steps on a bad SDK blob.
Bitkit/Services/PubkyService.swift Major refactor: replaced PaykitFFI free-function calls with the new PaykitSdkService actor. Introduces PaykitSdkStateBlobStore (atomic keychain persistence with revision checking), PaykitSdkSessionProvider, and PaykitSdkOperationLock. Auth-cancel race is handled correctly via requestID comparison inside completeAuth.
Bitkit/Services/PrivatePaykitService+Payments.swift Private/public payment resolution simplified: delegates to SDK prepareAndResolveContactPayment; cached endpoints and stale-lightning-hash eviction logic retained at app layer. Public fallback on SDK error is correctly guarded against CancellationError.
Bitkit/Services/PrivatePaykitService+Backup.swift Backup now exports a single SDK blob string and restores via PaykitSdkService; the old per-contact link snapshot serialisation is removed.
Bitkit/Managers/ContactsManager.swift Contact CRUD now goes through SDK contactRecords/saveContact/removeContact APIs. updateContact stores bio/image/links/tags as a local contactProfileOverride rather than on the homeserver; these are only recoverable via the MetadataBackupV1 category.
Bitkit/Services/PrivatePaykitService+Endpoints.swift Endpoint publication simplified: buildLocalEndpoints now @mainactor to allow synchronous walletHasUsableChannels access; syncLocalEndpointPublicationLocked batches reservation updates for all contacts in one SDK call.
Bitkit/Services/PrivatePaykitService+Contacts.swift Contact preparation delegates to syncLocalEndpointPublication; profile-recovery re-establishment logic removed (now handled by SDK).
Bitkit/Models/BackupPayloads.swift WalletBackupV1 replaces per-contact link snapshots with a single optional paykitSdkBackupState string; MetadataBackupV1 gains pubkyContactProfileOverrides for local contact customisations.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant UI
    participant PrivatePaykitService
    participant PaykitSdkService
    participant PaykitSdk
    participant Keychain

    Note over UI,Keychain: Payment preparation flow
    UI->>PrivatePaykitService: beginSavedContactPayment(publicKey)
    PrivatePaykitService->>PaykitSdkService: identityStatus()
    PaykitSdkService->>PaykitSdk: identityStatus()
    PaykitSdk-->>PaykitSdkService: IdentityStatus (privateLinkCapable)
    PaykitSdkService-->>PrivatePaykitService: status
    PrivatePaykitService->>PaykitSdkService: syncPrivatePaymentListsWithReservations(updates)
    PaykitSdkService->>Keychain: saveStateBlobAtomically (revision-checked)
    PaykitSdkService-->>PrivatePaykitService: PrivatePaymentListDeliveryReport
    PrivatePaykitService->>PaykitSdkService: prepareAndResolveContactPayment(counterparty)
    PaykitSdkService->>PaykitSdk: prepareAndResolveContactPayment(...)
    PaykitSdk-->>PaykitSdkService: PreparedContactPayment
    PaykitSdkService-->>PrivatePaykitService: resolution
    alt private endpoints available
        PrivatePaykitService-->>UI: .opened(paymentRequest)
    else public endpoints available
        PrivatePaykitService-->>UI: .opened(paymentRequest via public)
    else no endpoints
        PrivatePaykitService-->>UI: .noEndpoint
    end

    Note over UI,Keychain: Backup restore flow
    UI->>BackupService: restore()
    BackupService->>BackupService: performRestore(.wallet) sets pendingPaykitSdkBackupState
    BackupService->>BackupService: performRestore(.metadata) restoreContactProfileOverrides
    BackupService->>PrivatePaykitService: restoreBackup(pendingPaykitSdkBackupState)
    PrivatePaykitService->>PaykitSdkService: restoreBackupState(blob)
    PaykitSdkService->>Keychain: saveStateBlobAtomically
    PaykitSdkService-->>PrivatePaykitService: ok / throws
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"}}}%%
sequenceDiagram
    participant UI
    participant PrivatePaykitService
    participant PaykitSdkService
    participant PaykitSdk
    participant Keychain

    Note over UI,Keychain: Payment preparation flow
    UI->>PrivatePaykitService: beginSavedContactPayment(publicKey)
    PrivatePaykitService->>PaykitSdkService: identityStatus()
    PaykitSdkService->>PaykitSdk: identityStatus()
    PaykitSdk-->>PaykitSdkService: IdentityStatus (privateLinkCapable)
    PaykitSdkService-->>PrivatePaykitService: status
    PrivatePaykitService->>PaykitSdkService: syncPrivatePaymentListsWithReservations(updates)
    PaykitSdkService->>Keychain: saveStateBlobAtomically (revision-checked)
    PaykitSdkService-->>PrivatePaykitService: PrivatePaymentListDeliveryReport
    PrivatePaykitService->>PaykitSdkService: prepareAndResolveContactPayment(counterparty)
    PaykitSdkService->>PaykitSdk: prepareAndResolveContactPayment(...)
    PaykitSdk-->>PaykitSdkService: PreparedContactPayment
    PaykitSdkService-->>PrivatePaykitService: resolution
    alt private endpoints available
        PrivatePaykitService-->>UI: .opened(paymentRequest)
    else public endpoints available
        PrivatePaykitService-->>UI: .opened(paymentRequest via public)
    else no endpoints
        PrivatePaykitService-->>UI: .noEndpoint
    end

    Note over UI,Keychain: Backup restore flow
    UI->>BackupService: restore()
    BackupService->>BackupService: performRestore(.wallet) sets pendingPaykitSdkBackupState
    BackupService->>BackupService: performRestore(.metadata) restoreContactProfileOverrides
    BackupService->>PrivatePaykitService: restoreBackup(pendingPaykitSdkBackupState)
    PrivatePaykitService->>PaykitSdkService: restoreBackupState(blob)
    PaykitSdkService->>Keychain: saveStateBlobAtomically
    PaykitSdkService-->>PrivatePaykitService: ok / throws
Loading

Comments Outside Diff (1)

  1. Bitkit/Managers/ContactsManager.swift, line 355-361 (link)

    P2 Richer contact profile data (bio, image, links, tags) is now only stored locally

    Previously, updateContact serialised the full PubkyProfileData (name, bio, image URL, links, tags) to the homeserver. Now it calls PubkyService.saveContact with only label: name and stores the remaining fields in a local contactProfileOverride. The override is backed up via MetadataBackupV1.pubkyContactProfileOverrides, but if the metadata backup is absent or corrupted while the wallet backup is intact, all bio/image/links/tag customisations are silently lost on restore. This is a silent data-availability regression compared to the old homeserver-backed approach.

Reviews (1): Last reviewed commit: "chore: polish paykit cleanup" | Re-trigger Greptile

Comment on lines +249 to +261
if didRestoreWalletBackup {
if pendingPaykitSdkBackupState != nil {
try await PrivatePaykitService.shared.restoreBackup(pendingPaykitSdkBackupState)
await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk()
} else {
do {
try await PrivatePaykitService.shared.restoreBackup(nil)
await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk()
} catch {
Logger.warn("Failed to clear missing Paykit SDK backup state: \(error)", context: "BackupService")
}
}
}

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 Unprotected throw aborts the full restore on a bad SDK backup string

When pendingPaykitSdkBackupState is non-nil, a throw from PrivatePaykitService.shared.restoreBackup (e.g. a corrupted or version-incompatible SDK backup string) propagates straight to the outer catch, which logs a warning and returns — skipping the blocktank restore and the PIN-reset that follows. In contrast, the nil branch wraps its call in its own do/catch and only warns, letting the restore continue. The old restoreBackup was non-throwing so this edge case didn't exist before. Wrap the non-nil call identically to the nil case so that a bad SDK blob degrades gracefully rather than aborting the full wallet restore.

Suggested change
if didRestoreWalletBackup {
if pendingPaykitSdkBackupState != nil {
try await PrivatePaykitService.shared.restoreBackup(pendingPaykitSdkBackupState)
await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk()
} else {
do {
try await PrivatePaykitService.shared.restoreBackup(nil)
await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk()
} catch {
Logger.warn("Failed to clear missing Paykit SDK backup state: \(error)", context: "BackupService")
}
}
}
if didRestoreWalletBackup {
do {
try await PrivatePaykitService.shared.restoreBackup(pendingPaykitSdkBackupState)
await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk()
} catch {
Logger.warn("Failed to restore Paykit SDK backup state: \(error)", context: "BackupService")
}
}

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 703b592. The SDK backup restore is now wrapped locally, so a bad Paykit SDK backup only logs a warning and the rest of the wallet restore keeps going.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fbaa310479

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 831 to +832
let secretKeyHex = try deriveLocalSecretKeyFromWalletSeed(loadKeychainString: loadKeychainString)
try persistKeychainString(.pubkySecretKey, secretKeyHex)
try? deleteKeychainValue(.paykitSession)
_ = try await signInWithSecretKey(secretKeyHex)

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 Badge Preserve Pubky secret before network sign-in

When restoring a .localSeed Pubky backup while offline or while the homeserver/sign-in endpoint is unavailable, this call throws before anything writes the derived secretKeyHex back to Keychain. restoreSessionBackupState has just cleared session access, and BackupService only logs the restore error, so the restored wallet is left without .pubkySecretKey or .paykitSession and cannot retry Pubky restoration on the next launch. Persist the derived local secret before attempting the network sign-in, or otherwise retain it for retry.

Useful? React with 👍 / 👎.

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 703b592. We now persist the derived local secret before trying the network sign-in, so a failed sign-in can be retried on the next launch.

Comment on lines +696 to +698
if cleanPrivatePaykitEndpoints {
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.

P2 Badge Continue sign-out after private cleanup failure

If the app is offline or the SDK cannot clear a private payment list, removePrivatePaykitEndpoints marks cleanup pending and throws; because this try runs before PubkyService.signOut() and clearLocalState(), the Profile sign-out flow aborts and leaves the user signed in even though cleanup can be retried later. This affects users with private contact payments enabled during transient cleanup failures, so local sign-out should continue after recording the pending cleanup.

Useful? React with 👍 / 👎.

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.

I think we should keep this blocking for normal sign-out. If private cleanup fails and we clear the Pubky session anyway, we lose the credentials needed to retry removing those private lists. The pending flag only helps while the session is still available. For wipe we still have the best-effort emergency path, but for normal sign-out/delete I would rather fail visibly than leave private endpoints published with no retry path.

@ben-kaufman

Copy link
Copy Markdown
Contributor Author

For the outside-diff contact override note: this is intentional with the SDK path. Edited contact details are app-local now and backed up in metadata. We do not want to publish bio/image/links/tags back to the homeserver. If metadata backup is missing or corrupt, those local customizations are lost like other metadata, but wallet backup should not own that app-local contact data.

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