refactor: integrate paykit sdk - #1040

Merged
jvsena42 merged 24 commits into
masterfrom
codex/paykit-sdk-native-integration
Jul 8, 2026
Merged

refactor: integrate paykit sdk#1040
jvsena42 merged 24 commits into
masterfrom
codex/paykit-sdk-native-integration

Conversation

@ben-kaufman

@ben-kaufmanben-kaufman commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

This PR:

  1. Replaces Bitkit's custom Paykit private/public payment plumbing with the native Paykit SDK.
  2. Moves Pubky profile, contact, public endpoint, private endpoint, and SDK backup state handling through SDK APIs.
  3. Keeps Bitkit responsible for wallet execution, payment-request mapping, contact attribution, endpoint rotation, and public fallback behavior.
  4. Pins Paykit to the published com.synonym:paykit-android:0.1.0-rc23 artifact.
  5. Adds hardening for auth approval capabilities, canceled Ring auth cleanup, profile label fallback, pending private drain retries, and best-effort profile delete/sign-out cleanup.

Description

  • Adds a Paykit SDK service wrapper for session bootstrap, Ring auth, profile/avatar publishing, contact records, public endpoint sync, private payment list sync, and SDK backup state import/export.
  • Refactors public and private Paykit repositories to resolve and publish payment endpoints through SDK APIs while preserving Bitkit's existing endpoint preference order and local payability checks.
  • Moves private contact link and recovery state into the SDK backup string, while keeping Bitkit-owned address reservations and payment attribution in app storage.
  • Updates Pubky profile/contact loading, profile edits, sign-out/delete cleanup, backup/restore, and wallet wipe flows for the SDK-backed state model.

Preview

N/A - SDK integration; no UI layout change.

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.
  • 5. Profile -> Sign Out / Delete Profile / Disconnect Profile: remote endpoint cleanup runs first, then local Pubky and SDK state clear on success.

Automated Checks

  • ./gradlew compileDevDebugKotlin passed.
  • ./gradlew testDevDebugUnitTest passed.
  • ./gradlew testDevDebugUnitTest --tests to.bitkit.repositories.PrivatePaykitRepoTest passed.
  • ./gradlew detekt passed.
  • git diff --check passed.

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

greptile-appsBot commented Jun 24, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces Bitkit's hand-rolled Paykit plumbing with the published com.synonym:paykit-android:0.1.0-rc21 SDK, removing ~2,200 lines of custom link/handshake/recovery state machine code and delegating session, profile, contact, private-payment-list, and backup-state management to native SDK APIs. Wallet execution logic, public-endpoint fallback, Ring/public-only handling, contact attribution, and receiving-detail rotation remain in Bitkit.

  • PaykitSdkService (713 lines, new): wraps PaykitSdk behind operationMutex, implements SdkStateBlobStore (CAS-style revision check against the keychain) and SdkPubkySessionProvider, exposes backup-state versioning via withStateRevisionTracking.
  • PrivatePaykitRepo / PubkyRepo: substantially slimmed by delegating link/handshake work to the SDK; contact profile overrides and paykitSdkBackupState replace the previous PrivatePaykitContactLinkBackupV1 map in wallet backups.
  • Backup migration: old privatePaykitContactLinks data is silently discarded when restoring pre-SDK backups; existing contact-link sessions are not migrated to the new SDK state format.

Confidence Score: 4/5

The core payment flow and session lifecycle look structurally sound; the main risks are edge cases in the new blocking-inside-synchronized SDK state store and empty contact names when the SDK returns a profile with no display data.

The architectural shift is large but well-scoped: the SDK takes over state management that was previously hand-coded, and the delegation boundary is clear. The new PaykitSdkStateBlobStore uses runBlocking(ioDispatcher) inside a synchronized block — not a deadlock under normal load but a thread-starvation risk under sustained IO pressure. PaykitSdkSessionProvider.clearSessionAccess() uses a bare runBlocking {} without a dispatcher, which could misbehave if called from an unusual thread context. The backup restore path for legacy (pre-SDK) backups silently swallows SDK state-clearing errors. The contact-name-empty edge case is a UI regression when the SDK's profile record lacks both displayName and decodable extraJson. None of these are showstoppers, but the blocking-coroutine nesting deserves attention before shipping to broad audiences.

PaykitSdkService.kt (the PaykitSdkStateBlobStore and PaykitSdkSessionProvider inner classes), BackupRepo.kt (legacy restore path around line 619), and PubkyRepo.kt (contactProfile method).

Important Files Changed

FilenameOverview
app/src/main/java/to/bitkit/services/PaykitSdkService.ktNew singleton service wrapping the Paykit SDK; mixes runBlocking inside a synchronized block (saveStateBlobAtomically) and has a bare runBlocking in PaykitSdkSessionProvider.clearSessionAccess().
app/src/main/java/to/bitkit/data/keychain/Keychain.ktAdds a new synchronous upsert(ByteArray) method using runBlocking(this.coroutineContext); consistent with the existing snapshot pattern but called from a synchronized block, risking thread starvation under IO saturation.
app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.ktSubstantially trimmed by delegating link/handshake/recovery state to the SDK; backup snapshot now delegates to PaykitSdkService.exportBackupState(); logic looks correct.
app/src/main/java/to/bitkit/repositories/PubkyRepo.ktDelegates session/profile/contact operations to PaykitSdkService; introduces contactProfileOverrides in PubkyStore and snapshotContactProfileOverrides/restoreContactProfileOverrides for backup; contact name may be empty when paykitProfile has no displayName and no extraJson.
app/src/main/java/to/bitkit/repositories/BackupRepo.ktBackup listeners refactored to observeBackupChanges helper; wallet restore silently swallows SDK state-clearing errors for legacy backups (null paykitSdkBackupState).
app/src/main/java/to/bitkit/services/PubkyService.ktThin wrapper now fully delegates to PaykitSdkService; straightforward and correct.
gradle/libs.versions.tomlBumps paykit-android from rc8 to rc21; no other dependency changes.
app/src/main/java/to/bitkit/models/BackupPayloads.ktReplaces PrivatePaykitContactLinkBackupV1 map with a single paykitSdkBackupState string and adds pubkyContactProfileOverrides; old backup fields removed with no migration path for existing contact-link data.
app/src/main/java/to/bitkit/models/PubkyProfile.ktAdapts to SDK PubkyProfile/PaykitProfile types; fromPaykitProfile may produce an empty contact name if displayName and extraJson are both absent.
app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.ktWipe sequence unchanged in substance; closeAndClear() now delegates SDK state clearing, then keychain.wipe() removes all persisted state.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant App as App/UI
participant PPR as PrivatePaykitRepo
participant SDK as PaykitSdkService
participant PaykitSdk as PaykitSdk (native)
participant Keychain as Keychain
participant BR as BackupRepo
App->>PPR: prepareSavedContacts(publicKeys)
PPR->>SDK: ensureLinkWithPeer(counterparty)
SDK->>PaykitSdk: ensureLinkWithPeer()
PaykitSdk->>Keychain: saveStateBlobAtomically() [synchronized + runBlocking]
SDK->>BR: backupStateVersion++ (via withStateRevisionTracking)
PPR->>SDK: syncPrivatePaymentListsWithReservations(updates)
SDK->>PaykitSdk: syncPrivatePaymentListsWithReservationsAndProcessOutbound()
PaykitSdk->>Keychain: saveStateBlobAtomically()
SDK->>BR: backupStateVersion++
App->>PPR: beginSavedContactPayment(publicKey)
PPR->>SDK: prepareAndResolveContactPayment(counterparty)
SDK->>PaykitSdk: prepareAndResolveContactPayment()
PaykitSdk-->>SDK: ContactPaymentResolution
SDK-->>PPR: PaykitContactPaymentResolution
PPR-->>App: PublicPaykitPaymentResult
BR->>PPR: backupSnapshot()
PPR->>SDK: exportBackupState()
SDK->>PaykitSdk: exportBackupString()
PaykitSdk-->>SDK: String (opaque blob)
SDK-->>BR: paykitSdkBackupState
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 App as App/UI
participant PPR as PrivatePaykitRepo
participant SDK as PaykitSdkService
participant PaykitSdk as PaykitSdk (native)
participant Keychain as Keychain
participant BR as BackupRepo
App->>PPR: prepareSavedContacts(publicKeys)
PPR->>SDK: ensureLinkWithPeer(counterparty)
SDK->>PaykitSdk: ensureLinkWithPeer()
PaykitSdk->>Keychain: saveStateBlobAtomically() [synchronized + runBlocking]
SDK->>BR: backupStateVersion++ (via withStateRevisionTracking)
PPR->>SDK: syncPrivatePaymentListsWithReservations(updates)
SDK->>PaykitSdk: syncPrivatePaymentListsWithReservationsAndProcessOutbound()
PaykitSdk->>Keychain: saveStateBlobAtomically()
SDK->>BR: backupStateVersion++
App->>PPR: beginSavedContactPayment(publicKey)
PPR->>SDK: prepareAndResolveContactPayment(counterparty)
SDK->>PaykitSdk: prepareAndResolveContactPayment()
PaykitSdk-->>SDK: ContactPaymentResolution
SDK-->>PPR: PaykitContactPaymentResolution
PPR-->>App: PublicPaykitPaymentResult
BR->>PPR: backupSnapshot()
PPR->>SDK: exportBackupState()
SDK->>PaykitSdk: exportBackupString()
PaykitSdk-->>SDK: String (opaque blob)
SDK-->>BR: paykitSdkBackupState
Loading

Comments Outside Diff (1)

  1. app/src/main/java/to/bitkit/repositories/BackupRepo.kt, line 619-628 (link)

    P2SDK state-clear failure silently ignored during legacy backup restore

    When paykitSdkBackupState is null (restoring a backup created before this PR), privateRepo.restoreBackup(null) is called and any failure is only logged via onFailure { Logger.warn(...) } — execution continues regardless. Inside restoreBackup(null), paykitSdkService.clearState() deletes the PAYKIT_SDK_STATE keychain entry. If this deletion fails (e.g., keystore error), the stale SDK state persists while the rest of the wallet is restored from the new backup, leaving contact-link and session state out of sync with the freshly restored wallet. The successful path (paykitSdkBackupState != null) uses .getOrThrow() — the legacy path should follow the same convention or at least propagate the failure to surface the inconsistency.

Reviews (1): Last reviewed commit: "fix: preserve paykit cancellation" | Re-trigger Greptile

Comment threadapp/src/main/java/to/bitkit/services/PaykitSdkService.kt
Comment threadapp/src/main/java/to/bitkit/services/PaykitSdkService.kt
Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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:8202a59774

ℹ️ 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 threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt
Comment threadapp/src/main/java/to/bitkit/services/PaykitSdkService.kt Outdated
Comment threadapp/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt Outdated
@ben-kaufman

Copy link
Copy Markdown
ContributorAuthor

For the legacy backup migration note: this is intentional for this PR. The old private Paykit link backup format never shipped, so there is no production data to migrate. Treating it as if it never existed keeps the restore path simpler.

@ovitrifovitrif 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.

Left one inline comment.

Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@piotr-iohk

Copy link
Copy Markdown
Collaborator

That is not necessarily due to this change, because I saw it on other PR also - however e2e tests here failed partially because of this. The failure is intermittent and most of the time tests pass after re-runs.

To reproduce:

  • create a profile.
  • delete profile
  • recreate profile

Result after hitting "Continue" on the following screen:
Screenshot 2026-06-25 at 14 03 04

Attaching logs from e2e run where this happened:
bitkit_2026-06-24_17-37-36.log
logcat.txt

@ovitrifovitrif added this to the 2.5.0 milestone Jun 25, 2026
@ben-kaufmanChatGPT Codex Connector

Copy link
Copy Markdown
ContributorAuthor

Fixed now in 041548681.

Root cause was Android public Paykit publishing only refreshed the reusable on-chain address if the cached address was already reserved/unavailable. In the delete profile -> recreate profile flow, Lightning receive could be unavailable and the cached reusable on-chain address could still be blank, so endpoint sync concluded there were no supported endpoints and showed the toast.

I changed public Paykit endpoint sync to ensure a reusable on-chain address exists before deciding there is no publishable endpoint, and added regression coverage for the blank-address case. Also merged latest master and resolved the version-catalog conflict by keeping bitkit-core 0.1.75 from master plus Paykit 0.1.0-rc21 from this PR.

Checked:

  • ./gradlew testDevDebugUnitTest --tests to.bitkit.repositories.PublicPaykitRepoTest --tests to.bitkit.repositories.WalletRepoTest
  • ./gradlew compileDevDebugKotlin
  • ./gradlew detekt
  • git diff --check

GitHub now reports the PR as mergeable.

@jvsena42
jvsena42 self-requested a review July 1, 2026 12:46
@jvsena42

jvsena42 commented Jul 1, 2026

Copy link
Copy Markdown
Member

⚠️ Ring sign-in crashes: there is no reactor running, must be called from the context of a Tokio 1.x runtime

Reproduced when tapping "Sign in with Pubky Ring":

Screen_recording_20260701_095809.webm
ERROR [PubkyChoiceViewModel.kt:101] Starting Ring auth failed
[AppError='there is no reactor running, must be called from the context of a Tokio 1.x runtime']

Call chain

PubkyChoiceViewModel.startRingAuth()
→ PubkyRepo.startAuthentication() (PubkyRepo.kt:268)
→ PubkyService.startAuth() (PubkyService.kt:88)
→ PaykitSdkService.startAuth() (PaykitSdkService.kt:201)
→ PubkySessionBootstrap().startSignInAuth(...) ← panics here

Root cause (SDK binding, not app code)

Decompiled paykit-android:0.1.0-rc21 to confirm:

  • startSignInAuth / startSignUpAuth / resumeAuth are exported as synchronous FFI calls (uniffiRustCallWithError). UniFFI does not enter a Tokio runtime around blocking calls.
  • The bootstrap functions we use elsewhere — signIn, signUp, importSession, complete, approveAuth — are suspend, driven through UniFFI's async scaffolding on the SDK's Tokio runtime, so a reactor is present.

The Rust impl of startSignInAuth needs a Tokio reactor (builds the relay/network client for the Ring flow), but because it's a blocking export it runs on our core-queue thread with no runtime entered → panic. Pure-crypto sync functions in the same SDK (derivePubkySecretKey, pubkyPublicKeyFromSecret, parsePubkyAuthUrl) work fine because they touch no reactor.

The Ring startSignInAuth API did not exist in rc8 — it's new in rc21.

No clean app-side fix

Kotlin can't enter a Tokio reactor for a blocking UniFFI call, and there is no suspend alternative for starting the flow (only sync startSignInAuth/startSignUpAuth/resumeAuth exist), so withContext(ioDispatcher) / ServiceQueue.CORE don't help.

Fix belongs in paykit-rs: export the start-auth bootstrap functions as async, or have the Rust side enter/hold a runtime (Handle::enter()) inside them. Also worth checking whether a newer paykit-android rc already makes these async before pinning.

@jvsena42jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@piotr-iohk

Copy link
Copy Markdown
Collaborator

Manual regression — Paykit / contact payments

Environment: regtest, staging
Pair tested: Android (pubkyraoz…) ↔ iOS (pubkytrb4ja…)
Logs attached:
ios: bitkit_logs_2026-07-01_13-20-03.zip
android: logs.zip


Test setup

DevicePlatformProfile (pubky)LN node ID
AAndroidpubkyraozwuopbt5pa3e8ki4kqeec8rmw7giruqicw53zehk3uef71agy02f2dc5c…
BiOSpubkytrb4ja4aorm19xsiouw5hmq6ecfp1xprbdkh8x9jqe9edmrwtz1o021714b0…

Session 1 — fresh profiles (smoke)

TestAndroidiOS
Create Pubky profile
Paykit session / identity
Add contact (scan pubky)
Open LN channel (Blocktank)
On-chain send✅ (9a042478…)
LN send to/from contact
Activity sync
Incoming activity shows “Received from [contact]”
RestoreReplayError in logsNot seenNot seen

Session 1 looked good for basic contact + payment flows cross-platform.

Private Paykit in session 1: Incoming activity showing “Received from [contact]” indicates the receive path worked — that label is only set when the payment matches a private Paykit invoice/address (not a generic public profile invoice). There are no private Paykit link errors in session 1 logs on either platform. Send-side logs showing Handling decoded scan data: OnChain(…?lightning=lnbcrt1…) do not by themselves prove public vs private; that is how the send flow represents the payment request.


Session 2 — profile delete, re-create, re-add contacts, second delete blocked

StepAndroidiOS
Delete profile (1st)✅ ~12:23✅ ~12:22 (Deleted all contacts, keychain cleared)
Re-create profile (same pubky key)✅ 409 → sign-in retry✅ 409 → sign-in retry
Re-add contact✅ ~12:26
Contact LN send A → B
Contact LN send B → A
Incoming activity shows “Received from [contact]”
Private Paykit link (no RestoreReplayError)
Delete profile again (2nd attempt)❌ ~13:17–13:18❌ ~13:17 UTC
2nd delete error“Private Paykit is not available.”“Private Paykit is not available.”

Delete profile:

Screen.Recording.2026-07-01.at.15.18.17.mov

Regression — private Paykit broken after profile reset

Session 1: Private Paykit appears to work (receive-side “Received from contact” + no link errors).
Session 2: After deleting/re-creating profiles (same pubky keys) and re-adding contacts, contact LN sends still succeed but private Paykit does not recover. Incoming activity no longer shows “Received from [contact]” — consistent with payments hitting public endpoints instead of private ones. Public fallback is by design (includePublicEndpoints = true); no in-app warning is expected for payments.

Later in the same session, a second profile delete also failed on both platforms — private Paykit cleanup runs before delete and throws PrivateUnavailable, blocking sign-out entirely.

Private Paykit errors (identical on both platforms)

Every private Paykit attempt (prepare, channel usable / refresh, foreground, contact payment) logs:

Failed to prepare private Paykit link for '<contact>'
→ RestoreReplayError: pubky-noise handshake restore failed
Failed to queue private Paykit endpoints …
→ Encrypted Link recovery is required for counterparty <pubky-id>
Deferred private Paykit endpoint publish / Private Paykit is not available

First failures appear immediately after profile re-create (~12:23 iOS, ~12:26 Android on contact re-add).

Contact payments fall back to public

Payments use a public BIP21 unified invoice from the contact’s published profile — not an encrypted private payment list:

  • Shared public address in logs: bcrt1q2h4c7ghs2lj3glrm77mxdae3w2r5h6f3ph258l?lightning=lnbcrt1…
  • Android (AppViewModel): Handling decoded scan data: OnChain(… params={lightning=lnbcrt1…})PaymentSuccessful
  • iOS (LightningService / SendConfirmationView): Paying bolt11: lnbcrt1…Lightning payment successful

Second profile delete blocked

Profile delete runs private Paykit endpoint cleanup first. With private Paykit already broken, cleanup throws PrivateUnavailable and delete aborts before homeserver sign-out.

Android (EditProfileViewModelPrivatePaykitRepo.removePublishedEndpointsForCleanup):

Failed to remove private Paykit endpoints during 'EditProfileViewModel'
[PrivateUnavailable='Private Paykit is not available']

iOS (PubkyProfileManager.deleteProfileremovePrivatePaykitEndpoints):

Failed to remove private Paykit endpoints before clearing session: privateUnavailable
ERROR Failed to delete profile: privateUnavailable - EditProfileView

Profile reset sequence (both sides)

  1. Profile delete → contacts removed, PAYKIT_SESSION / PAYKIT_SDK_STATE cleared
  2. Re-create → homeserver returns 409 User already exists → app signs in with existing key (same pubky identity)
  3. Public Paykit endpoints sync; no successful private encrypted-link handshake in logs
  4. After re-adding contact, RestoreReplayError persists through contact payments
  5. Second delete attempt fails — user stuck unless disconnect/retry workaround is used

Likely cause: local Paykit SDK state is wiped on delete/re-create, but encrypted-link handshake state is inconsistent across peers. SDK reports recovery is required; the app logs warnings, skips private publish, and resolves contact payments via public endpoints (intentional fallback).

Useful grep patterns:RestoreReplayError, Encrypted Link recovery, PrivateUnavailable, Failed to delete profile, Handling decoded scan data: OnChain


Verdict

ScopeResult
Session 1 — fresh profiles: contacts, on-chain + LN, private receive (“Received from contact”)✅ Pass (smoke)
Session 2 — profile reset: contact payments work (public fallback)✅ By design
Session 2 — private Paykit restored; “Received from contact” on receiveRegression
Session 2 — second profile delete blocked (PrivateUnavailable)Regression

Not approving on “private contact payments survive profile delete/re-add.” Session 1 private Paykit looks fine; session 2 regresses on private Paykit recovery and blocks a second profile delete.

@ben-kaufman

ben-kaufman commented Jul 2, 2026

Copy link
Copy Markdown
ContributorAuthor

Fixed in 82bb55cf6 on Android and 51b7c2ce on iOS.

Main thing is we now use Paykit v0.1.0-rc23, which includes the SDK fix for the stale recovery-required encrypted-link state after deleting/recreating a profile. It also fixes the Ring startSignInAuth Tokio runtime crash, so Android is pinned to rc23 now too.

I also fixed the related app-side edges:

  • sign out/delete no longer get blocked if private cleanup is temporarily unavailable
  • pending private drain retries now keep all queued peers instead of replacing older ones
  • auth approval uses the capabilities from the actual auth URL
  • if Ring auth completes but the app flow is canceled/superseded, we clear that session
  • blank SDK profile names fall back to the saved contact label

Public fallback while private recovery/link work is unavailable is still intentional so contact payments can still complete. Ring is still public-only for now; this fixes the crash path, not full Ring private payments support.

Comment threadapp/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt Outdated
Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt Outdated
@piotr-iohk

Copy link
Copy Markdown
Collaborator

@ben-kaufman is pubky-ring option disabled?
Gating_no_profile_pubky_profile_1_-_Contactsprofile_entry_points_lead_to_choice_screen-2026-07-02T10-13-40-607Z

@ben-kaufman

Copy link
Copy Markdown
ContributorAuthor

@piotr-iohk Added it back for now, but we will likely remove it, still waiting for final decision on that...

@piotr-iohk

Copy link
Copy Markdown
Collaborator

@piotr-iohk Added it back for now, but we will likely remove it, still waiting for final decision on that...

OK, atm clicking at Import with Pubky ring results in error toast. Not sure then if we want to resolve that or just leave for now? that is on both iOS and Android

Screen.Recording.2026-07-03.at.12.44.46.mov

@piotr-iohk

Copy link
Copy Markdown
Collaborator

Manual regression retest (Jul 3, post rc23)

Environment: regtest, staging
PRs:bitkit-android #1040 · bitkit-ios #606
Build:codex/paykit-sdk-native-integration, Paykit v0.1.0-rc23

Logs:

Same flow as Jul 1: create profiles → add contacts → LN + on-chain (verify private) → delete → re-create (same pubky) → re-add → LN + on-chain → delete again.


Results

StepAndroidiOS
Session 1 — profiles, contacts, LN + on-chain
Session 1 — private receive (“Received from [contact]”)
Session 1 — RestoreReplayError in logsNot seenNot seen
Delete → re-create → re-add contact
Session 2 — LN + on-chain (payments complete)
Session 2 — private Paykit / “Received from [contact]”
Session 2 — RestoreReplayError after re-add
Second profile delete (while private Paykit broken)

Session 2 — private Paykit still broken after profile reset

After delete/re-create/re-add, private link fails again on both platforms:

RestoreReplayError: failed to restore Encrypted Link handshake
Encrypted Link recovery is required for counterparty …
Private Paykit is not available (deferred publish)

Contact payments still complete via public fallback (by design). On Android, post-reset sends resolve to public BIP21 bcrt1qd8yaa9mwfcr5wwqyd999wmuj2vpyfs4s5emuy4?lightning=… after RestoreReplayError on the contact payment path — same pattern as Jul 1. UI: no “Received from [contact]” on incoming activity.

First failures after re-add: ~10:52 Android, ~10:52 UTC iOS.


Fixed since Jul 1 — profile delete no longer blocked

Second delete succeeds even when private cleanup fails. Logs show PrivateUnavailable warnings during cleanup, but noFailed to delete profile: privateUnavailable (iOS) and profile/session clears (Deleted all contacts, PAYKIT_SESSION removed). Jul 1 blocker is resolved.


Verdict

ScopeResult
Session 1 smoke (private contact payments)✅ Pass
Public fallback when private unavailable✅ By design
Private Paykit recovery after profile delete/re-addStill failing (rc23 did not fix this in manual test)
Profile delete when private cleanup failsFixed

Not approving on “private contact payments survive profile delete/re-add.” Happy to re-test after another SDK/app fix; delete trap fix looks good.

Useful grep patterns:RestoreReplayError, Encrypted Link recovery, PrivateUnavailable, Handling decoded scan data: OnChain, Deleted all contacts

@ben-kaufman

Copy link
Copy Markdown
ContributorAuthor

Fixed and tested now. I reran the Android rc26 E2E with two fresh dev installs: Bitkit profiles on both sides, Pay Contacts enabled, contacts added/resolved both ways, Alice paid Bob from Send -> Contact, and Bob's received activity was assigned to Alice with the contact chip + Detach action. I also checked the app logs/DB for the run: no no-endpoint/public-fallback/private-unavailable/send-failure markers, and both latest activity rows have the expected contact keys.

@piotr-iohk

Copy link
Copy Markdown
Collaborator

Manual regression retest (Jul 7)

Environment: regtest, staging
PRs:bitkit-android #1040 · bitkit-ios #606
Build:codex/paykit-sdk-native-integration, Paykit v0.1.0-rc23

Logs:

Cross-platform pair: Android ↔ iOS sim. Same flow as prior retests (Jul 1 / Jul 3) plus PR QA checklist from #1040.


PR QA checklist

#TestAndroidiOS
1Create/edit profile → add contact → contact survives restart
2Send → Contact → pay (private first, public fallback ok)
3Backup/restore wallet with Pubky → pay contact
4Settings → Payment Preference → toggle public/private
5Sign out / delete / disconnect — cleanup then local state cleared

Session flow (regression focus)

StepAndroidiOS
Session 1 — fresh profiles, contacts, LN + on-chain
Session 1 — private contact payments
Delete → re-create (same pubky, 409 → sign-in) → re-add contact
Session 2 — LN + on-chain after reset
Session 2 — private contact payments (incl. “Received from [contact]”)
Second profile delete in same session

Jul 3 blockers — status in this run:

  • RestoreReplayError / encrypted-link recovery after profile reset → not seen (fixed)
  • Profile delete blocked by PrivateUnavailablenot seen (still fixed)

Log support: multiple PaymentSuccessful / Lightning payment successful on both sides; iOS setContact after incoming payments in session 1 and session 2; Deleted all contacts on both platforms without Failed to delete profile.


Known issue — deferred (Android only)

Pubky Ring profile import on Android fails after Ring returns auth success:

Received Pubky Ring auth success callback
Auth approval failed: code=identity_error, context=complete Pubky auth flow
Screenshot 2026-07-07 at 13 56 09

UI: “Authorization Failed” toast on Join the Pubky Web screen (Import with Pubky Ring).

iOS: Ring import works (Pubky auth completed for pubkyc97…).

Agreed with @ben-kaufman on Slack to merge without blocking on this — Android Ring import tracked as follow-up, not a Paykit SDK regression.


Verdict

ScopeResult
Paykit SDK integration — contact payments, profile lifecycle, backup/restore✅ Pass
Private Paykit recovery after profile delete/re-add (Jul 3 regression)✅ Pass
Profile delete when private cleanup flaky✅ Pass
Android Pubky Ring import❌ Deferred (Android-only, post-merge)

LGTM on #1040 / #606 for merge, modulo deferred Android Ring import.

piotr-iohk
piotr-iohk previously approved these changes Jul 7, 2026

@piotr-iohkpiotr-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.

tACK

@jvsena42jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approved except for one comment that worth addressing

Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt Outdated
@ben-kaufman

ben-kaufman commented Jul 8, 2026

Copy link
Copy Markdown
ContributorAuthor

@jvsena42 Fixed in 0c0dd99. Ring auth completion now returns a failed Result if the auth attempt is canceled/superseded while waiting for approval, instead of throwing or waiting forever. Also cleaned up the Keychain runBlocking nits from the review.

@jvsena42
jvsena42 enabled auto-merge July 8, 2026 13:57
@jvsena42
jvsena42 merged commit b3212d6 into masterJul 8, 2026
31 of 33 checks passed
@jvsena42
jvsena42 deleted the codex/paykit-sdk-native-integration branch July 8, 2026 18:06
@piotr-iohkpiotr-iohk mentioned this pull request Jul 21, 2026
5 tasks
Sign up for freeto 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.

5 participants

@ben-kaufman@piotr-iohk@jvsena42@ovitrif@Jasonvdb
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

refactor: integrate paykit sdk - #1040

Merged
jvsena42 merged 24 commits into
masterfrom
codex/paykit-sdk-native-integration
Jul 8, 2026
Merged

refactor: integrate paykit sdk#1040
jvsena42 merged 24 commits into
masterfrom
codex/paykit-sdk-native-integration

Conversation

@ben-kaufman

@ben-kaufmanben-kaufman commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

This PR:

  1. Replaces Bitkit's custom Paykit private/public payment plumbing with the native Paykit SDK.
  2. Moves Pubky profile, contact, public endpoint, private endpoint, and SDK backup state handling through SDK APIs.
  3. Keeps Bitkit responsible for wallet execution, payment-request mapping, contact attribution, endpoint rotation, and public fallback behavior.
  4. Pins Paykit to the published com.synonym:paykit-android:0.1.0-rc23 artifact.
  5. Adds hardening for auth approval capabilities, canceled Ring auth cleanup, profile label fallback, pending private drain retries, and best-effort profile delete/sign-out cleanup.

Description

  • Adds a Paykit SDK service wrapper for session bootstrap, Ring auth, profile/avatar publishing, contact records, public endpoint sync, private payment list sync, and SDK backup state import/export.
  • Refactors public and private Paykit repositories to resolve and publish payment endpoints through SDK APIs while preserving Bitkit's existing endpoint preference order and local payability checks.
  • Moves private contact link and recovery state into the SDK backup string, while keeping Bitkit-owned address reservations and payment attribution in app storage.
  • Updates Pubky profile/contact loading, profile edits, sign-out/delete cleanup, backup/restore, and wallet wipe flows for the SDK-backed state model.

Preview

N/A - SDK integration; no UI layout change.

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.
  • 5. Profile -> Sign Out / Delete Profile / Disconnect Profile: remote endpoint cleanup runs first, then local Pubky and SDK state clear on success.

Automated Checks

  • ./gradlew compileDevDebugKotlin passed.
  • ./gradlew testDevDebugUnitTest passed.
  • ./gradlew testDevDebugUnitTest --tests to.bitkit.repositories.PrivatePaykitRepoTest passed.
  • ./gradlew detekt passed.
  • git diff --check passed.

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

greptile-appsBot commented Jun 24, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces Bitkit's hand-rolled Paykit plumbing with the published com.synonym:paykit-android:0.1.0-rc21 SDK, removing ~2,200 lines of custom link/handshake/recovery state machine code and delegating session, profile, contact, private-payment-list, and backup-state management to native SDK APIs. Wallet execution logic, public-endpoint fallback, Ring/public-only handling, contact attribution, and receiving-detail rotation remain in Bitkit.

  • PaykitSdkService (713 lines, new): wraps PaykitSdk behind operationMutex, implements SdkStateBlobStore (CAS-style revision check against the keychain) and SdkPubkySessionProvider, exposes backup-state versioning via withStateRevisionTracking.
  • PrivatePaykitRepo / PubkyRepo: substantially slimmed by delegating link/handshake work to the SDK; contact profile overrides and paykitSdkBackupState replace the previous PrivatePaykitContactLinkBackupV1 map in wallet backups.
  • Backup migration: old privatePaykitContactLinks data is silently discarded when restoring pre-SDK backups; existing contact-link sessions are not migrated to the new SDK state format.

Confidence Score: 4/5

The core payment flow and session lifecycle look structurally sound; the main risks are edge cases in the new blocking-inside-synchronized SDK state store and empty contact names when the SDK returns a profile with no display data.

The architectural shift is large but well-scoped: the SDK takes over state management that was previously hand-coded, and the delegation boundary is clear. The new PaykitSdkStateBlobStore uses runBlocking(ioDispatcher) inside a synchronized block — not a deadlock under normal load but a thread-starvation risk under sustained IO pressure. PaykitSdkSessionProvider.clearSessionAccess() uses a bare runBlocking {} without a dispatcher, which could misbehave if called from an unusual thread context. The backup restore path for legacy (pre-SDK) backups silently swallows SDK state-clearing errors. The contact-name-empty edge case is a UI regression when the SDK's profile record lacks both displayName and decodable extraJson. None of these are showstoppers, but the blocking-coroutine nesting deserves attention before shipping to broad audiences.

PaykitSdkService.kt (the PaykitSdkStateBlobStore and PaykitSdkSessionProvider inner classes), BackupRepo.kt (legacy restore path around line 619), and PubkyRepo.kt (contactProfile method).

Important Files Changed

FilenameOverview
app/src/main/java/to/bitkit/services/PaykitSdkService.ktNew singleton service wrapping the Paykit SDK; mixes runBlocking inside a synchronized block (saveStateBlobAtomically) and has a bare runBlocking in PaykitSdkSessionProvider.clearSessionAccess().
app/src/main/java/to/bitkit/data/keychain/Keychain.ktAdds a new synchronous upsert(ByteArray) method using runBlocking(this.coroutineContext); consistent with the existing snapshot pattern but called from a synchronized block, risking thread starvation under IO saturation.
app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.ktSubstantially trimmed by delegating link/handshake/recovery state to the SDK; backup snapshot now delegates to PaykitSdkService.exportBackupState(); logic looks correct.
app/src/main/java/to/bitkit/repositories/PubkyRepo.ktDelegates session/profile/contact operations to PaykitSdkService; introduces contactProfileOverrides in PubkyStore and snapshotContactProfileOverrides/restoreContactProfileOverrides for backup; contact name may be empty when paykitProfile has no displayName and no extraJson.
app/src/main/java/to/bitkit/repositories/BackupRepo.ktBackup listeners refactored to observeBackupChanges helper; wallet restore silently swallows SDK state-clearing errors for legacy backups (null paykitSdkBackupState).
app/src/main/java/to/bitkit/services/PubkyService.ktThin wrapper now fully delegates to PaykitSdkService; straightforward and correct.
gradle/libs.versions.tomlBumps paykit-android from rc8 to rc21; no other dependency changes.
app/src/main/java/to/bitkit/models/BackupPayloads.ktReplaces PrivatePaykitContactLinkBackupV1 map with a single paykitSdkBackupState string and adds pubkyContactProfileOverrides; old backup fields removed with no migration path for existing contact-link data.
app/src/main/java/to/bitkit/models/PubkyProfile.ktAdapts to SDK PubkyProfile/PaykitProfile types; fromPaykitProfile may produce an empty contact name if displayName and extraJson are both absent.
app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.ktWipe sequence unchanged in substance; closeAndClear() now delegates SDK state clearing, then keychain.wipe() removes all persisted state.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant App as App/UI
participant PPR as PrivatePaykitRepo
participant SDK as PaykitSdkService
participant PaykitSdk as PaykitSdk (native)
participant Keychain as Keychain
participant BR as BackupRepo
App->>PPR: prepareSavedContacts(publicKeys)
PPR->>SDK: ensureLinkWithPeer(counterparty)
SDK->>PaykitSdk: ensureLinkWithPeer()
PaykitSdk->>Keychain: saveStateBlobAtomically() [synchronized + runBlocking]
SDK->>BR: backupStateVersion++ (via withStateRevisionTracking)
PPR->>SDK: syncPrivatePaymentListsWithReservations(updates)
SDK->>PaykitSdk: syncPrivatePaymentListsWithReservationsAndProcessOutbound()
PaykitSdk->>Keychain: saveStateBlobAtomically()
SDK->>BR: backupStateVersion++
App->>PPR: beginSavedContactPayment(publicKey)
PPR->>SDK: prepareAndResolveContactPayment(counterparty)
SDK->>PaykitSdk: prepareAndResolveContactPayment()
PaykitSdk-->>SDK: ContactPaymentResolution
SDK-->>PPR: PaykitContactPaymentResolution
PPR-->>App: PublicPaykitPaymentResult
BR->>PPR: backupSnapshot()
PPR->>SDK: exportBackupState()
SDK->>PaykitSdk: exportBackupString()
PaykitSdk-->>SDK: String (opaque blob)
SDK-->>BR: paykitSdkBackupState
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 App as App/UI
participant PPR as PrivatePaykitRepo
participant SDK as PaykitSdkService
participant PaykitSdk as PaykitSdk (native)
participant Keychain as Keychain
participant BR as BackupRepo
App->>PPR: prepareSavedContacts(publicKeys)
PPR->>SDK: ensureLinkWithPeer(counterparty)
SDK->>PaykitSdk: ensureLinkWithPeer()
PaykitSdk->>Keychain: saveStateBlobAtomically() [synchronized + runBlocking]
SDK->>BR: backupStateVersion++ (via withStateRevisionTracking)
PPR->>SDK: syncPrivatePaymentListsWithReservations(updates)
SDK->>PaykitSdk: syncPrivatePaymentListsWithReservationsAndProcessOutbound()
PaykitSdk->>Keychain: saveStateBlobAtomically()
SDK->>BR: backupStateVersion++
App->>PPR: beginSavedContactPayment(publicKey)
PPR->>SDK: prepareAndResolveContactPayment(counterparty)
SDK->>PaykitSdk: prepareAndResolveContactPayment()
PaykitSdk-->>SDK: ContactPaymentResolution
SDK-->>PPR: PaykitContactPaymentResolution
PPR-->>App: PublicPaykitPaymentResult
BR->>PPR: backupSnapshot()
PPR->>SDK: exportBackupState()
SDK->>PaykitSdk: exportBackupString()
PaykitSdk-->>SDK: String (opaque blob)
SDK-->>BR: paykitSdkBackupState
Loading

Comments Outside Diff (1)

  1. app/src/main/java/to/bitkit/repositories/BackupRepo.kt, line 619-628 (link)

    P2SDK state-clear failure silently ignored during legacy backup restore

    When paykitSdkBackupState is null (restoring a backup created before this PR), privateRepo.restoreBackup(null) is called and any failure is only logged via onFailure { Logger.warn(...) } — execution continues regardless. Inside restoreBackup(null), paykitSdkService.clearState() deletes the PAYKIT_SDK_STATE keychain entry. If this deletion fails (e.g., keystore error), the stale SDK state persists while the rest of the wallet is restored from the new backup, leaving contact-link and session state out of sync with the freshly restored wallet. The successful path (paykitSdkBackupState != null) uses .getOrThrow() — the legacy path should follow the same convention or at least propagate the failure to surface the inconsistency.

Reviews (1): Last reviewed commit: "fix: preserve paykit cancellation" | Re-trigger Greptile

Comment threadapp/src/main/java/to/bitkit/services/PaykitSdkService.kt
Comment threadapp/src/main/java/to/bitkit/services/PaykitSdkService.kt
Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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:8202a59774

ℹ️ 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 threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt
Comment threadapp/src/main/java/to/bitkit/services/PaykitSdkService.kt Outdated
Comment threadapp/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt Outdated
@ben-kaufman

Copy link
Copy Markdown
ContributorAuthor

For the legacy backup migration note: this is intentional for this PR. The old private Paykit link backup format never shipped, so there is no production data to migrate. Treating it as if it never existed keeps the restore path simpler.

@ovitrifovitrif 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.

Left one inline comment.

Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@piotr-iohk

Copy link
Copy Markdown
Collaborator

That is not necessarily due to this change, because I saw it on other PR also - however e2e tests here failed partially because of this. The failure is intermittent and most of the time tests pass after re-runs.

To reproduce:

  • create a profile.
  • delete profile
  • recreate profile

Result after hitting "Continue" on the following screen:
Screenshot 2026-06-25 at 14 03 04

Attaching logs from e2e run where this happened:
bitkit_2026-06-24_17-37-36.log
logcat.txt

@ovitrifovitrif added this to the 2.5.0 milestone Jun 25, 2026
@ben-kaufmanChatGPT Codex Connector

Copy link
Copy Markdown
ContributorAuthor

Fixed now in 041548681.

Root cause was Android public Paykit publishing only refreshed the reusable on-chain address if the cached address was already reserved/unavailable. In the delete profile -> recreate profile flow, Lightning receive could be unavailable and the cached reusable on-chain address could still be blank, so endpoint sync concluded there were no supported endpoints and showed the toast.

I changed public Paykit endpoint sync to ensure a reusable on-chain address exists before deciding there is no publishable endpoint, and added regression coverage for the blank-address case. Also merged latest master and resolved the version-catalog conflict by keeping bitkit-core 0.1.75 from master plus Paykit 0.1.0-rc21 from this PR.

Checked:

  • ./gradlew testDevDebugUnitTest --tests to.bitkit.repositories.PublicPaykitRepoTest --tests to.bitkit.repositories.WalletRepoTest
  • ./gradlew compileDevDebugKotlin
  • ./gradlew detekt
  • git diff --check

GitHub now reports the PR as mergeable.

@jvsena42
jvsena42 self-requested a review July 1, 2026 12:46
@jvsena42

jvsena42 commented Jul 1, 2026

Copy link
Copy Markdown
Member

⚠️ Ring sign-in crashes: there is no reactor running, must be called from the context of a Tokio 1.x runtime

Reproduced when tapping "Sign in with Pubky Ring":

Screen_recording_20260701_095809.webm
ERROR [PubkyChoiceViewModel.kt:101] Starting Ring auth failed
[AppError='there is no reactor running, must be called from the context of a Tokio 1.x runtime']

Call chain

PubkyChoiceViewModel.startRingAuth()
→ PubkyRepo.startAuthentication() (PubkyRepo.kt:268)
→ PubkyService.startAuth() (PubkyService.kt:88)
→ PaykitSdkService.startAuth() (PaykitSdkService.kt:201)
→ PubkySessionBootstrap().startSignInAuth(...) ← panics here

Root cause (SDK binding, not app code)

Decompiled paykit-android:0.1.0-rc21 to confirm:

  • startSignInAuth / startSignUpAuth / resumeAuth are exported as synchronous FFI calls (uniffiRustCallWithError). UniFFI does not enter a Tokio runtime around blocking calls.
  • The bootstrap functions we use elsewhere — signIn, signUp, importSession, complete, approveAuth — are suspend, driven through UniFFI's async scaffolding on the SDK's Tokio runtime, so a reactor is present.

The Rust impl of startSignInAuth needs a Tokio reactor (builds the relay/network client for the Ring flow), but because it's a blocking export it runs on our core-queue thread with no runtime entered → panic. Pure-crypto sync functions in the same SDK (derivePubkySecretKey, pubkyPublicKeyFromSecret, parsePubkyAuthUrl) work fine because they touch no reactor.

The Ring startSignInAuth API did not exist in rc8 — it's new in rc21.

No clean app-side fix

Kotlin can't enter a Tokio reactor for a blocking UniFFI call, and there is no suspend alternative for starting the flow (only sync startSignInAuth/startSignUpAuth/resumeAuth exist), so withContext(ioDispatcher) / ServiceQueue.CORE don't help.

Fix belongs in paykit-rs: export the start-auth bootstrap functions as async, or have the Rust side enter/hold a runtime (Handle::enter()) inside them. Also worth checking whether a newer paykit-android rc already makes these async before pinning.

@jvsena42jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@piotr-iohk

Copy link
Copy Markdown
Collaborator

Manual regression — Paykit / contact payments

Environment: regtest, staging
Pair tested: Android (pubkyraoz…) ↔ iOS (pubkytrb4ja…)
Logs attached:
ios: bitkit_logs_2026-07-01_13-20-03.zip
android: logs.zip


Test setup

DevicePlatformProfile (pubky)LN node ID
AAndroidpubkyraozwuopbt5pa3e8ki4kqeec8rmw7giruqicw53zehk3uef71agy02f2dc5c…
BiOSpubkytrb4ja4aorm19xsiouw5hmq6ecfp1xprbdkh8x9jqe9edmrwtz1o021714b0…

Session 1 — fresh profiles (smoke)

TestAndroidiOS
Create Pubky profile
Paykit session / identity
Add contact (scan pubky)
Open LN channel (Blocktank)
On-chain send✅ (9a042478…)
LN send to/from contact
Activity sync
Incoming activity shows “Received from [contact]”
RestoreReplayError in logsNot seenNot seen

Session 1 looked good for basic contact + payment flows cross-platform.

Private Paykit in session 1: Incoming activity showing “Received from [contact]” indicates the receive path worked — that label is only set when the payment matches a private Paykit invoice/address (not a generic public profile invoice). There are no private Paykit link errors in session 1 logs on either platform. Send-side logs showing Handling decoded scan data: OnChain(…?lightning=lnbcrt1…) do not by themselves prove public vs private; that is how the send flow represents the payment request.


Session 2 — profile delete, re-create, re-add contacts, second delete blocked

StepAndroidiOS
Delete profile (1st)✅ ~12:23✅ ~12:22 (Deleted all contacts, keychain cleared)
Re-create profile (same pubky key)✅ 409 → sign-in retry✅ 409 → sign-in retry
Re-add contact✅ ~12:26
Contact LN send A → B
Contact LN send B → A
Incoming activity shows “Received from [contact]”
Private Paykit link (no RestoreReplayError)
Delete profile again (2nd attempt)❌ ~13:17–13:18❌ ~13:17 UTC
2nd delete error“Private Paykit is not available.”“Private Paykit is not available.”

Delete profile:

Screen.Recording.2026-07-01.at.15.18.17.mov

Regression — private Paykit broken after profile reset

Session 1: Private Paykit appears to work (receive-side “Received from contact” + no link errors).
Session 2: After deleting/re-creating profiles (same pubky keys) and re-adding contacts, contact LN sends still succeed but private Paykit does not recover. Incoming activity no longer shows “Received from [contact]” — consistent with payments hitting public endpoints instead of private ones. Public fallback is by design (includePublicEndpoints = true); no in-app warning is expected for payments.

Later in the same session, a second profile delete also failed on both platforms — private Paykit cleanup runs before delete and throws PrivateUnavailable, blocking sign-out entirely.

Private Paykit errors (identical on both platforms)

Every private Paykit attempt (prepare, channel usable / refresh, foreground, contact payment) logs:

Failed to prepare private Paykit link for '<contact>'
→ RestoreReplayError: pubky-noise handshake restore failed
Failed to queue private Paykit endpoints …
→ Encrypted Link recovery is required for counterparty <pubky-id>
Deferred private Paykit endpoint publish / Private Paykit is not available

First failures appear immediately after profile re-create (~12:23 iOS, ~12:26 Android on contact re-add).

Contact payments fall back to public

Payments use a public BIP21 unified invoice from the contact’s published profile — not an encrypted private payment list:

  • Shared public address in logs: bcrt1q2h4c7ghs2lj3glrm77mxdae3w2r5h6f3ph258l?lightning=lnbcrt1…
  • Android (AppViewModel): Handling decoded scan data: OnChain(… params={lightning=lnbcrt1…})PaymentSuccessful
  • iOS (LightningService / SendConfirmationView): Paying bolt11: lnbcrt1…Lightning payment successful

Second profile delete blocked

Profile delete runs private Paykit endpoint cleanup first. With private Paykit already broken, cleanup throws PrivateUnavailable and delete aborts before homeserver sign-out.

Android (EditProfileViewModelPrivatePaykitRepo.removePublishedEndpointsForCleanup):

Failed to remove private Paykit endpoints during 'EditProfileViewModel'
[PrivateUnavailable='Private Paykit is not available']

iOS (PubkyProfileManager.deleteProfileremovePrivatePaykitEndpoints):

Failed to remove private Paykit endpoints before clearing session: privateUnavailable
ERROR Failed to delete profile: privateUnavailable - EditProfileView

Profile reset sequence (both sides)

  1. Profile delete → contacts removed, PAYKIT_SESSION / PAYKIT_SDK_STATE cleared
  2. Re-create → homeserver returns 409 User already exists → app signs in with existing key (same pubky identity)
  3. Public Paykit endpoints sync; no successful private encrypted-link handshake in logs
  4. After re-adding contact, RestoreReplayError persists through contact payments
  5. Second delete attempt fails — user stuck unless disconnect/retry workaround is used

Likely cause: local Paykit SDK state is wiped on delete/re-create, but encrypted-link handshake state is inconsistent across peers. SDK reports recovery is required; the app logs warnings, skips private publish, and resolves contact payments via public endpoints (intentional fallback).

Useful grep patterns:RestoreReplayError, Encrypted Link recovery, PrivateUnavailable, Failed to delete profile, Handling decoded scan data: OnChain


Verdict

ScopeResult
Session 1 — fresh profiles: contacts, on-chain + LN, private receive (“Received from contact”)✅ Pass (smoke)
Session 2 — profile reset: contact payments work (public fallback)✅ By design
Session 2 — private Paykit restored; “Received from contact” on receiveRegression
Session 2 — second profile delete blocked (PrivateUnavailable)Regression

Not approving on “private contact payments survive profile delete/re-add.” Session 1 private Paykit looks fine; session 2 regresses on private Paykit recovery and blocks a second profile delete.

@ben-kaufman

ben-kaufman commented Jul 2, 2026

Copy link
Copy Markdown
ContributorAuthor

Fixed in 82bb55cf6 on Android and 51b7c2ce on iOS.

Main thing is we now use Paykit v0.1.0-rc23, which includes the SDK fix for the stale recovery-required encrypted-link state after deleting/recreating a profile. It also fixes the Ring startSignInAuth Tokio runtime crash, so Android is pinned to rc23 now too.

I also fixed the related app-side edges:

  • sign out/delete no longer get blocked if private cleanup is temporarily unavailable
  • pending private drain retries now keep all queued peers instead of replacing older ones
  • auth approval uses the capabilities from the actual auth URL
  • if Ring auth completes but the app flow is canceled/superseded, we clear that session
  • blank SDK profile names fall back to the saved contact label

Public fallback while private recovery/link work is unavailable is still intentional so contact payments can still complete. Ring is still public-only for now; this fixes the crash path, not full Ring private payments support.

Comment threadapp/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt Outdated
Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt Outdated
@piotr-iohk

Copy link
Copy Markdown
Collaborator

@ben-kaufman is pubky-ring option disabled?
Gating_no_profile_pubky_profile_1_-_Contactsprofile_entry_points_lead_to_choice_screen-2026-07-02T10-13-40-607Z

@ben-kaufman

Copy link
Copy Markdown
ContributorAuthor

@piotr-iohk Added it back for now, but we will likely remove it, still waiting for final decision on that...

@piotr-iohk

Copy link
Copy Markdown
Collaborator

@piotr-iohk Added it back for now, but we will likely remove it, still waiting for final decision on that...

OK, atm clicking at Import with Pubky ring results in error toast. Not sure then if we want to resolve that or just leave for now? that is on both iOS and Android

Screen.Recording.2026-07-03.at.12.44.46.mov

@piotr-iohk

Copy link
Copy Markdown
Collaborator

Manual regression retest (Jul 3, post rc23)

Environment: regtest, staging
PRs:bitkit-android #1040 · bitkit-ios #606
Build:codex/paykit-sdk-native-integration, Paykit v0.1.0-rc23

Logs:

Same flow as Jul 1: create profiles → add contacts → LN + on-chain (verify private) → delete → re-create (same pubky) → re-add → LN + on-chain → delete again.


Results

StepAndroidiOS
Session 1 — profiles, contacts, LN + on-chain
Session 1 — private receive (“Received from [contact]”)
Session 1 — RestoreReplayError in logsNot seenNot seen
Delete → re-create → re-add contact
Session 2 — LN + on-chain (payments complete)
Session 2 — private Paykit / “Received from [contact]”
Session 2 — RestoreReplayError after re-add
Second profile delete (while private Paykit broken)

Session 2 — private Paykit still broken after profile reset

After delete/re-create/re-add, private link fails again on both platforms:

RestoreReplayError: failed to restore Encrypted Link handshake
Encrypted Link recovery is required for counterparty …
Private Paykit is not available (deferred publish)

Contact payments still complete via public fallback (by design). On Android, post-reset sends resolve to public BIP21 bcrt1qd8yaa9mwfcr5wwqyd999wmuj2vpyfs4s5emuy4?lightning=… after RestoreReplayError on the contact payment path — same pattern as Jul 1. UI: no “Received from [contact]” on incoming activity.

First failures after re-add: ~10:52 Android, ~10:52 UTC iOS.


Fixed since Jul 1 — profile delete no longer blocked

Second delete succeeds even when private cleanup fails. Logs show PrivateUnavailable warnings during cleanup, but noFailed to delete profile: privateUnavailable (iOS) and profile/session clears (Deleted all contacts, PAYKIT_SESSION removed). Jul 1 blocker is resolved.


Verdict

ScopeResult
Session 1 smoke (private contact payments)✅ Pass
Public fallback when private unavailable✅ By design
Private Paykit recovery after profile delete/re-addStill failing (rc23 did not fix this in manual test)
Profile delete when private cleanup failsFixed

Not approving on “private contact payments survive profile delete/re-add.” Happy to re-test after another SDK/app fix; delete trap fix looks good.

Useful grep patterns:RestoreReplayError, Encrypted Link recovery, PrivateUnavailable, Handling decoded scan data: OnChain, Deleted all contacts

@ben-kaufman

Copy link
Copy Markdown
ContributorAuthor

Fixed and tested now. I reran the Android rc26 E2E with two fresh dev installs: Bitkit profiles on both sides, Pay Contacts enabled, contacts added/resolved both ways, Alice paid Bob from Send -> Contact, and Bob's received activity was assigned to Alice with the contact chip + Detach action. I also checked the app logs/DB for the run: no no-endpoint/public-fallback/private-unavailable/send-failure markers, and both latest activity rows have the expected contact keys.

@piotr-iohk

Copy link
Copy Markdown
Collaborator

Manual regression retest (Jul 7)

Environment: regtest, staging
PRs:bitkit-android #1040 · bitkit-ios #606
Build:codex/paykit-sdk-native-integration, Paykit v0.1.0-rc23

Logs:

Cross-platform pair: Android ↔ iOS sim. Same flow as prior retests (Jul 1 / Jul 3) plus PR QA checklist from #1040.


PR QA checklist

#TestAndroidiOS
1Create/edit profile → add contact → contact survives restart
2Send → Contact → pay (private first, public fallback ok)
3Backup/restore wallet with Pubky → pay contact
4Settings → Payment Preference → toggle public/private
5Sign out / delete / disconnect — cleanup then local state cleared

Session flow (regression focus)

StepAndroidiOS
Session 1 — fresh profiles, contacts, LN + on-chain
Session 1 — private contact payments
Delete → re-create (same pubky, 409 → sign-in) → re-add contact
Session 2 — LN + on-chain after reset
Session 2 — private contact payments (incl. “Received from [contact]”)
Second profile delete in same session

Jul 3 blockers — status in this run:

  • RestoreReplayError / encrypted-link recovery after profile reset → not seen (fixed)
  • Profile delete blocked by PrivateUnavailablenot seen (still fixed)

Log support: multiple PaymentSuccessful / Lightning payment successful on both sides; iOS setContact after incoming payments in session 1 and session 2; Deleted all contacts on both platforms without Failed to delete profile.


Known issue — deferred (Android only)

Pubky Ring profile import on Android fails after Ring returns auth success:

Received Pubky Ring auth success callback
Auth approval failed: code=identity_error, context=complete Pubky auth flow
Screenshot 2026-07-07 at 13 56 09

UI: “Authorization Failed” toast on Join the Pubky Web screen (Import with Pubky Ring).

iOS: Ring import works (Pubky auth completed for pubkyc97…).

Agreed with @ben-kaufman on Slack to merge without blocking on this — Android Ring import tracked as follow-up, not a Paykit SDK regression.


Verdict

ScopeResult
Paykit SDK integration — contact payments, profile lifecycle, backup/restore✅ Pass
Private Paykit recovery after profile delete/re-add (Jul 3 regression)✅ Pass
Profile delete when private cleanup flaky✅ Pass
Android Pubky Ring import❌ Deferred (Android-only, post-merge)

LGTM on #1040 / #606 for merge, modulo deferred Android Ring import.

piotr-iohk
piotr-iohk previously approved these changes Jul 7, 2026

@piotr-iohkpiotr-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.

tACK

@jvsena42jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approved except for one comment that worth addressing

Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt Outdated
@ben-kaufman

ben-kaufman commented Jul 8, 2026

Copy link
Copy Markdown
ContributorAuthor

@jvsena42 Fixed in 0c0dd99. Ring auth completion now returns a failed Result if the auth attempt is canceled/superseded while waiting for approval, instead of throwing or waiting forever. Also cleaned up the Keychain runBlocking nits from the review.

@jvsena42
jvsena42 enabled auto-merge July 8, 2026 13:57
@jvsena42
jvsena42 merged commit b3212d6 into masterJul 8, 2026
31 of 33 checks passed
@jvsena42
jvsena42 deleted the codex/paykit-sdk-native-integration branch July 8, 2026 18:06
@piotr-iohkpiotr-iohk mentioned this pull request Jul 21, 2026
5 tasks
Sign up for freeto 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.

5 participants

@ben-kaufman@piotr-iohk@jvsena42@ovitrif@Jasonvdb
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

refactor: integrate paykit sdk - #1040

Merged
jvsena42 merged 24 commits into
masterfrom
codex/paykit-sdk-native-integration
Jul 8, 2026
Merged

refactor: integrate paykit sdk#1040
jvsena42 merged 24 commits into
masterfrom
codex/paykit-sdk-native-integration

Conversation

@ben-kaufman

@ben-kaufmanben-kaufman commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

This PR:

  1. Replaces Bitkit's custom Paykit private/public payment plumbing with the native Paykit SDK.
  2. Moves Pubky profile, contact, public endpoint, private endpoint, and SDK backup state handling through SDK APIs.
  3. Keeps Bitkit responsible for wallet execution, payment-request mapping, contact attribution, endpoint rotation, and public fallback behavior.
  4. Pins Paykit to the published com.synonym:paykit-android:0.1.0-rc23 artifact.
  5. Adds hardening for auth approval capabilities, canceled Ring auth cleanup, profile label fallback, pending private drain retries, and best-effort profile delete/sign-out cleanup.

Description

  • Adds a Paykit SDK service wrapper for session bootstrap, Ring auth, profile/avatar publishing, contact records, public endpoint sync, private payment list sync, and SDK backup state import/export.
  • Refactors public and private Paykit repositories to resolve and publish payment endpoints through SDK APIs while preserving Bitkit's existing endpoint preference order and local payability checks.
  • Moves private contact link and recovery state into the SDK backup string, while keeping Bitkit-owned address reservations and payment attribution in app storage.
  • Updates Pubky profile/contact loading, profile edits, sign-out/delete cleanup, backup/restore, and wallet wipe flows for the SDK-backed state model.

Preview

N/A - SDK integration; no UI layout change.

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.
  • 5. Profile -> Sign Out / Delete Profile / Disconnect Profile: remote endpoint cleanup runs first, then local Pubky and SDK state clear on success.

Automated Checks

  • ./gradlew compileDevDebugKotlin passed.
  • ./gradlew testDevDebugUnitTest passed.
  • ./gradlew testDevDebugUnitTest --tests to.bitkit.repositories.PrivatePaykitRepoTest passed.
  • ./gradlew detekt passed.
  • git diff --check passed.

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

greptile-appsBot commented Jun 24, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces Bitkit's hand-rolled Paykit plumbing with the published com.synonym:paykit-android:0.1.0-rc21 SDK, removing ~2,200 lines of custom link/handshake/recovery state machine code and delegating session, profile, contact, private-payment-list, and backup-state management to native SDK APIs. Wallet execution logic, public-endpoint fallback, Ring/public-only handling, contact attribution, and receiving-detail rotation remain in Bitkit.

  • PaykitSdkService (713 lines, new): wraps PaykitSdk behind operationMutex, implements SdkStateBlobStore (CAS-style revision check against the keychain) and SdkPubkySessionProvider, exposes backup-state versioning via withStateRevisionTracking.
  • PrivatePaykitRepo / PubkyRepo: substantially slimmed by delegating link/handshake work to the SDK; contact profile overrides and paykitSdkBackupState replace the previous PrivatePaykitContactLinkBackupV1 map in wallet backups.
  • Backup migration: old privatePaykitContactLinks data is silently discarded when restoring pre-SDK backups; existing contact-link sessions are not migrated to the new SDK state format.

Confidence Score: 4/5

The core payment flow and session lifecycle look structurally sound; the main risks are edge cases in the new blocking-inside-synchronized SDK state store and empty contact names when the SDK returns a profile with no display data.

The architectural shift is large but well-scoped: the SDK takes over state management that was previously hand-coded, and the delegation boundary is clear. The new PaykitSdkStateBlobStore uses runBlocking(ioDispatcher) inside a synchronized block — not a deadlock under normal load but a thread-starvation risk under sustained IO pressure. PaykitSdkSessionProvider.clearSessionAccess() uses a bare runBlocking {} without a dispatcher, which could misbehave if called from an unusual thread context. The backup restore path for legacy (pre-SDK) backups silently swallows SDK state-clearing errors. The contact-name-empty edge case is a UI regression when the SDK's profile record lacks both displayName and decodable extraJson. None of these are showstoppers, but the blocking-coroutine nesting deserves attention before shipping to broad audiences.

PaykitSdkService.kt (the PaykitSdkStateBlobStore and PaykitSdkSessionProvider inner classes), BackupRepo.kt (legacy restore path around line 619), and PubkyRepo.kt (contactProfile method).

Important Files Changed

FilenameOverview
app/src/main/java/to/bitkit/services/PaykitSdkService.ktNew singleton service wrapping the Paykit SDK; mixes runBlocking inside a synchronized block (saveStateBlobAtomically) and has a bare runBlocking in PaykitSdkSessionProvider.clearSessionAccess().
app/src/main/java/to/bitkit/data/keychain/Keychain.ktAdds a new synchronous upsert(ByteArray) method using runBlocking(this.coroutineContext); consistent with the existing snapshot pattern but called from a synchronized block, risking thread starvation under IO saturation.
app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.ktSubstantially trimmed by delegating link/handshake/recovery state to the SDK; backup snapshot now delegates to PaykitSdkService.exportBackupState(); logic looks correct.
app/src/main/java/to/bitkit/repositories/PubkyRepo.ktDelegates session/profile/contact operations to PaykitSdkService; introduces contactProfileOverrides in PubkyStore and snapshotContactProfileOverrides/restoreContactProfileOverrides for backup; contact name may be empty when paykitProfile has no displayName and no extraJson.
app/src/main/java/to/bitkit/repositories/BackupRepo.ktBackup listeners refactored to observeBackupChanges helper; wallet restore silently swallows SDK state-clearing errors for legacy backups (null paykitSdkBackupState).
app/src/main/java/to/bitkit/services/PubkyService.ktThin wrapper now fully delegates to PaykitSdkService; straightforward and correct.
gradle/libs.versions.tomlBumps paykit-android from rc8 to rc21; no other dependency changes.
app/src/main/java/to/bitkit/models/BackupPayloads.ktReplaces PrivatePaykitContactLinkBackupV1 map with a single paykitSdkBackupState string and adds pubkyContactProfileOverrides; old backup fields removed with no migration path for existing contact-link data.
app/src/main/java/to/bitkit/models/PubkyProfile.ktAdapts to SDK PubkyProfile/PaykitProfile types; fromPaykitProfile may produce an empty contact name if displayName and extraJson are both absent.
app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.ktWipe sequence unchanged in substance; closeAndClear() now delegates SDK state clearing, then keychain.wipe() removes all persisted state.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant App as App/UI
participant PPR as PrivatePaykitRepo
participant SDK as PaykitSdkService
participant PaykitSdk as PaykitSdk (native)
participant Keychain as Keychain
participant BR as BackupRepo
App->>PPR: prepareSavedContacts(publicKeys)
PPR->>SDK: ensureLinkWithPeer(counterparty)
SDK->>PaykitSdk: ensureLinkWithPeer()
PaykitSdk->>Keychain: saveStateBlobAtomically() [synchronized + runBlocking]
SDK->>BR: backupStateVersion++ (via withStateRevisionTracking)
PPR->>SDK: syncPrivatePaymentListsWithReservations(updates)
SDK->>PaykitSdk: syncPrivatePaymentListsWithReservationsAndProcessOutbound()
PaykitSdk->>Keychain: saveStateBlobAtomically()
SDK->>BR: backupStateVersion++
App->>PPR: beginSavedContactPayment(publicKey)
PPR->>SDK: prepareAndResolveContactPayment(counterparty)
SDK->>PaykitSdk: prepareAndResolveContactPayment()
PaykitSdk-->>SDK: ContactPaymentResolution
SDK-->>PPR: PaykitContactPaymentResolution
PPR-->>App: PublicPaykitPaymentResult
BR->>PPR: backupSnapshot()
PPR->>SDK: exportBackupState()
SDK->>PaykitSdk: exportBackupString()
PaykitSdk-->>SDK: String (opaque blob)
SDK-->>BR: paykitSdkBackupState
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 App as App/UI
participant PPR as PrivatePaykitRepo
participant SDK as PaykitSdkService
participant PaykitSdk as PaykitSdk (native)
participant Keychain as Keychain
participant BR as BackupRepo
App->>PPR: prepareSavedContacts(publicKeys)
PPR->>SDK: ensureLinkWithPeer(counterparty)
SDK->>PaykitSdk: ensureLinkWithPeer()
PaykitSdk->>Keychain: saveStateBlobAtomically() [synchronized + runBlocking]
SDK->>BR: backupStateVersion++ (via withStateRevisionTracking)
PPR->>SDK: syncPrivatePaymentListsWithReservations(updates)
SDK->>PaykitSdk: syncPrivatePaymentListsWithReservationsAndProcessOutbound()
PaykitSdk->>Keychain: saveStateBlobAtomically()
SDK->>BR: backupStateVersion++
App->>PPR: beginSavedContactPayment(publicKey)
PPR->>SDK: prepareAndResolveContactPayment(counterparty)
SDK->>PaykitSdk: prepareAndResolveContactPayment()
PaykitSdk-->>SDK: ContactPaymentResolution
SDK-->>PPR: PaykitContactPaymentResolution
PPR-->>App: PublicPaykitPaymentResult
BR->>PPR: backupSnapshot()
PPR->>SDK: exportBackupState()
SDK->>PaykitSdk: exportBackupString()
PaykitSdk-->>SDK: String (opaque blob)
SDK-->>BR: paykitSdkBackupState
Loading

Comments Outside Diff (1)

  1. app/src/main/java/to/bitkit/repositories/BackupRepo.kt, line 619-628 (link)

    P2SDK state-clear failure silently ignored during legacy backup restore

    When paykitSdkBackupState is null (restoring a backup created before this PR), privateRepo.restoreBackup(null) is called and any failure is only logged via onFailure { Logger.warn(...) } — execution continues regardless. Inside restoreBackup(null), paykitSdkService.clearState() deletes the PAYKIT_SDK_STATE keychain entry. If this deletion fails (e.g., keystore error), the stale SDK state persists while the rest of the wallet is restored from the new backup, leaving contact-link and session state out of sync with the freshly restored wallet. The successful path (paykitSdkBackupState != null) uses .getOrThrow() — the legacy path should follow the same convention or at least propagate the failure to surface the inconsistency.

Reviews (1): Last reviewed commit: "fix: preserve paykit cancellation" | Re-trigger Greptile

Comment threadapp/src/main/java/to/bitkit/services/PaykitSdkService.kt
Comment threadapp/src/main/java/to/bitkit/services/PaykitSdkService.kt
Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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:8202a59774

ℹ️ 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 threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt
Comment threadapp/src/main/java/to/bitkit/services/PaykitSdkService.kt Outdated
Comment threadapp/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt Outdated
@ben-kaufman

Copy link
Copy Markdown
ContributorAuthor

For the legacy backup migration note: this is intentional for this PR. The old private Paykit link backup format never shipped, so there is no production data to migrate. Treating it as if it never existed keeps the restore path simpler.

@ovitrifovitrif 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.

Left one inline comment.

Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@piotr-iohk

Copy link
Copy Markdown
Collaborator

That is not necessarily due to this change, because I saw it on other PR also - however e2e tests here failed partially because of this. The failure is intermittent and most of the time tests pass after re-runs.

To reproduce:

  • create a profile.
  • delete profile
  • recreate profile

Result after hitting "Continue" on the following screen:
Screenshot 2026-06-25 at 14 03 04

Attaching logs from e2e run where this happened:
bitkit_2026-06-24_17-37-36.log
logcat.txt

@ovitrifovitrif added this to the 2.5.0 milestone Jun 25, 2026
@ben-kaufmanChatGPT Codex Connector

Copy link
Copy Markdown
ContributorAuthor

Fixed now in 041548681.

Root cause was Android public Paykit publishing only refreshed the reusable on-chain address if the cached address was already reserved/unavailable. In the delete profile -> recreate profile flow, Lightning receive could be unavailable and the cached reusable on-chain address could still be blank, so endpoint sync concluded there were no supported endpoints and showed the toast.

I changed public Paykit endpoint sync to ensure a reusable on-chain address exists before deciding there is no publishable endpoint, and added regression coverage for the blank-address case. Also merged latest master and resolved the version-catalog conflict by keeping bitkit-core 0.1.75 from master plus Paykit 0.1.0-rc21 from this PR.

Checked:

  • ./gradlew testDevDebugUnitTest --tests to.bitkit.repositories.PublicPaykitRepoTest --tests to.bitkit.repositories.WalletRepoTest
  • ./gradlew compileDevDebugKotlin
  • ./gradlew detekt
  • git diff --check

GitHub now reports the PR as mergeable.

@jvsena42
jvsena42 self-requested a review July 1, 2026 12:46
@jvsena42

jvsena42 commented Jul 1, 2026

Copy link
Copy Markdown
Member

⚠️ Ring sign-in crashes: there is no reactor running, must be called from the context of a Tokio 1.x runtime

Reproduced when tapping "Sign in with Pubky Ring":

Screen_recording_20260701_095809.webm
ERROR [PubkyChoiceViewModel.kt:101] Starting Ring auth failed
[AppError='there is no reactor running, must be called from the context of a Tokio 1.x runtime']

Call chain

PubkyChoiceViewModel.startRingAuth()
→ PubkyRepo.startAuthentication() (PubkyRepo.kt:268)
→ PubkyService.startAuth() (PubkyService.kt:88)
→ PaykitSdkService.startAuth() (PaykitSdkService.kt:201)
→ PubkySessionBootstrap().startSignInAuth(...) ← panics here

Root cause (SDK binding, not app code)

Decompiled paykit-android:0.1.0-rc21 to confirm:

  • startSignInAuth / startSignUpAuth / resumeAuth are exported as synchronous FFI calls (uniffiRustCallWithError). UniFFI does not enter a Tokio runtime around blocking calls.
  • The bootstrap functions we use elsewhere — signIn, signUp, importSession, complete, approveAuth — are suspend, driven through UniFFI's async scaffolding on the SDK's Tokio runtime, so a reactor is present.

The Rust impl of startSignInAuth needs a Tokio reactor (builds the relay/network client for the Ring flow), but because it's a blocking export it runs on our core-queue thread with no runtime entered → panic. Pure-crypto sync functions in the same SDK (derivePubkySecretKey, pubkyPublicKeyFromSecret, parsePubkyAuthUrl) work fine because they touch no reactor.

The Ring startSignInAuth API did not exist in rc8 — it's new in rc21.

No clean app-side fix

Kotlin can't enter a Tokio reactor for a blocking UniFFI call, and there is no suspend alternative for starting the flow (only sync startSignInAuth/startSignUpAuth/resumeAuth exist), so withContext(ioDispatcher) / ServiceQueue.CORE don't help.

Fix belongs in paykit-rs: export the start-auth bootstrap functions as async, or have the Rust side enter/hold a runtime (Handle::enter()) inside them. Also worth checking whether a newer paykit-android rc already makes these async before pinning.

@jvsena42jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@piotr-iohk

Copy link
Copy Markdown
Collaborator

Manual regression — Paykit / contact payments

Environment: regtest, staging
Pair tested: Android (pubkyraoz…) ↔ iOS (pubkytrb4ja…)
Logs attached:
ios: bitkit_logs_2026-07-01_13-20-03.zip
android: logs.zip


Test setup

DevicePlatformProfile (pubky)LN node ID
AAndroidpubkyraozwuopbt5pa3e8ki4kqeec8rmw7giruqicw53zehk3uef71agy02f2dc5c…
BiOSpubkytrb4ja4aorm19xsiouw5hmq6ecfp1xprbdkh8x9jqe9edmrwtz1o021714b0…

Session 1 — fresh profiles (smoke)

TestAndroidiOS
Create Pubky profile
Paykit session / identity
Add contact (scan pubky)
Open LN channel (Blocktank)
On-chain send✅ (9a042478…)
LN send to/from contact
Activity sync
Incoming activity shows “Received from [contact]”
RestoreReplayError in logsNot seenNot seen

Session 1 looked good for basic contact + payment flows cross-platform.

Private Paykit in session 1: Incoming activity showing “Received from [contact]” indicates the receive path worked — that label is only set when the payment matches a private Paykit invoice/address (not a generic public profile invoice). There are no private Paykit link errors in session 1 logs on either platform. Send-side logs showing Handling decoded scan data: OnChain(…?lightning=lnbcrt1…) do not by themselves prove public vs private; that is how the send flow represents the payment request.


Session 2 — profile delete, re-create, re-add contacts, second delete blocked

StepAndroidiOS
Delete profile (1st)✅ ~12:23✅ ~12:22 (Deleted all contacts, keychain cleared)
Re-create profile (same pubky key)✅ 409 → sign-in retry✅ 409 → sign-in retry
Re-add contact✅ ~12:26
Contact LN send A → B
Contact LN send B → A
Incoming activity shows “Received from [contact]”
Private Paykit link (no RestoreReplayError)
Delete profile again (2nd attempt)❌ ~13:17–13:18❌ ~13:17 UTC
2nd delete error“Private Paykit is not available.”“Private Paykit is not available.”

Delete profile:

Screen.Recording.2026-07-01.at.15.18.17.mov

Regression — private Paykit broken after profile reset

Session 1: Private Paykit appears to work (receive-side “Received from contact” + no link errors).
Session 2: After deleting/re-creating profiles (same pubky keys) and re-adding contacts, contact LN sends still succeed but private Paykit does not recover. Incoming activity no longer shows “Received from [contact]” — consistent with payments hitting public endpoints instead of private ones. Public fallback is by design (includePublicEndpoints = true); no in-app warning is expected for payments.

Later in the same session, a second profile delete also failed on both platforms — private Paykit cleanup runs before delete and throws PrivateUnavailable, blocking sign-out entirely.

Private Paykit errors (identical on both platforms)

Every private Paykit attempt (prepare, channel usable / refresh, foreground, contact payment) logs:

Failed to prepare private Paykit link for '<contact>'
→ RestoreReplayError: pubky-noise handshake restore failed
Failed to queue private Paykit endpoints …
→ Encrypted Link recovery is required for counterparty <pubky-id>
Deferred private Paykit endpoint publish / Private Paykit is not available

First failures appear immediately after profile re-create (~12:23 iOS, ~12:26 Android on contact re-add).

Contact payments fall back to public

Payments use a public BIP21 unified invoice from the contact’s published profile — not an encrypted private payment list:

  • Shared public address in logs: bcrt1q2h4c7ghs2lj3glrm77mxdae3w2r5h6f3ph258l?lightning=lnbcrt1…
  • Android (AppViewModel): Handling decoded scan data: OnChain(… params={lightning=lnbcrt1…})PaymentSuccessful
  • iOS (LightningService / SendConfirmationView): Paying bolt11: lnbcrt1…Lightning payment successful

Second profile delete blocked

Profile delete runs private Paykit endpoint cleanup first. With private Paykit already broken, cleanup throws PrivateUnavailable and delete aborts before homeserver sign-out.

Android (EditProfileViewModelPrivatePaykitRepo.removePublishedEndpointsForCleanup):

Failed to remove private Paykit endpoints during 'EditProfileViewModel'
[PrivateUnavailable='Private Paykit is not available']

iOS (PubkyProfileManager.deleteProfileremovePrivatePaykitEndpoints):

Failed to remove private Paykit endpoints before clearing session: privateUnavailable
ERROR Failed to delete profile: privateUnavailable - EditProfileView

Profile reset sequence (both sides)

  1. Profile delete → contacts removed, PAYKIT_SESSION / PAYKIT_SDK_STATE cleared
  2. Re-create → homeserver returns 409 User already exists → app signs in with existing key (same pubky identity)
  3. Public Paykit endpoints sync; no successful private encrypted-link handshake in logs
  4. After re-adding contact, RestoreReplayError persists through contact payments
  5. Second delete attempt fails — user stuck unless disconnect/retry workaround is used

Likely cause: local Paykit SDK state is wiped on delete/re-create, but encrypted-link handshake state is inconsistent across peers. SDK reports recovery is required; the app logs warnings, skips private publish, and resolves contact payments via public endpoints (intentional fallback).

Useful grep patterns:RestoreReplayError, Encrypted Link recovery, PrivateUnavailable, Failed to delete profile, Handling decoded scan data: OnChain


Verdict

ScopeResult
Session 1 — fresh profiles: contacts, on-chain + LN, private receive (“Received from contact”)✅ Pass (smoke)
Session 2 — profile reset: contact payments work (public fallback)✅ By design
Session 2 — private Paykit restored; “Received from contact” on receiveRegression
Session 2 — second profile delete blocked (PrivateUnavailable)Regression

Not approving on “private contact payments survive profile delete/re-add.” Session 1 private Paykit looks fine; session 2 regresses on private Paykit recovery and blocks a second profile delete.

@ben-kaufman

ben-kaufman commented Jul 2, 2026

Copy link
Copy Markdown
ContributorAuthor

Fixed in 82bb55cf6 on Android and 51b7c2ce on iOS.

Main thing is we now use Paykit v0.1.0-rc23, which includes the SDK fix for the stale recovery-required encrypted-link state after deleting/recreating a profile. It also fixes the Ring startSignInAuth Tokio runtime crash, so Android is pinned to rc23 now too.

I also fixed the related app-side edges:

  • sign out/delete no longer get blocked if private cleanup is temporarily unavailable
  • pending private drain retries now keep all queued peers instead of replacing older ones
  • auth approval uses the capabilities from the actual auth URL
  • if Ring auth completes but the app flow is canceled/superseded, we clear that session
  • blank SDK profile names fall back to the saved contact label

Public fallback while private recovery/link work is unavailable is still intentional so contact payments can still complete. Ring is still public-only for now; this fixes the crash path, not full Ring private payments support.

Comment threadapp/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt Outdated
Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt Outdated
@piotr-iohk

Copy link
Copy Markdown
Collaborator

@ben-kaufman is pubky-ring option disabled?
Gating_no_profile_pubky_profile_1_-_Contactsprofile_entry_points_lead_to_choice_screen-2026-07-02T10-13-40-607Z

@ben-kaufman

Copy link
Copy Markdown
ContributorAuthor

@piotr-iohk Added it back for now, but we will likely remove it, still waiting for final decision on that...

@piotr-iohk

Copy link
Copy Markdown
Collaborator

@piotr-iohk Added it back for now, but we will likely remove it, still waiting for final decision on that...

OK, atm clicking at Import with Pubky ring results in error toast. Not sure then if we want to resolve that or just leave for now? that is on both iOS and Android

Screen.Recording.2026-07-03.at.12.44.46.mov

@piotr-iohk

Copy link
Copy Markdown
Collaborator

Manual regression retest (Jul 3, post rc23)

Environment: regtest, staging
PRs:bitkit-android #1040 · bitkit-ios #606
Build:codex/paykit-sdk-native-integration, Paykit v0.1.0-rc23

Logs:

Same flow as Jul 1: create profiles → add contacts → LN + on-chain (verify private) → delete → re-create (same pubky) → re-add → LN + on-chain → delete again.


Results

StepAndroidiOS
Session 1 — profiles, contacts, LN + on-chain
Session 1 — private receive (“Received from [contact]”)
Session 1 — RestoreReplayError in logsNot seenNot seen
Delete → re-create → re-add contact
Session 2 — LN + on-chain (payments complete)
Session 2 — private Paykit / “Received from [contact]”
Session 2 — RestoreReplayError after re-add
Second profile delete (while private Paykit broken)

Session 2 — private Paykit still broken after profile reset

After delete/re-create/re-add, private link fails again on both platforms:

RestoreReplayError: failed to restore Encrypted Link handshake
Encrypted Link recovery is required for counterparty …
Private Paykit is not available (deferred publish)

Contact payments still complete via public fallback (by design). On Android, post-reset sends resolve to public BIP21 bcrt1qd8yaa9mwfcr5wwqyd999wmuj2vpyfs4s5emuy4?lightning=… after RestoreReplayError on the contact payment path — same pattern as Jul 1. UI: no “Received from [contact]” on incoming activity.

First failures after re-add: ~10:52 Android, ~10:52 UTC iOS.


Fixed since Jul 1 — profile delete no longer blocked

Second delete succeeds even when private cleanup fails. Logs show PrivateUnavailable warnings during cleanup, but noFailed to delete profile: privateUnavailable (iOS) and profile/session clears (Deleted all contacts, PAYKIT_SESSION removed). Jul 1 blocker is resolved.


Verdict

ScopeResult
Session 1 smoke (private contact payments)✅ Pass
Public fallback when private unavailable✅ By design
Private Paykit recovery after profile delete/re-addStill failing (rc23 did not fix this in manual test)
Profile delete when private cleanup failsFixed

Not approving on “private contact payments survive profile delete/re-add.” Happy to re-test after another SDK/app fix; delete trap fix looks good.

Useful grep patterns:RestoreReplayError, Encrypted Link recovery, PrivateUnavailable, Handling decoded scan data: OnChain, Deleted all contacts

@ben-kaufman

Copy link
Copy Markdown
ContributorAuthor

Fixed and tested now. I reran the Android rc26 E2E with two fresh dev installs: Bitkit profiles on both sides, Pay Contacts enabled, contacts added/resolved both ways, Alice paid Bob from Send -> Contact, and Bob's received activity was assigned to Alice with the contact chip + Detach action. I also checked the app logs/DB for the run: no no-endpoint/public-fallback/private-unavailable/send-failure markers, and both latest activity rows have the expected contact keys.

@piotr-iohk

Copy link
Copy Markdown
Collaborator

Manual regression retest (Jul 7)

Environment: regtest, staging
PRs:bitkit-android #1040 · bitkit-ios #606
Build:codex/paykit-sdk-native-integration, Paykit v0.1.0-rc23

Logs:

Cross-platform pair: Android ↔ iOS sim. Same flow as prior retests (Jul 1 / Jul 3) plus PR QA checklist from #1040.


PR QA checklist

#TestAndroidiOS
1Create/edit profile → add contact → contact survives restart
2Send → Contact → pay (private first, public fallback ok)
3Backup/restore wallet with Pubky → pay contact
4Settings → Payment Preference → toggle public/private
5Sign out / delete / disconnect — cleanup then local state cleared

Session flow (regression focus)

StepAndroidiOS
Session 1 — fresh profiles, contacts, LN + on-chain
Session 1 — private contact payments
Delete → re-create (same pubky, 409 → sign-in) → re-add contact
Session 2 — LN + on-chain after reset
Session 2 — private contact payments (incl. “Received from [contact]”)
Second profile delete in same session

Jul 3 blockers — status in this run:

  • RestoreReplayError / encrypted-link recovery after profile reset → not seen (fixed)
  • Profile delete blocked by PrivateUnavailablenot seen (still fixed)

Log support: multiple PaymentSuccessful / Lightning payment successful on both sides; iOS setContact after incoming payments in session 1 and session 2; Deleted all contacts on both platforms without Failed to delete profile.


Known issue — deferred (Android only)

Pubky Ring profile import on Android fails after Ring returns auth success:

Received Pubky Ring auth success callback
Auth approval failed: code=identity_error, context=complete Pubky auth flow
Screenshot 2026-07-07 at 13 56 09

UI: “Authorization Failed” toast on Join the Pubky Web screen (Import with Pubky Ring).

iOS: Ring import works (Pubky auth completed for pubkyc97…).

Agreed with @ben-kaufman on Slack to merge without blocking on this — Android Ring import tracked as follow-up, not a Paykit SDK regression.


Verdict

ScopeResult
Paykit SDK integration — contact payments, profile lifecycle, backup/restore✅ Pass
Private Paykit recovery after profile delete/re-add (Jul 3 regression)✅ Pass
Profile delete when private cleanup flaky✅ Pass
Android Pubky Ring import❌ Deferred (Android-only, post-merge)

LGTM on #1040 / #606 for merge, modulo deferred Android Ring import.

piotr-iohk
piotr-iohk previously approved these changes Jul 7, 2026

@piotr-iohkpiotr-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.

tACK

@jvsena42jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approved except for one comment that worth addressing

Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt Outdated
@ben-kaufman

ben-kaufman commented Jul 8, 2026

Copy link
Copy Markdown
ContributorAuthor

@jvsena42 Fixed in 0c0dd99. Ring auth completion now returns a failed Result if the auth attempt is canceled/superseded while waiting for approval, instead of throwing or waiting forever. Also cleaned up the Keychain runBlocking nits from the review.

@jvsena42
jvsena42 enabled auto-merge July 8, 2026 13:57
@jvsena42
jvsena42 merged commit b3212d6 into masterJul 8, 2026
31 of 33 checks passed
@jvsena42
jvsena42 deleted the codex/paykit-sdk-native-integration branch July 8, 2026 18:06
@piotr-iohkpiotr-iohk mentioned this pull request Jul 21, 2026
5 tasks
Sign up for freeto 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.

5 participants

@ben-kaufman@piotr-iohk@jvsena42@ovitrif@Jasonvdb
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

refactor: integrate paykit sdk - #1040

Merged
jvsena42 merged 24 commits into
masterfrom
codex/paykit-sdk-native-integration
Jul 8, 2026
Merged

refactor: integrate paykit sdk#1040
jvsena42 merged 24 commits into
masterfrom
codex/paykit-sdk-native-integration

Conversation

@ben-kaufman

@ben-kaufmanben-kaufman commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

This PR:

  1. Replaces Bitkit's custom Paykit private/public payment plumbing with the native Paykit SDK.
  2. Moves Pubky profile, contact, public endpoint, private endpoint, and SDK backup state handling through SDK APIs.
  3. Keeps Bitkit responsible for wallet execution, payment-request mapping, contact attribution, endpoint rotation, and public fallback behavior.
  4. Pins Paykit to the published com.synonym:paykit-android:0.1.0-rc23 artifact.
  5. Adds hardening for auth approval capabilities, canceled Ring auth cleanup, profile label fallback, pending private drain retries, and best-effort profile delete/sign-out cleanup.

Description

  • Adds a Paykit SDK service wrapper for session bootstrap, Ring auth, profile/avatar publishing, contact records, public endpoint sync, private payment list sync, and SDK backup state import/export.
  • Refactors public and private Paykit repositories to resolve and publish payment endpoints through SDK APIs while preserving Bitkit's existing endpoint preference order and local payability checks.
  • Moves private contact link and recovery state into the SDK backup string, while keeping Bitkit-owned address reservations and payment attribution in app storage.
  • Updates Pubky profile/contact loading, profile edits, sign-out/delete cleanup, backup/restore, and wallet wipe flows for the SDK-backed state model.

Preview

N/A - SDK integration; no UI layout change.

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.
  • 5. Profile -> Sign Out / Delete Profile / Disconnect Profile: remote endpoint cleanup runs first, then local Pubky and SDK state clear on success.

Automated Checks

  • ./gradlew compileDevDebugKotlin passed.
  • ./gradlew testDevDebugUnitTest passed.
  • ./gradlew testDevDebugUnitTest --tests to.bitkit.repositories.PrivatePaykitRepoTest passed.
  • ./gradlew detekt passed.
  • git diff --check passed.

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

greptile-appsBot commented Jun 24, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces Bitkit's hand-rolled Paykit plumbing with the published com.synonym:paykit-android:0.1.0-rc21 SDK, removing ~2,200 lines of custom link/handshake/recovery state machine code and delegating session, profile, contact, private-payment-list, and backup-state management to native SDK APIs. Wallet execution logic, public-endpoint fallback, Ring/public-only handling, contact attribution, and receiving-detail rotation remain in Bitkit.

  • PaykitSdkService (713 lines, new): wraps PaykitSdk behind operationMutex, implements SdkStateBlobStore (CAS-style revision check against the keychain) and SdkPubkySessionProvider, exposes backup-state versioning via withStateRevisionTracking.
  • PrivatePaykitRepo / PubkyRepo: substantially slimmed by delegating link/handshake work to the SDK; contact profile overrides and paykitSdkBackupState replace the previous PrivatePaykitContactLinkBackupV1 map in wallet backups.
  • Backup migration: old privatePaykitContactLinks data is silently discarded when restoring pre-SDK backups; existing contact-link sessions are not migrated to the new SDK state format.

Confidence Score: 4/5

The core payment flow and session lifecycle look structurally sound; the main risks are edge cases in the new blocking-inside-synchronized SDK state store and empty contact names when the SDK returns a profile with no display data.

The architectural shift is large but well-scoped: the SDK takes over state management that was previously hand-coded, and the delegation boundary is clear. The new PaykitSdkStateBlobStore uses runBlocking(ioDispatcher) inside a synchronized block — not a deadlock under normal load but a thread-starvation risk under sustained IO pressure. PaykitSdkSessionProvider.clearSessionAccess() uses a bare runBlocking {} without a dispatcher, which could misbehave if called from an unusual thread context. The backup restore path for legacy (pre-SDK) backups silently swallows SDK state-clearing errors. The contact-name-empty edge case is a UI regression when the SDK's profile record lacks both displayName and decodable extraJson. None of these are showstoppers, but the blocking-coroutine nesting deserves attention before shipping to broad audiences.

PaykitSdkService.kt (the PaykitSdkStateBlobStore and PaykitSdkSessionProvider inner classes), BackupRepo.kt (legacy restore path around line 619), and PubkyRepo.kt (contactProfile method).

Important Files Changed

FilenameOverview
app/src/main/java/to/bitkit/services/PaykitSdkService.ktNew singleton service wrapping the Paykit SDK; mixes runBlocking inside a synchronized block (saveStateBlobAtomically) and has a bare runBlocking in PaykitSdkSessionProvider.clearSessionAccess().
app/src/main/java/to/bitkit/data/keychain/Keychain.ktAdds a new synchronous upsert(ByteArray) method using runBlocking(this.coroutineContext); consistent with the existing snapshot pattern but called from a synchronized block, risking thread starvation under IO saturation.
app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.ktSubstantially trimmed by delegating link/handshake/recovery state to the SDK; backup snapshot now delegates to PaykitSdkService.exportBackupState(); logic looks correct.
app/src/main/java/to/bitkit/repositories/PubkyRepo.ktDelegates session/profile/contact operations to PaykitSdkService; introduces contactProfileOverrides in PubkyStore and snapshotContactProfileOverrides/restoreContactProfileOverrides for backup; contact name may be empty when paykitProfile has no displayName and no extraJson.
app/src/main/java/to/bitkit/repositories/BackupRepo.ktBackup listeners refactored to observeBackupChanges helper; wallet restore silently swallows SDK state-clearing errors for legacy backups (null paykitSdkBackupState).
app/src/main/java/to/bitkit/services/PubkyService.ktThin wrapper now fully delegates to PaykitSdkService; straightforward and correct.
gradle/libs.versions.tomlBumps paykit-android from rc8 to rc21; no other dependency changes.
app/src/main/java/to/bitkit/models/BackupPayloads.ktReplaces PrivatePaykitContactLinkBackupV1 map with a single paykitSdkBackupState string and adds pubkyContactProfileOverrides; old backup fields removed with no migration path for existing contact-link data.
app/src/main/java/to/bitkit/models/PubkyProfile.ktAdapts to SDK PubkyProfile/PaykitProfile types; fromPaykitProfile may produce an empty contact name if displayName and extraJson are both absent.
app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.ktWipe sequence unchanged in substance; closeAndClear() now delegates SDK state clearing, then keychain.wipe() removes all persisted state.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant App as App/UI
participant PPR as PrivatePaykitRepo
participant SDK as PaykitSdkService
participant PaykitSdk as PaykitSdk (native)
participant Keychain as Keychain
participant BR as BackupRepo
App->>PPR: prepareSavedContacts(publicKeys)
PPR->>SDK: ensureLinkWithPeer(counterparty)
SDK->>PaykitSdk: ensureLinkWithPeer()
PaykitSdk->>Keychain: saveStateBlobAtomically() [synchronized + runBlocking]
SDK->>BR: backupStateVersion++ (via withStateRevisionTracking)
PPR->>SDK: syncPrivatePaymentListsWithReservations(updates)
SDK->>PaykitSdk: syncPrivatePaymentListsWithReservationsAndProcessOutbound()
PaykitSdk->>Keychain: saveStateBlobAtomically()
SDK->>BR: backupStateVersion++
App->>PPR: beginSavedContactPayment(publicKey)
PPR->>SDK: prepareAndResolveContactPayment(counterparty)
SDK->>PaykitSdk: prepareAndResolveContactPayment()
PaykitSdk-->>SDK: ContactPaymentResolution
SDK-->>PPR: PaykitContactPaymentResolution
PPR-->>App: PublicPaykitPaymentResult
BR->>PPR: backupSnapshot()
PPR->>SDK: exportBackupState()
SDK->>PaykitSdk: exportBackupString()
PaykitSdk-->>SDK: String (opaque blob)
SDK-->>BR: paykitSdkBackupState
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 App as App/UI
participant PPR as PrivatePaykitRepo
participant SDK as PaykitSdkService
participant PaykitSdk as PaykitSdk (native)
participant Keychain as Keychain
participant BR as BackupRepo
App->>PPR: prepareSavedContacts(publicKeys)
PPR->>SDK: ensureLinkWithPeer(counterparty)
SDK->>PaykitSdk: ensureLinkWithPeer()
PaykitSdk->>Keychain: saveStateBlobAtomically() [synchronized + runBlocking]
SDK->>BR: backupStateVersion++ (via withStateRevisionTracking)
PPR->>SDK: syncPrivatePaymentListsWithReservations(updates)
SDK->>PaykitSdk: syncPrivatePaymentListsWithReservationsAndProcessOutbound()
PaykitSdk->>Keychain: saveStateBlobAtomically()
SDK->>BR: backupStateVersion++
App->>PPR: beginSavedContactPayment(publicKey)
PPR->>SDK: prepareAndResolveContactPayment(counterparty)
SDK->>PaykitSdk: prepareAndResolveContactPayment()
PaykitSdk-->>SDK: ContactPaymentResolution
SDK-->>PPR: PaykitContactPaymentResolution
PPR-->>App: PublicPaykitPaymentResult
BR->>PPR: backupSnapshot()
PPR->>SDK: exportBackupState()
SDK->>PaykitSdk: exportBackupString()
PaykitSdk-->>SDK: String (opaque blob)
SDK-->>BR: paykitSdkBackupState
Loading

Comments Outside Diff (1)

  1. app/src/main/java/to/bitkit/repositories/BackupRepo.kt, line 619-628 (link)

    P2SDK state-clear failure silently ignored during legacy backup restore

    When paykitSdkBackupState is null (restoring a backup created before this PR), privateRepo.restoreBackup(null) is called and any failure is only logged via onFailure { Logger.warn(...) } — execution continues regardless. Inside restoreBackup(null), paykitSdkService.clearState() deletes the PAYKIT_SDK_STATE keychain entry. If this deletion fails (e.g., keystore error), the stale SDK state persists while the rest of the wallet is restored from the new backup, leaving contact-link and session state out of sync with the freshly restored wallet. The successful path (paykitSdkBackupState != null) uses .getOrThrow() — the legacy path should follow the same convention or at least propagate the failure to surface the inconsistency.

Reviews (1): Last reviewed commit: "fix: preserve paykit cancellation" | Re-trigger Greptile

Comment threadapp/src/main/java/to/bitkit/services/PaykitSdkService.kt
Comment threadapp/src/main/java/to/bitkit/services/PaykitSdkService.kt
Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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:8202a59774

ℹ️ 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 threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt
Comment threadapp/src/main/java/to/bitkit/services/PaykitSdkService.kt Outdated
Comment threadapp/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt Outdated
@ben-kaufman

Copy link
Copy Markdown
ContributorAuthor

For the legacy backup migration note: this is intentional for this PR. The old private Paykit link backup format never shipped, so there is no production data to migrate. Treating it as if it never existed keeps the restore path simpler.

@ovitrifovitrif 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.

Left one inline comment.

Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@piotr-iohk

Copy link
Copy Markdown
Collaborator

That is not necessarily due to this change, because I saw it on other PR also - however e2e tests here failed partially because of this. The failure is intermittent and most of the time tests pass after re-runs.

To reproduce:

  • create a profile.
  • delete profile
  • recreate profile

Result after hitting "Continue" on the following screen:
Screenshot 2026-06-25 at 14 03 04

Attaching logs from e2e run where this happened:
bitkit_2026-06-24_17-37-36.log
logcat.txt

@ovitrifovitrif added this to the 2.5.0 milestone Jun 25, 2026
@ben-kaufmanChatGPT Codex Connector

Copy link
Copy Markdown
ContributorAuthor

Fixed now in 041548681.

Root cause was Android public Paykit publishing only refreshed the reusable on-chain address if the cached address was already reserved/unavailable. In the delete profile -> recreate profile flow, Lightning receive could be unavailable and the cached reusable on-chain address could still be blank, so endpoint sync concluded there were no supported endpoints and showed the toast.

I changed public Paykit endpoint sync to ensure a reusable on-chain address exists before deciding there is no publishable endpoint, and added regression coverage for the blank-address case. Also merged latest master and resolved the version-catalog conflict by keeping bitkit-core 0.1.75 from master plus Paykit 0.1.0-rc21 from this PR.

Checked:

  • ./gradlew testDevDebugUnitTest --tests to.bitkit.repositories.PublicPaykitRepoTest --tests to.bitkit.repositories.WalletRepoTest
  • ./gradlew compileDevDebugKotlin
  • ./gradlew detekt
  • git diff --check

GitHub now reports the PR as mergeable.

@jvsena42
jvsena42 self-requested a review July 1, 2026 12:46
@jvsena42

jvsena42 commented Jul 1, 2026

Copy link
Copy Markdown
Member

⚠️ Ring sign-in crashes: there is no reactor running, must be called from the context of a Tokio 1.x runtime

Reproduced when tapping "Sign in with Pubky Ring":

Screen_recording_20260701_095809.webm
ERROR [PubkyChoiceViewModel.kt:101] Starting Ring auth failed
[AppError='there is no reactor running, must be called from the context of a Tokio 1.x runtime']

Call chain

PubkyChoiceViewModel.startRingAuth()
→ PubkyRepo.startAuthentication() (PubkyRepo.kt:268)
→ PubkyService.startAuth() (PubkyService.kt:88)
→ PaykitSdkService.startAuth() (PaykitSdkService.kt:201)
→ PubkySessionBootstrap().startSignInAuth(...) ← panics here

Root cause (SDK binding, not app code)

Decompiled paykit-android:0.1.0-rc21 to confirm:

  • startSignInAuth / startSignUpAuth / resumeAuth are exported as synchronous FFI calls (uniffiRustCallWithError). UniFFI does not enter a Tokio runtime around blocking calls.
  • The bootstrap functions we use elsewhere — signIn, signUp, importSession, complete, approveAuth — are suspend, driven through UniFFI's async scaffolding on the SDK's Tokio runtime, so a reactor is present.

The Rust impl of startSignInAuth needs a Tokio reactor (builds the relay/network client for the Ring flow), but because it's a blocking export it runs on our core-queue thread with no runtime entered → panic. Pure-crypto sync functions in the same SDK (derivePubkySecretKey, pubkyPublicKeyFromSecret, parsePubkyAuthUrl) work fine because they touch no reactor.

The Ring startSignInAuth API did not exist in rc8 — it's new in rc21.

No clean app-side fix

Kotlin can't enter a Tokio reactor for a blocking UniFFI call, and there is no suspend alternative for starting the flow (only sync startSignInAuth/startSignUpAuth/resumeAuth exist), so withContext(ioDispatcher) / ServiceQueue.CORE don't help.

Fix belongs in paykit-rs: export the start-auth bootstrap functions as async, or have the Rust side enter/hold a runtime (Handle::enter()) inside them. Also worth checking whether a newer paykit-android rc already makes these async before pinning.

@jvsena42jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@piotr-iohk

Copy link
Copy Markdown
Collaborator

Manual regression — Paykit / contact payments

Environment: regtest, staging
Pair tested: Android (pubkyraoz…) ↔ iOS (pubkytrb4ja…)
Logs attached:
ios: bitkit_logs_2026-07-01_13-20-03.zip
android: logs.zip


Test setup

DevicePlatformProfile (pubky)LN node ID
AAndroidpubkyraozwuopbt5pa3e8ki4kqeec8rmw7giruqicw53zehk3uef71agy02f2dc5c…
BiOSpubkytrb4ja4aorm19xsiouw5hmq6ecfp1xprbdkh8x9jqe9edmrwtz1o021714b0…

Session 1 — fresh profiles (smoke)

TestAndroidiOS
Create Pubky profile
Paykit session / identity
Add contact (scan pubky)
Open LN channel (Blocktank)
On-chain send✅ (9a042478…)
LN send to/from contact
Activity sync
Incoming activity shows “Received from [contact]”
RestoreReplayError in logsNot seenNot seen

Session 1 looked good for basic contact + payment flows cross-platform.

Private Paykit in session 1: Incoming activity showing “Received from [contact]” indicates the receive path worked — that label is only set when the payment matches a private Paykit invoice/address (not a generic public profile invoice). There are no private Paykit link errors in session 1 logs on either platform. Send-side logs showing Handling decoded scan data: OnChain(…?lightning=lnbcrt1…) do not by themselves prove public vs private; that is how the send flow represents the payment request.


Session 2 — profile delete, re-create, re-add contacts, second delete blocked

StepAndroidiOS
Delete profile (1st)✅ ~12:23✅ ~12:22 (Deleted all contacts, keychain cleared)
Re-create profile (same pubky key)✅ 409 → sign-in retry✅ 409 → sign-in retry
Re-add contact✅ ~12:26
Contact LN send A → B
Contact LN send B → A
Incoming activity shows “Received from [contact]”
Private Paykit link (no RestoreReplayError)
Delete profile again (2nd attempt)❌ ~13:17–13:18❌ ~13:17 UTC
2nd delete error“Private Paykit is not available.”“Private Paykit is not available.”

Delete profile:

Screen.Recording.2026-07-01.at.15.18.17.mov

Regression — private Paykit broken after profile reset

Session 1: Private Paykit appears to work (receive-side “Received from contact” + no link errors).
Session 2: After deleting/re-creating profiles (same pubky keys) and re-adding contacts, contact LN sends still succeed but private Paykit does not recover. Incoming activity no longer shows “Received from [contact]” — consistent with payments hitting public endpoints instead of private ones. Public fallback is by design (includePublicEndpoints = true); no in-app warning is expected for payments.

Later in the same session, a second profile delete also failed on both platforms — private Paykit cleanup runs before delete and throws PrivateUnavailable, blocking sign-out entirely.

Private Paykit errors (identical on both platforms)

Every private Paykit attempt (prepare, channel usable / refresh, foreground, contact payment) logs:

Failed to prepare private Paykit link for '<contact>'
→ RestoreReplayError: pubky-noise handshake restore failed
Failed to queue private Paykit endpoints …
→ Encrypted Link recovery is required for counterparty <pubky-id>
Deferred private Paykit endpoint publish / Private Paykit is not available

First failures appear immediately after profile re-create (~12:23 iOS, ~12:26 Android on contact re-add).

Contact payments fall back to public

Payments use a public BIP21 unified invoice from the contact’s published profile — not an encrypted private payment list:

  • Shared public address in logs: bcrt1q2h4c7ghs2lj3glrm77mxdae3w2r5h6f3ph258l?lightning=lnbcrt1…
  • Android (AppViewModel): Handling decoded scan data: OnChain(… params={lightning=lnbcrt1…})PaymentSuccessful
  • iOS (LightningService / SendConfirmationView): Paying bolt11: lnbcrt1…Lightning payment successful

Second profile delete blocked

Profile delete runs private Paykit endpoint cleanup first. With private Paykit already broken, cleanup throws PrivateUnavailable and delete aborts before homeserver sign-out.

Android (EditProfileViewModelPrivatePaykitRepo.removePublishedEndpointsForCleanup):

Failed to remove private Paykit endpoints during 'EditProfileViewModel'
[PrivateUnavailable='Private Paykit is not available']

iOS (PubkyProfileManager.deleteProfileremovePrivatePaykitEndpoints):

Failed to remove private Paykit endpoints before clearing session: privateUnavailable
ERROR Failed to delete profile: privateUnavailable - EditProfileView

Profile reset sequence (both sides)

  1. Profile delete → contacts removed, PAYKIT_SESSION / PAYKIT_SDK_STATE cleared
  2. Re-create → homeserver returns 409 User already exists → app signs in with existing key (same pubky identity)
  3. Public Paykit endpoints sync; no successful private encrypted-link handshake in logs
  4. After re-adding contact, RestoreReplayError persists through contact payments
  5. Second delete attempt fails — user stuck unless disconnect/retry workaround is used

Likely cause: local Paykit SDK state is wiped on delete/re-create, but encrypted-link handshake state is inconsistent across peers. SDK reports recovery is required; the app logs warnings, skips private publish, and resolves contact payments via public endpoints (intentional fallback).

Useful grep patterns:RestoreReplayError, Encrypted Link recovery, PrivateUnavailable, Failed to delete profile, Handling decoded scan data: OnChain


Verdict

ScopeResult
Session 1 — fresh profiles: contacts, on-chain + LN, private receive (“Received from contact”)✅ Pass (smoke)
Session 2 — profile reset: contact payments work (public fallback)✅ By design
Session 2 — private Paykit restored; “Received from contact” on receiveRegression
Session 2 — second profile delete blocked (PrivateUnavailable)Regression

Not approving on “private contact payments survive profile delete/re-add.” Session 1 private Paykit looks fine; session 2 regresses on private Paykit recovery and blocks a second profile delete.

@ben-kaufman

ben-kaufman commented Jul 2, 2026

Copy link
Copy Markdown
ContributorAuthor

Fixed in 82bb55cf6 on Android and 51b7c2ce on iOS.

Main thing is we now use Paykit v0.1.0-rc23, which includes the SDK fix for the stale recovery-required encrypted-link state after deleting/recreating a profile. It also fixes the Ring startSignInAuth Tokio runtime crash, so Android is pinned to rc23 now too.

I also fixed the related app-side edges:

  • sign out/delete no longer get blocked if private cleanup is temporarily unavailable
  • pending private drain retries now keep all queued peers instead of replacing older ones
  • auth approval uses the capabilities from the actual auth URL
  • if Ring auth completes but the app flow is canceled/superseded, we clear that session
  • blank SDK profile names fall back to the saved contact label

Public fallback while private recovery/link work is unavailable is still intentional so contact payments can still complete. Ring is still public-only for now; this fixes the crash path, not full Ring private payments support.

Comment threadapp/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt Outdated
Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt Outdated
@piotr-iohk

Copy link
Copy Markdown
Collaborator

@ben-kaufman is pubky-ring option disabled?
Gating_no_profile_pubky_profile_1_-_Contactsprofile_entry_points_lead_to_choice_screen-2026-07-02T10-13-40-607Z

@ben-kaufman

Copy link
Copy Markdown
ContributorAuthor

@piotr-iohk Added it back for now, but we will likely remove it, still waiting for final decision on that...

@piotr-iohk

Copy link
Copy Markdown
Collaborator

@piotr-iohk Added it back for now, but we will likely remove it, still waiting for final decision on that...

OK, atm clicking at Import with Pubky ring results in error toast. Not sure then if we want to resolve that or just leave for now? that is on both iOS and Android

Screen.Recording.2026-07-03.at.12.44.46.mov

@piotr-iohk

Copy link
Copy Markdown
Collaborator

Manual regression retest (Jul 3, post rc23)

Environment: regtest, staging
PRs:bitkit-android #1040 · bitkit-ios #606
Build:codex/paykit-sdk-native-integration, Paykit v0.1.0-rc23

Logs:

Same flow as Jul 1: create profiles → add contacts → LN + on-chain (verify private) → delete → re-create (same pubky) → re-add → LN + on-chain → delete again.


Results

StepAndroidiOS
Session 1 — profiles, contacts, LN + on-chain
Session 1 — private receive (“Received from [contact]”)
Session 1 — RestoreReplayError in logsNot seenNot seen
Delete → re-create → re-add contact
Session 2 — LN + on-chain (payments complete)
Session 2 — private Paykit / “Received from [contact]”
Session 2 — RestoreReplayError after re-add
Second profile delete (while private Paykit broken)

Session 2 — private Paykit still broken after profile reset

After delete/re-create/re-add, private link fails again on both platforms:

RestoreReplayError: failed to restore Encrypted Link handshake
Encrypted Link recovery is required for counterparty …
Private Paykit is not available (deferred publish)

Contact payments still complete via public fallback (by design). On Android, post-reset sends resolve to public BIP21 bcrt1qd8yaa9mwfcr5wwqyd999wmuj2vpyfs4s5emuy4?lightning=… after RestoreReplayError on the contact payment path — same pattern as Jul 1. UI: no “Received from [contact]” on incoming activity.

First failures after re-add: ~10:52 Android, ~10:52 UTC iOS.


Fixed since Jul 1 — profile delete no longer blocked

Second delete succeeds even when private cleanup fails. Logs show PrivateUnavailable warnings during cleanup, but noFailed to delete profile: privateUnavailable (iOS) and profile/session clears (Deleted all contacts, PAYKIT_SESSION removed). Jul 1 blocker is resolved.


Verdict

ScopeResult
Session 1 smoke (private contact payments)✅ Pass
Public fallback when private unavailable✅ By design
Private Paykit recovery after profile delete/re-addStill failing (rc23 did not fix this in manual test)
Profile delete when private cleanup failsFixed

Not approving on “private contact payments survive profile delete/re-add.” Happy to re-test after another SDK/app fix; delete trap fix looks good.

Useful grep patterns:RestoreReplayError, Encrypted Link recovery, PrivateUnavailable, Handling decoded scan data: OnChain, Deleted all contacts

@ben-kaufman

Copy link
Copy Markdown
ContributorAuthor

Fixed and tested now. I reran the Android rc26 E2E with two fresh dev installs: Bitkit profiles on both sides, Pay Contacts enabled, contacts added/resolved both ways, Alice paid Bob from Send -> Contact, and Bob's received activity was assigned to Alice with the contact chip + Detach action. I also checked the app logs/DB for the run: no no-endpoint/public-fallback/private-unavailable/send-failure markers, and both latest activity rows have the expected contact keys.

@piotr-iohk

Copy link
Copy Markdown
Collaborator

Manual regression retest (Jul 7)

Environment: regtest, staging
PRs:bitkit-android #1040 · bitkit-ios #606
Build:codex/paykit-sdk-native-integration, Paykit v0.1.0-rc23

Logs:

Cross-platform pair: Android ↔ iOS sim. Same flow as prior retests (Jul 1 / Jul 3) plus PR QA checklist from #1040.


PR QA checklist

#TestAndroidiOS
1Create/edit profile → add contact → contact survives restart
2Send → Contact → pay (private first, public fallback ok)
3Backup/restore wallet with Pubky → pay contact
4Settings → Payment Preference → toggle public/private
5Sign out / delete / disconnect — cleanup then local state cleared

Session flow (regression focus)

StepAndroidiOS
Session 1 — fresh profiles, contacts, LN + on-chain
Session 1 — private contact payments
Delete → re-create (same pubky, 409 → sign-in) → re-add contact
Session 2 — LN + on-chain after reset
Session 2 — private contact payments (incl. “Received from [contact]”)
Second profile delete in same session

Jul 3 blockers — status in this run:

  • RestoreReplayError / encrypted-link recovery after profile reset → not seen (fixed)
  • Profile delete blocked by PrivateUnavailablenot seen (still fixed)

Log support: multiple PaymentSuccessful / Lightning payment successful on both sides; iOS setContact after incoming payments in session 1 and session 2; Deleted all contacts on both platforms without Failed to delete profile.


Known issue — deferred (Android only)

Pubky Ring profile import on Android fails after Ring returns auth success:

Received Pubky Ring auth success callback
Auth approval failed: code=identity_error, context=complete Pubky auth flow
Screenshot 2026-07-07 at 13 56 09

UI: “Authorization Failed” toast on Join the Pubky Web screen (Import with Pubky Ring).

iOS: Ring import works (Pubky auth completed for pubkyc97…).

Agreed with @ben-kaufman on Slack to merge without blocking on this — Android Ring import tracked as follow-up, not a Paykit SDK regression.


Verdict

ScopeResult
Paykit SDK integration — contact payments, profile lifecycle, backup/restore✅ Pass
Private Paykit recovery after profile delete/re-add (Jul 3 regression)✅ Pass
Profile delete when private cleanup flaky✅ Pass
Android Pubky Ring import❌ Deferred (Android-only, post-merge)

LGTM on #1040 / #606 for merge, modulo deferred Android Ring import.

piotr-iohk
piotr-iohk previously approved these changes Jul 7, 2026

@piotr-iohkpiotr-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.

tACK

@jvsena42jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approved except for one comment that worth addressing

Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt Outdated
@ben-kaufman

ben-kaufman commented Jul 8, 2026

Copy link
Copy Markdown
ContributorAuthor

@jvsena42 Fixed in 0c0dd99. Ring auth completion now returns a failed Result if the auth attempt is canceled/superseded while waiting for approval, instead of throwing or waiting forever. Also cleaned up the Keychain runBlocking nits from the review.

@jvsena42
jvsena42 enabled auto-merge July 8, 2026 13:57
@jvsena42
jvsena42 merged commit b3212d6 into masterJul 8, 2026
31 of 33 checks passed
@jvsena42
jvsena42 deleted the codex/paykit-sdk-native-integration branch July 8, 2026 18:06
@piotr-iohkpiotr-iohk mentioned this pull request Jul 21, 2026
5 tasks
Sign up for freeto 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.

5 participants

@ben-kaufman@piotr-iohk@jvsena42@ovitrif@Jasonvdb
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

refactor: integrate paykit sdk - #1040

Merged
jvsena42 merged 24 commits into
masterfrom
codex/paykit-sdk-native-integration
Jul 8, 2026
Merged

refactor: integrate paykit sdk#1040
jvsena42 merged 24 commits into
masterfrom
codex/paykit-sdk-native-integration

Conversation

@ben-kaufman

@ben-kaufmanben-kaufman commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

This PR:

  1. Replaces Bitkit's custom Paykit private/public payment plumbing with the native Paykit SDK.
  2. Moves Pubky profile, contact, public endpoint, private endpoint, and SDK backup state handling through SDK APIs.
  3. Keeps Bitkit responsible for wallet execution, payment-request mapping, contact attribution, endpoint rotation, and public fallback behavior.
  4. Pins Paykit to the published com.synonym:paykit-android:0.1.0-rc23 artifact.
  5. Adds hardening for auth approval capabilities, canceled Ring auth cleanup, profile label fallback, pending private drain retries, and best-effort profile delete/sign-out cleanup.

Description

  • Adds a Paykit SDK service wrapper for session bootstrap, Ring auth, profile/avatar publishing, contact records, public endpoint sync, private payment list sync, and SDK backup state import/export.
  • Refactors public and private Paykit repositories to resolve and publish payment endpoints through SDK APIs while preserving Bitkit's existing endpoint preference order and local payability checks.
  • Moves private contact link and recovery state into the SDK backup string, while keeping Bitkit-owned address reservations and payment attribution in app storage.
  • Updates Pubky profile/contact loading, profile edits, sign-out/delete cleanup, backup/restore, and wallet wipe flows for the SDK-backed state model.

Preview

N/A - SDK integration; no UI layout change.

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.
  • 5. Profile -> Sign Out / Delete Profile / Disconnect Profile: remote endpoint cleanup runs first, then local Pubky and SDK state clear on success.

Automated Checks

  • ./gradlew compileDevDebugKotlin passed.
  • ./gradlew testDevDebugUnitTest passed.
  • ./gradlew testDevDebugUnitTest --tests to.bitkit.repositories.PrivatePaykitRepoTest passed.
  • ./gradlew detekt passed.
  • git diff --check passed.

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

greptile-appsBot commented Jun 24, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces Bitkit's hand-rolled Paykit plumbing with the published com.synonym:paykit-android:0.1.0-rc21 SDK, removing ~2,200 lines of custom link/handshake/recovery state machine code and delegating session, profile, contact, private-payment-list, and backup-state management to native SDK APIs. Wallet execution logic, public-endpoint fallback, Ring/public-only handling, contact attribution, and receiving-detail rotation remain in Bitkit.

  • PaykitSdkService (713 lines, new): wraps PaykitSdk behind operationMutex, implements SdkStateBlobStore (CAS-style revision check against the keychain) and SdkPubkySessionProvider, exposes backup-state versioning via withStateRevisionTracking.
  • PrivatePaykitRepo / PubkyRepo: substantially slimmed by delegating link/handshake work to the SDK; contact profile overrides and paykitSdkBackupState replace the previous PrivatePaykitContactLinkBackupV1 map in wallet backups.
  • Backup migration: old privatePaykitContactLinks data is silently discarded when restoring pre-SDK backups; existing contact-link sessions are not migrated to the new SDK state format.

Confidence Score: 4/5

The core payment flow and session lifecycle look structurally sound; the main risks are edge cases in the new blocking-inside-synchronized SDK state store and empty contact names when the SDK returns a profile with no display data.

The architectural shift is large but well-scoped: the SDK takes over state management that was previously hand-coded, and the delegation boundary is clear. The new PaykitSdkStateBlobStore uses runBlocking(ioDispatcher) inside a synchronized block — not a deadlock under normal load but a thread-starvation risk under sustained IO pressure. PaykitSdkSessionProvider.clearSessionAccess() uses a bare runBlocking {} without a dispatcher, which could misbehave if called from an unusual thread context. The backup restore path for legacy (pre-SDK) backups silently swallows SDK state-clearing errors. The contact-name-empty edge case is a UI regression when the SDK's profile record lacks both displayName and decodable extraJson. None of these are showstoppers, but the blocking-coroutine nesting deserves attention before shipping to broad audiences.

PaykitSdkService.kt (the PaykitSdkStateBlobStore and PaykitSdkSessionProvider inner classes), BackupRepo.kt (legacy restore path around line 619), and PubkyRepo.kt (contactProfile method).

Important Files Changed

FilenameOverview
app/src/main/java/to/bitkit/services/PaykitSdkService.ktNew singleton service wrapping the Paykit SDK; mixes runBlocking inside a synchronized block (saveStateBlobAtomically) and has a bare runBlocking in PaykitSdkSessionProvider.clearSessionAccess().
app/src/main/java/to/bitkit/data/keychain/Keychain.ktAdds a new synchronous upsert(ByteArray) method using runBlocking(this.coroutineContext); consistent with the existing snapshot pattern but called from a synchronized block, risking thread starvation under IO saturation.
app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.ktSubstantially trimmed by delegating link/handshake/recovery state to the SDK; backup snapshot now delegates to PaykitSdkService.exportBackupState(); logic looks correct.
app/src/main/java/to/bitkit/repositories/PubkyRepo.ktDelegates session/profile/contact operations to PaykitSdkService; introduces contactProfileOverrides in PubkyStore and snapshotContactProfileOverrides/restoreContactProfileOverrides for backup; contact name may be empty when paykitProfile has no displayName and no extraJson.
app/src/main/java/to/bitkit/repositories/BackupRepo.ktBackup listeners refactored to observeBackupChanges helper; wallet restore silently swallows SDK state-clearing errors for legacy backups (null paykitSdkBackupState).
app/src/main/java/to/bitkit/services/PubkyService.ktThin wrapper now fully delegates to PaykitSdkService; straightforward and correct.
gradle/libs.versions.tomlBumps paykit-android from rc8 to rc21; no other dependency changes.
app/src/main/java/to/bitkit/models/BackupPayloads.ktReplaces PrivatePaykitContactLinkBackupV1 map with a single paykitSdkBackupState string and adds pubkyContactProfileOverrides; old backup fields removed with no migration path for existing contact-link data.
app/src/main/java/to/bitkit/models/PubkyProfile.ktAdapts to SDK PubkyProfile/PaykitProfile types; fromPaykitProfile may produce an empty contact name if displayName and extraJson are both absent.
app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.ktWipe sequence unchanged in substance; closeAndClear() now delegates SDK state clearing, then keychain.wipe() removes all persisted state.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant App as App/UI
participant PPR as PrivatePaykitRepo
participant SDK as PaykitSdkService
participant PaykitSdk as PaykitSdk (native)
participant Keychain as Keychain
participant BR as BackupRepo
App->>PPR: prepareSavedContacts(publicKeys)
PPR->>SDK: ensureLinkWithPeer(counterparty)
SDK->>PaykitSdk: ensureLinkWithPeer()
PaykitSdk->>Keychain: saveStateBlobAtomically() [synchronized + runBlocking]
SDK->>BR: backupStateVersion++ (via withStateRevisionTracking)
PPR->>SDK: syncPrivatePaymentListsWithReservations(updates)
SDK->>PaykitSdk: syncPrivatePaymentListsWithReservationsAndProcessOutbound()
PaykitSdk->>Keychain: saveStateBlobAtomically()
SDK->>BR: backupStateVersion++
App->>PPR: beginSavedContactPayment(publicKey)
PPR->>SDK: prepareAndResolveContactPayment(counterparty)
SDK->>PaykitSdk: prepareAndResolveContactPayment()
PaykitSdk-->>SDK: ContactPaymentResolution
SDK-->>PPR: PaykitContactPaymentResolution
PPR-->>App: PublicPaykitPaymentResult
BR->>PPR: backupSnapshot()
PPR->>SDK: exportBackupState()
SDK->>PaykitSdk: exportBackupString()
PaykitSdk-->>SDK: String (opaque blob)
SDK-->>BR: paykitSdkBackupState
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 App as App/UI
participant PPR as PrivatePaykitRepo
participant SDK as PaykitSdkService
participant PaykitSdk as PaykitSdk (native)
participant Keychain as Keychain
participant BR as BackupRepo
App->>PPR: prepareSavedContacts(publicKeys)
PPR->>SDK: ensureLinkWithPeer(counterparty)
SDK->>PaykitSdk: ensureLinkWithPeer()
PaykitSdk->>Keychain: saveStateBlobAtomically() [synchronized + runBlocking]
SDK->>BR: backupStateVersion++ (via withStateRevisionTracking)
PPR->>SDK: syncPrivatePaymentListsWithReservations(updates)
SDK->>PaykitSdk: syncPrivatePaymentListsWithReservationsAndProcessOutbound()
PaykitSdk->>Keychain: saveStateBlobAtomically()
SDK->>BR: backupStateVersion++
App->>PPR: beginSavedContactPayment(publicKey)
PPR->>SDK: prepareAndResolveContactPayment(counterparty)
SDK->>PaykitSdk: prepareAndResolveContactPayment()
PaykitSdk-->>SDK: ContactPaymentResolution
SDK-->>PPR: PaykitContactPaymentResolution
PPR-->>App: PublicPaykitPaymentResult
BR->>PPR: backupSnapshot()
PPR->>SDK: exportBackupState()
SDK->>PaykitSdk: exportBackupString()
PaykitSdk-->>SDK: String (opaque blob)
SDK-->>BR: paykitSdkBackupState
Loading

Comments Outside Diff (1)

  1. app/src/main/java/to/bitkit/repositories/BackupRepo.kt, line 619-628 (link)

    P2SDK state-clear failure silently ignored during legacy backup restore

    When paykitSdkBackupState is null (restoring a backup created before this PR), privateRepo.restoreBackup(null) is called and any failure is only logged via onFailure { Logger.warn(...) } — execution continues regardless. Inside restoreBackup(null), paykitSdkService.clearState() deletes the PAYKIT_SDK_STATE keychain entry. If this deletion fails (e.g., keystore error), the stale SDK state persists while the rest of the wallet is restored from the new backup, leaving contact-link and session state out of sync with the freshly restored wallet. The successful path (paykitSdkBackupState != null) uses .getOrThrow() — the legacy path should follow the same convention or at least propagate the failure to surface the inconsistency.

Reviews (1): Last reviewed commit: "fix: preserve paykit cancellation" | Re-trigger Greptile

Comment threadapp/src/main/java/to/bitkit/services/PaykitSdkService.kt
Comment threadapp/src/main/java/to/bitkit/services/PaykitSdkService.kt
Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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:8202a59774

ℹ️ 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 threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt
Comment threadapp/src/main/java/to/bitkit/services/PaykitSdkService.kt Outdated
Comment threadapp/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt Outdated
@ben-kaufman

Copy link
Copy Markdown
ContributorAuthor

For the legacy backup migration note: this is intentional for this PR. The old private Paykit link backup format never shipped, so there is no production data to migrate. Treating it as if it never existed keeps the restore path simpler.

@ovitrifovitrif 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.

Left one inline comment.

Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@piotr-iohk

Copy link
Copy Markdown
Collaborator

That is not necessarily due to this change, because I saw it on other PR also - however e2e tests here failed partially because of this. The failure is intermittent and most of the time tests pass after re-runs.

To reproduce:

  • create a profile.
  • delete profile
  • recreate profile

Result after hitting "Continue" on the following screen:
Screenshot 2026-06-25 at 14 03 04

Attaching logs from e2e run where this happened:
bitkit_2026-06-24_17-37-36.log
logcat.txt

@ovitrifovitrif added this to the 2.5.0 milestone Jun 25, 2026
@ben-kaufmanChatGPT Codex Connector

Copy link
Copy Markdown
ContributorAuthor

Fixed now in 041548681.

Root cause was Android public Paykit publishing only refreshed the reusable on-chain address if the cached address was already reserved/unavailable. In the delete profile -> recreate profile flow, Lightning receive could be unavailable and the cached reusable on-chain address could still be blank, so endpoint sync concluded there were no supported endpoints and showed the toast.

I changed public Paykit endpoint sync to ensure a reusable on-chain address exists before deciding there is no publishable endpoint, and added regression coverage for the blank-address case. Also merged latest master and resolved the version-catalog conflict by keeping bitkit-core 0.1.75 from master plus Paykit 0.1.0-rc21 from this PR.

Checked:

  • ./gradlew testDevDebugUnitTest --tests to.bitkit.repositories.PublicPaykitRepoTest --tests to.bitkit.repositories.WalletRepoTest
  • ./gradlew compileDevDebugKotlin
  • ./gradlew detekt
  • git diff --check

GitHub now reports the PR as mergeable.

@jvsena42
jvsena42 self-requested a review July 1, 2026 12:46
@jvsena42

jvsena42 commented Jul 1, 2026

Copy link
Copy Markdown
Member

⚠️ Ring sign-in crashes: there is no reactor running, must be called from the context of a Tokio 1.x runtime

Reproduced when tapping "Sign in with Pubky Ring":

Screen_recording_20260701_095809.webm
ERROR [PubkyChoiceViewModel.kt:101] Starting Ring auth failed
[AppError='there is no reactor running, must be called from the context of a Tokio 1.x runtime']

Call chain

PubkyChoiceViewModel.startRingAuth()
→ PubkyRepo.startAuthentication() (PubkyRepo.kt:268)
→ PubkyService.startAuth() (PubkyService.kt:88)
→ PaykitSdkService.startAuth() (PaykitSdkService.kt:201)
→ PubkySessionBootstrap().startSignInAuth(...) ← panics here

Root cause (SDK binding, not app code)

Decompiled paykit-android:0.1.0-rc21 to confirm:

  • startSignInAuth / startSignUpAuth / resumeAuth are exported as synchronous FFI calls (uniffiRustCallWithError). UniFFI does not enter a Tokio runtime around blocking calls.
  • The bootstrap functions we use elsewhere — signIn, signUp, importSession, complete, approveAuth — are suspend, driven through UniFFI's async scaffolding on the SDK's Tokio runtime, so a reactor is present.

The Rust impl of startSignInAuth needs a Tokio reactor (builds the relay/network client for the Ring flow), but because it's a blocking export it runs on our core-queue thread with no runtime entered → panic. Pure-crypto sync functions in the same SDK (derivePubkySecretKey, pubkyPublicKeyFromSecret, parsePubkyAuthUrl) work fine because they touch no reactor.

The Ring startSignInAuth API did not exist in rc8 — it's new in rc21.

No clean app-side fix

Kotlin can't enter a Tokio reactor for a blocking UniFFI call, and there is no suspend alternative for starting the flow (only sync startSignInAuth/startSignUpAuth/resumeAuth exist), so withContext(ioDispatcher) / ServiceQueue.CORE don't help.

Fix belongs in paykit-rs: export the start-auth bootstrap functions as async, or have the Rust side enter/hold a runtime (Handle::enter()) inside them. Also worth checking whether a newer paykit-android rc already makes these async before pinning.

@jvsena42jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@piotr-iohk

Copy link
Copy Markdown
Collaborator

Manual regression — Paykit / contact payments

Environment: regtest, staging
Pair tested: Android (pubkyraoz…) ↔ iOS (pubkytrb4ja…)
Logs attached:
ios: bitkit_logs_2026-07-01_13-20-03.zip
android: logs.zip


Test setup

DevicePlatformProfile (pubky)LN node ID
AAndroidpubkyraozwuopbt5pa3e8ki4kqeec8rmw7giruqicw53zehk3uef71agy02f2dc5c…
BiOSpubkytrb4ja4aorm19xsiouw5hmq6ecfp1xprbdkh8x9jqe9edmrwtz1o021714b0…

Session 1 — fresh profiles (smoke)

TestAndroidiOS
Create Pubky profile
Paykit session / identity
Add contact (scan pubky)
Open LN channel (Blocktank)
On-chain send✅ (9a042478…)
LN send to/from contact
Activity sync
Incoming activity shows “Received from [contact]”
RestoreReplayError in logsNot seenNot seen

Session 1 looked good for basic contact + payment flows cross-platform.

Private Paykit in session 1: Incoming activity showing “Received from [contact]” indicates the receive path worked — that label is only set when the payment matches a private Paykit invoice/address (not a generic public profile invoice). There are no private Paykit link errors in session 1 logs on either platform. Send-side logs showing Handling decoded scan data: OnChain(…?lightning=lnbcrt1…) do not by themselves prove public vs private; that is how the send flow represents the payment request.


Session 2 — profile delete, re-create, re-add contacts, second delete blocked

StepAndroidiOS
Delete profile (1st)✅ ~12:23✅ ~12:22 (Deleted all contacts, keychain cleared)
Re-create profile (same pubky key)✅ 409 → sign-in retry✅ 409 → sign-in retry
Re-add contact✅ ~12:26
Contact LN send A → B
Contact LN send B → A
Incoming activity shows “Received from [contact]”
Private Paykit link (no RestoreReplayError)
Delete profile again (2nd attempt)❌ ~13:17–13:18❌ ~13:17 UTC
2nd delete error“Private Paykit is not available.”“Private Paykit is not available.”

Delete profile:

Screen.Recording.2026-07-01.at.15.18.17.mov

Regression — private Paykit broken after profile reset

Session 1: Private Paykit appears to work (receive-side “Received from contact” + no link errors).
Session 2: After deleting/re-creating profiles (same pubky keys) and re-adding contacts, contact LN sends still succeed but private Paykit does not recover. Incoming activity no longer shows “Received from [contact]” — consistent with payments hitting public endpoints instead of private ones. Public fallback is by design (includePublicEndpoints = true); no in-app warning is expected for payments.

Later in the same session, a second profile delete also failed on both platforms — private Paykit cleanup runs before delete and throws PrivateUnavailable, blocking sign-out entirely.

Private Paykit errors (identical on both platforms)

Every private Paykit attempt (prepare, channel usable / refresh, foreground, contact payment) logs:

Failed to prepare private Paykit link for '<contact>'
→ RestoreReplayError: pubky-noise handshake restore failed
Failed to queue private Paykit endpoints …
→ Encrypted Link recovery is required for counterparty <pubky-id>
Deferred private Paykit endpoint publish / Private Paykit is not available

First failures appear immediately after profile re-create (~12:23 iOS, ~12:26 Android on contact re-add).

Contact payments fall back to public

Payments use a public BIP21 unified invoice from the contact’s published profile — not an encrypted private payment list:

  • Shared public address in logs: bcrt1q2h4c7ghs2lj3glrm77mxdae3w2r5h6f3ph258l?lightning=lnbcrt1…
  • Android (AppViewModel): Handling decoded scan data: OnChain(… params={lightning=lnbcrt1…})PaymentSuccessful
  • iOS (LightningService / SendConfirmationView): Paying bolt11: lnbcrt1…Lightning payment successful

Second profile delete blocked

Profile delete runs private Paykit endpoint cleanup first. With private Paykit already broken, cleanup throws PrivateUnavailable and delete aborts before homeserver sign-out.

Android (EditProfileViewModelPrivatePaykitRepo.removePublishedEndpointsForCleanup):

Failed to remove private Paykit endpoints during 'EditProfileViewModel'
[PrivateUnavailable='Private Paykit is not available']

iOS (PubkyProfileManager.deleteProfileremovePrivatePaykitEndpoints):

Failed to remove private Paykit endpoints before clearing session: privateUnavailable
ERROR Failed to delete profile: privateUnavailable - EditProfileView

Profile reset sequence (both sides)

  1. Profile delete → contacts removed, PAYKIT_SESSION / PAYKIT_SDK_STATE cleared
  2. Re-create → homeserver returns 409 User already exists → app signs in with existing key (same pubky identity)
  3. Public Paykit endpoints sync; no successful private encrypted-link handshake in logs
  4. After re-adding contact, RestoreReplayError persists through contact payments
  5. Second delete attempt fails — user stuck unless disconnect/retry workaround is used

Likely cause: local Paykit SDK state is wiped on delete/re-create, but encrypted-link handshake state is inconsistent across peers. SDK reports recovery is required; the app logs warnings, skips private publish, and resolves contact payments via public endpoints (intentional fallback).

Useful grep patterns:RestoreReplayError, Encrypted Link recovery, PrivateUnavailable, Failed to delete profile, Handling decoded scan data: OnChain


Verdict

ScopeResult
Session 1 — fresh profiles: contacts, on-chain + LN, private receive (“Received from contact”)✅ Pass (smoke)
Session 2 — profile reset: contact payments work (public fallback)✅ By design
Session 2 — private Paykit restored; “Received from contact” on receiveRegression
Session 2 — second profile delete blocked (PrivateUnavailable)Regression

Not approving on “private contact payments survive profile delete/re-add.” Session 1 private Paykit looks fine; session 2 regresses on private Paykit recovery and blocks a second profile delete.

@ben-kaufman

ben-kaufman commented Jul 2, 2026

Copy link
Copy Markdown
ContributorAuthor

Fixed in 82bb55cf6 on Android and 51b7c2ce on iOS.

Main thing is we now use Paykit v0.1.0-rc23, which includes the SDK fix for the stale recovery-required encrypted-link state after deleting/recreating a profile. It also fixes the Ring startSignInAuth Tokio runtime crash, so Android is pinned to rc23 now too.

I also fixed the related app-side edges:

  • sign out/delete no longer get blocked if private cleanup is temporarily unavailable
  • pending private drain retries now keep all queued peers instead of replacing older ones
  • auth approval uses the capabilities from the actual auth URL
  • if Ring auth completes but the app flow is canceled/superseded, we clear that session
  • blank SDK profile names fall back to the saved contact label

Public fallback while private recovery/link work is unavailable is still intentional so contact payments can still complete. Ring is still public-only for now; this fixes the crash path, not full Ring private payments support.

Comment threadapp/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt Outdated
Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt Outdated
@piotr-iohk

Copy link
Copy Markdown
Collaborator

@ben-kaufman is pubky-ring option disabled?
Gating_no_profile_pubky_profile_1_-_Contactsprofile_entry_points_lead_to_choice_screen-2026-07-02T10-13-40-607Z

@ben-kaufman

Copy link
Copy Markdown
ContributorAuthor

@piotr-iohk Added it back for now, but we will likely remove it, still waiting for final decision on that...

@piotr-iohk

Copy link
Copy Markdown
Collaborator

@piotr-iohk Added it back for now, but we will likely remove it, still waiting for final decision on that...

OK, atm clicking at Import with Pubky ring results in error toast. Not sure then if we want to resolve that or just leave for now? that is on both iOS and Android

Screen.Recording.2026-07-03.at.12.44.46.mov

@piotr-iohk

Copy link
Copy Markdown
Collaborator

Manual regression retest (Jul 3, post rc23)

Environment: regtest, staging
PRs:bitkit-android #1040 · bitkit-ios #606
Build:codex/paykit-sdk-native-integration, Paykit v0.1.0-rc23

Logs:

Same flow as Jul 1: create profiles → add contacts → LN + on-chain (verify private) → delete → re-create (same pubky) → re-add → LN + on-chain → delete again.


Results

StepAndroidiOS
Session 1 — profiles, contacts, LN + on-chain
Session 1 — private receive (“Received from [contact]”)
Session 1 — RestoreReplayError in logsNot seenNot seen
Delete → re-create → re-add contact
Session 2 — LN + on-chain (payments complete)
Session 2 — private Paykit / “Received from [contact]”
Session 2 — RestoreReplayError after re-add
Second profile delete (while private Paykit broken)

Session 2 — private Paykit still broken after profile reset

After delete/re-create/re-add, private link fails again on both platforms:

RestoreReplayError: failed to restore Encrypted Link handshake
Encrypted Link recovery is required for counterparty …
Private Paykit is not available (deferred publish)

Contact payments still complete via public fallback (by design). On Android, post-reset sends resolve to public BIP21 bcrt1qd8yaa9mwfcr5wwqyd999wmuj2vpyfs4s5emuy4?lightning=… after RestoreReplayError on the contact payment path — same pattern as Jul 1. UI: no “Received from [contact]” on incoming activity.

First failures after re-add: ~10:52 Android, ~10:52 UTC iOS.


Fixed since Jul 1 — profile delete no longer blocked

Second delete succeeds even when private cleanup fails. Logs show PrivateUnavailable warnings during cleanup, but noFailed to delete profile: privateUnavailable (iOS) and profile/session clears (Deleted all contacts, PAYKIT_SESSION removed). Jul 1 blocker is resolved.


Verdict

ScopeResult
Session 1 smoke (private contact payments)✅ Pass
Public fallback when private unavailable✅ By design
Private Paykit recovery after profile delete/re-addStill failing (rc23 did not fix this in manual test)
Profile delete when private cleanup failsFixed

Not approving on “private contact payments survive profile delete/re-add.” Happy to re-test after another SDK/app fix; delete trap fix looks good.

Useful grep patterns:RestoreReplayError, Encrypted Link recovery, PrivateUnavailable, Handling decoded scan data: OnChain, Deleted all contacts

@ben-kaufman

Copy link
Copy Markdown
ContributorAuthor

Fixed and tested now. I reran the Android rc26 E2E with two fresh dev installs: Bitkit profiles on both sides, Pay Contacts enabled, contacts added/resolved both ways, Alice paid Bob from Send -> Contact, and Bob's received activity was assigned to Alice with the contact chip + Detach action. I also checked the app logs/DB for the run: no no-endpoint/public-fallback/private-unavailable/send-failure markers, and both latest activity rows have the expected contact keys.

@piotr-iohk

Copy link
Copy Markdown
Collaborator

Manual regression retest (Jul 7)

Environment: regtest, staging
PRs:bitkit-android #1040 · bitkit-ios #606
Build:codex/paykit-sdk-native-integration, Paykit v0.1.0-rc23

Logs:

Cross-platform pair: Android ↔ iOS sim. Same flow as prior retests (Jul 1 / Jul 3) plus PR QA checklist from #1040.


PR QA checklist

#TestAndroidiOS
1Create/edit profile → add contact → contact survives restart
2Send → Contact → pay (private first, public fallback ok)
3Backup/restore wallet with Pubky → pay contact
4Settings → Payment Preference → toggle public/private
5Sign out / delete / disconnect — cleanup then local state cleared

Session flow (regression focus)

StepAndroidiOS
Session 1 — fresh profiles, contacts, LN + on-chain
Session 1 — private contact payments
Delete → re-create (same pubky, 409 → sign-in) → re-add contact
Session 2 — LN + on-chain after reset
Session 2 — private contact payments (incl. “Received from [contact]”)
Second profile delete in same session

Jul 3 blockers — status in this run:

  • RestoreReplayError / encrypted-link recovery after profile reset → not seen (fixed)
  • Profile delete blocked by PrivateUnavailablenot seen (still fixed)

Log support: multiple PaymentSuccessful / Lightning payment successful on both sides; iOS setContact after incoming payments in session 1 and session 2; Deleted all contacts on both platforms without Failed to delete profile.


Known issue — deferred (Android only)

Pubky Ring profile import on Android fails after Ring returns auth success:

Received Pubky Ring auth success callback
Auth approval failed: code=identity_error, context=complete Pubky auth flow
Screenshot 2026-07-07 at 13 56 09

UI: “Authorization Failed” toast on Join the Pubky Web screen (Import with Pubky Ring).

iOS: Ring import works (Pubky auth completed for pubkyc97…).

Agreed with @ben-kaufman on Slack to merge without blocking on this — Android Ring import tracked as follow-up, not a Paykit SDK regression.


Verdict

ScopeResult
Paykit SDK integration — contact payments, profile lifecycle, backup/restore✅ Pass
Private Paykit recovery after profile delete/re-add (Jul 3 regression)✅ Pass
Profile delete when private cleanup flaky✅ Pass
Android Pubky Ring import❌ Deferred (Android-only, post-merge)

LGTM on #1040 / #606 for merge, modulo deferred Android Ring import.

piotr-iohk
piotr-iohk previously approved these changes Jul 7, 2026

@piotr-iohkpiotr-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.

tACK

@jvsena42jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approved except for one comment that worth addressing

Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt Outdated
@ben-kaufman

ben-kaufman commented Jul 8, 2026

Copy link
Copy Markdown
ContributorAuthor

@jvsena42 Fixed in 0c0dd99. Ring auth completion now returns a failed Result if the auth attempt is canceled/superseded while waiting for approval, instead of throwing or waiting forever. Also cleaned up the Keychain runBlocking nits from the review.

@jvsena42
jvsena42 enabled auto-merge July 8, 2026 13:57
@jvsena42
jvsena42 merged commit b3212d6 into masterJul 8, 2026
31 of 33 checks passed
@jvsena42
jvsena42 deleted the codex/paykit-sdk-native-integration branch July 8, 2026 18:06
@piotr-iohkpiotr-iohk mentioned this pull request Jul 21, 2026
5 tasks
Sign up for freeto 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.

5 participants

@ben-kaufman@piotr-iohk@jvsena42@ovitrif@Jasonvdb
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

refactor: integrate paykit sdk - #1040

Merged
jvsena42 merged 24 commits into
masterfrom
codex/paykit-sdk-native-integration
Jul 8, 2026
Merged

refactor: integrate paykit sdk#1040
jvsena42 merged 24 commits into
masterfrom
codex/paykit-sdk-native-integration

Conversation

@ben-kaufman

@ben-kaufmanben-kaufman commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

This PR:

  1. Replaces Bitkit's custom Paykit private/public payment plumbing with the native Paykit SDK.
  2. Moves Pubky profile, contact, public endpoint, private endpoint, and SDK backup state handling through SDK APIs.
  3. Keeps Bitkit responsible for wallet execution, payment-request mapping, contact attribution, endpoint rotation, and public fallback behavior.
  4. Pins Paykit to the published com.synonym:paykit-android:0.1.0-rc23 artifact.
  5. Adds hardening for auth approval capabilities, canceled Ring auth cleanup, profile label fallback, pending private drain retries, and best-effort profile delete/sign-out cleanup.

Description

  • Adds a Paykit SDK service wrapper for session bootstrap, Ring auth, profile/avatar publishing, contact records, public endpoint sync, private payment list sync, and SDK backup state import/export.
  • Refactors public and private Paykit repositories to resolve and publish payment endpoints through SDK APIs while preserving Bitkit's existing endpoint preference order and local payability checks.
  • Moves private contact link and recovery state into the SDK backup string, while keeping Bitkit-owned address reservations and payment attribution in app storage.
  • Updates Pubky profile/contact loading, profile edits, sign-out/delete cleanup, backup/restore, and wallet wipe flows for the SDK-backed state model.

Preview

N/A - SDK integration; no UI layout change.

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.
  • 5. Profile -> Sign Out / Delete Profile / Disconnect Profile: remote endpoint cleanup runs first, then local Pubky and SDK state clear on success.

Automated Checks

  • ./gradlew compileDevDebugKotlin passed.
  • ./gradlew testDevDebugUnitTest passed.
  • ./gradlew testDevDebugUnitTest --tests to.bitkit.repositories.PrivatePaykitRepoTest passed.
  • ./gradlew detekt passed.
  • git diff --check passed.

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

greptile-appsBot commented Jun 24, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces Bitkit's hand-rolled Paykit plumbing with the published com.synonym:paykit-android:0.1.0-rc21 SDK, removing ~2,200 lines of custom link/handshake/recovery state machine code and delegating session, profile, contact, private-payment-list, and backup-state management to native SDK APIs. Wallet execution logic, public-endpoint fallback, Ring/public-only handling, contact attribution, and receiving-detail rotation remain in Bitkit.

  • PaykitSdkService (713 lines, new): wraps PaykitSdk behind operationMutex, implements SdkStateBlobStore (CAS-style revision check against the keychain) and SdkPubkySessionProvider, exposes backup-state versioning via withStateRevisionTracking.
  • PrivatePaykitRepo / PubkyRepo: substantially slimmed by delegating link/handshake work to the SDK; contact profile overrides and paykitSdkBackupState replace the previous PrivatePaykitContactLinkBackupV1 map in wallet backups.
  • Backup migration: old privatePaykitContactLinks data is silently discarded when restoring pre-SDK backups; existing contact-link sessions are not migrated to the new SDK state format.

Confidence Score: 4/5

The core payment flow and session lifecycle look structurally sound; the main risks are edge cases in the new blocking-inside-synchronized SDK state store and empty contact names when the SDK returns a profile with no display data.

The architectural shift is large but well-scoped: the SDK takes over state management that was previously hand-coded, and the delegation boundary is clear. The new PaykitSdkStateBlobStore uses runBlocking(ioDispatcher) inside a synchronized block — not a deadlock under normal load but a thread-starvation risk under sustained IO pressure. PaykitSdkSessionProvider.clearSessionAccess() uses a bare runBlocking {} without a dispatcher, which could misbehave if called from an unusual thread context. The backup restore path for legacy (pre-SDK) backups silently swallows SDK state-clearing errors. The contact-name-empty edge case is a UI regression when the SDK's profile record lacks both displayName and decodable extraJson. None of these are showstoppers, but the blocking-coroutine nesting deserves attention before shipping to broad audiences.

PaykitSdkService.kt (the PaykitSdkStateBlobStore and PaykitSdkSessionProvider inner classes), BackupRepo.kt (legacy restore path around line 619), and PubkyRepo.kt (contactProfile method).

Important Files Changed

FilenameOverview
app/src/main/java/to/bitkit/services/PaykitSdkService.ktNew singleton service wrapping the Paykit SDK; mixes runBlocking inside a synchronized block (saveStateBlobAtomically) and has a bare runBlocking in PaykitSdkSessionProvider.clearSessionAccess().
app/src/main/java/to/bitkit/data/keychain/Keychain.ktAdds a new synchronous upsert(ByteArray) method using runBlocking(this.coroutineContext); consistent with the existing snapshot pattern but called from a synchronized block, risking thread starvation under IO saturation.
app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.ktSubstantially trimmed by delegating link/handshake/recovery state to the SDK; backup snapshot now delegates to PaykitSdkService.exportBackupState(); logic looks correct.
app/src/main/java/to/bitkit/repositories/PubkyRepo.ktDelegates session/profile/contact operations to PaykitSdkService; introduces contactProfileOverrides in PubkyStore and snapshotContactProfileOverrides/restoreContactProfileOverrides for backup; contact name may be empty when paykitProfile has no displayName and no extraJson.
app/src/main/java/to/bitkit/repositories/BackupRepo.ktBackup listeners refactored to observeBackupChanges helper; wallet restore silently swallows SDK state-clearing errors for legacy backups (null paykitSdkBackupState).
app/src/main/java/to/bitkit/services/PubkyService.ktThin wrapper now fully delegates to PaykitSdkService; straightforward and correct.
gradle/libs.versions.tomlBumps paykit-android from rc8 to rc21; no other dependency changes.
app/src/main/java/to/bitkit/models/BackupPayloads.ktReplaces PrivatePaykitContactLinkBackupV1 map with a single paykitSdkBackupState string and adds pubkyContactProfileOverrides; old backup fields removed with no migration path for existing contact-link data.
app/src/main/java/to/bitkit/models/PubkyProfile.ktAdapts to SDK PubkyProfile/PaykitProfile types; fromPaykitProfile may produce an empty contact name if displayName and extraJson are both absent.
app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.ktWipe sequence unchanged in substance; closeAndClear() now delegates SDK state clearing, then keychain.wipe() removes all persisted state.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant App as App/UI
participant PPR as PrivatePaykitRepo
participant SDK as PaykitSdkService
participant PaykitSdk as PaykitSdk (native)
participant Keychain as Keychain
participant BR as BackupRepo
App->>PPR: prepareSavedContacts(publicKeys)
PPR->>SDK: ensureLinkWithPeer(counterparty)
SDK->>PaykitSdk: ensureLinkWithPeer()
PaykitSdk->>Keychain: saveStateBlobAtomically() [synchronized + runBlocking]
SDK->>BR: backupStateVersion++ (via withStateRevisionTracking)
PPR->>SDK: syncPrivatePaymentListsWithReservations(updates)
SDK->>PaykitSdk: syncPrivatePaymentListsWithReservationsAndProcessOutbound()
PaykitSdk->>Keychain: saveStateBlobAtomically()
SDK->>BR: backupStateVersion++
App->>PPR: beginSavedContactPayment(publicKey)
PPR->>SDK: prepareAndResolveContactPayment(counterparty)
SDK->>PaykitSdk: prepareAndResolveContactPayment()
PaykitSdk-->>SDK: ContactPaymentResolution
SDK-->>PPR: PaykitContactPaymentResolution
PPR-->>App: PublicPaykitPaymentResult
BR->>PPR: backupSnapshot()
PPR->>SDK: exportBackupState()
SDK->>PaykitSdk: exportBackupString()
PaykitSdk-->>SDK: String (opaque blob)
SDK-->>BR: paykitSdkBackupState
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 App as App/UI
participant PPR as PrivatePaykitRepo
participant SDK as PaykitSdkService
participant PaykitSdk as PaykitSdk (native)
participant Keychain as Keychain
participant BR as BackupRepo
App->>PPR: prepareSavedContacts(publicKeys)
PPR->>SDK: ensureLinkWithPeer(counterparty)
SDK->>PaykitSdk: ensureLinkWithPeer()
PaykitSdk->>Keychain: saveStateBlobAtomically() [synchronized + runBlocking]
SDK->>BR: backupStateVersion++ (via withStateRevisionTracking)
PPR->>SDK: syncPrivatePaymentListsWithReservations(updates)
SDK->>PaykitSdk: syncPrivatePaymentListsWithReservationsAndProcessOutbound()
PaykitSdk->>Keychain: saveStateBlobAtomically()
SDK->>BR: backupStateVersion++
App->>PPR: beginSavedContactPayment(publicKey)
PPR->>SDK: prepareAndResolveContactPayment(counterparty)
SDK->>PaykitSdk: prepareAndResolveContactPayment()
PaykitSdk-->>SDK: ContactPaymentResolution
SDK-->>PPR: PaykitContactPaymentResolution
PPR-->>App: PublicPaykitPaymentResult
BR->>PPR: backupSnapshot()
PPR->>SDK: exportBackupState()
SDK->>PaykitSdk: exportBackupString()
PaykitSdk-->>SDK: String (opaque blob)
SDK-->>BR: paykitSdkBackupState
Loading

Comments Outside Diff (1)

  1. app/src/main/java/to/bitkit/repositories/BackupRepo.kt, line 619-628 (link)

    P2SDK state-clear failure silently ignored during legacy backup restore

    When paykitSdkBackupState is null (restoring a backup created before this PR), privateRepo.restoreBackup(null) is called and any failure is only logged via onFailure { Logger.warn(...) } — execution continues regardless. Inside restoreBackup(null), paykitSdkService.clearState() deletes the PAYKIT_SDK_STATE keychain entry. If this deletion fails (e.g., keystore error), the stale SDK state persists while the rest of the wallet is restored from the new backup, leaving contact-link and session state out of sync with the freshly restored wallet. The successful path (paykitSdkBackupState != null) uses .getOrThrow() — the legacy path should follow the same convention or at least propagate the failure to surface the inconsistency.

Reviews (1): Last reviewed commit: "fix: preserve paykit cancellation" | Re-trigger Greptile

Comment threadapp/src/main/java/to/bitkit/services/PaykitSdkService.kt
Comment threadapp/src/main/java/to/bitkit/services/PaykitSdkService.kt
Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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:8202a59774

ℹ️ 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 threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt
Comment threadapp/src/main/java/to/bitkit/services/PaykitSdkService.kt Outdated
Comment threadapp/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt Outdated
@ben-kaufman

Copy link
Copy Markdown
ContributorAuthor

For the legacy backup migration note: this is intentional for this PR. The old private Paykit link backup format never shipped, so there is no production data to migrate. Treating it as if it never existed keeps the restore path simpler.

@ovitrifovitrif 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.

Left one inline comment.

Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@piotr-iohk

Copy link
Copy Markdown
Collaborator

That is not necessarily due to this change, because I saw it on other PR also - however e2e tests here failed partially because of this. The failure is intermittent and most of the time tests pass after re-runs.

To reproduce:

  • create a profile.
  • delete profile
  • recreate profile

Result after hitting "Continue" on the following screen:
Screenshot 2026-06-25 at 14 03 04

Attaching logs from e2e run where this happened:
bitkit_2026-06-24_17-37-36.log
logcat.txt

@ovitrifovitrif added this to the 2.5.0 milestone Jun 25, 2026
@ben-kaufmanChatGPT Codex Connector

Copy link
Copy Markdown
ContributorAuthor

Fixed now in 041548681.

Root cause was Android public Paykit publishing only refreshed the reusable on-chain address if the cached address was already reserved/unavailable. In the delete profile -> recreate profile flow, Lightning receive could be unavailable and the cached reusable on-chain address could still be blank, so endpoint sync concluded there were no supported endpoints and showed the toast.

I changed public Paykit endpoint sync to ensure a reusable on-chain address exists before deciding there is no publishable endpoint, and added regression coverage for the blank-address case. Also merged latest master and resolved the version-catalog conflict by keeping bitkit-core 0.1.75 from master plus Paykit 0.1.0-rc21 from this PR.

Checked:

  • ./gradlew testDevDebugUnitTest --tests to.bitkit.repositories.PublicPaykitRepoTest --tests to.bitkit.repositories.WalletRepoTest
  • ./gradlew compileDevDebugKotlin
  • ./gradlew detekt
  • git diff --check

GitHub now reports the PR as mergeable.

@jvsena42
jvsena42 self-requested a review July 1, 2026 12:46
@jvsena42

jvsena42 commented Jul 1, 2026

Copy link
Copy Markdown
Member

⚠️ Ring sign-in crashes: there is no reactor running, must be called from the context of a Tokio 1.x runtime

Reproduced when tapping "Sign in with Pubky Ring":

Screen_recording_20260701_095809.webm
ERROR [PubkyChoiceViewModel.kt:101] Starting Ring auth failed
[AppError='there is no reactor running, must be called from the context of a Tokio 1.x runtime']

Call chain

PubkyChoiceViewModel.startRingAuth()
→ PubkyRepo.startAuthentication() (PubkyRepo.kt:268)
→ PubkyService.startAuth() (PubkyService.kt:88)
→ PaykitSdkService.startAuth() (PaykitSdkService.kt:201)
→ PubkySessionBootstrap().startSignInAuth(...) ← panics here

Root cause (SDK binding, not app code)

Decompiled paykit-android:0.1.0-rc21 to confirm:

  • startSignInAuth / startSignUpAuth / resumeAuth are exported as synchronous FFI calls (uniffiRustCallWithError). UniFFI does not enter a Tokio runtime around blocking calls.
  • The bootstrap functions we use elsewhere — signIn, signUp, importSession, complete, approveAuth — are suspend, driven through UniFFI's async scaffolding on the SDK's Tokio runtime, so a reactor is present.

The Rust impl of startSignInAuth needs a Tokio reactor (builds the relay/network client for the Ring flow), but because it's a blocking export it runs on our core-queue thread with no runtime entered → panic. Pure-crypto sync functions in the same SDK (derivePubkySecretKey, pubkyPublicKeyFromSecret, parsePubkyAuthUrl) work fine because they touch no reactor.

The Ring startSignInAuth API did not exist in rc8 — it's new in rc21.

No clean app-side fix

Kotlin can't enter a Tokio reactor for a blocking UniFFI call, and there is no suspend alternative for starting the flow (only sync startSignInAuth/startSignUpAuth/resumeAuth exist), so withContext(ioDispatcher) / ServiceQueue.CORE don't help.

Fix belongs in paykit-rs: export the start-auth bootstrap functions as async, or have the Rust side enter/hold a runtime (Handle::enter()) inside them. Also worth checking whether a newer paykit-android rc already makes these async before pinning.

@jvsena42jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@piotr-iohk

Copy link
Copy Markdown
Collaborator

Manual regression — Paykit / contact payments

Environment: regtest, staging
Pair tested: Android (pubkyraoz…) ↔ iOS (pubkytrb4ja…)
Logs attached:
ios: bitkit_logs_2026-07-01_13-20-03.zip
android: logs.zip


Test setup

DevicePlatformProfile (pubky)LN node ID
AAndroidpubkyraozwuopbt5pa3e8ki4kqeec8rmw7giruqicw53zehk3uef71agy02f2dc5c…
BiOSpubkytrb4ja4aorm19xsiouw5hmq6ecfp1xprbdkh8x9jqe9edmrwtz1o021714b0…

Session 1 — fresh profiles (smoke)

TestAndroidiOS
Create Pubky profile
Paykit session / identity
Add contact (scan pubky)
Open LN channel (Blocktank)
On-chain send✅ (9a042478…)
LN send to/from contact
Activity sync
Incoming activity shows “Received from [contact]”
RestoreReplayError in logsNot seenNot seen

Session 1 looked good for basic contact + payment flows cross-platform.

Private Paykit in session 1: Incoming activity showing “Received from [contact]” indicates the receive path worked — that label is only set when the payment matches a private Paykit invoice/address (not a generic public profile invoice). There are no private Paykit link errors in session 1 logs on either platform. Send-side logs showing Handling decoded scan data: OnChain(…?lightning=lnbcrt1…) do not by themselves prove public vs private; that is how the send flow represents the payment request.


Session 2 — profile delete, re-create, re-add contacts, second delete blocked

StepAndroidiOS
Delete profile (1st)✅ ~12:23✅ ~12:22 (Deleted all contacts, keychain cleared)
Re-create profile (same pubky key)✅ 409 → sign-in retry✅ 409 → sign-in retry
Re-add contact✅ ~12:26
Contact LN send A → B
Contact LN send B → A
Incoming activity shows “Received from [contact]”
Private Paykit link (no RestoreReplayError)
Delete profile again (2nd attempt)❌ ~13:17–13:18❌ ~13:17 UTC
2nd delete error“Private Paykit is not available.”“Private Paykit is not available.”

Delete profile:

Screen.Recording.2026-07-01.at.15.18.17.mov

Regression — private Paykit broken after profile reset

Session 1: Private Paykit appears to work (receive-side “Received from contact” + no link errors).
Session 2: After deleting/re-creating profiles (same pubky keys) and re-adding contacts, contact LN sends still succeed but private Paykit does not recover. Incoming activity no longer shows “Received from [contact]” — consistent with payments hitting public endpoints instead of private ones. Public fallback is by design (includePublicEndpoints = true); no in-app warning is expected for payments.

Later in the same session, a second profile delete also failed on both platforms — private Paykit cleanup runs before delete and throws PrivateUnavailable, blocking sign-out entirely.

Private Paykit errors (identical on both platforms)

Every private Paykit attempt (prepare, channel usable / refresh, foreground, contact payment) logs:

Failed to prepare private Paykit link for '<contact>'
→ RestoreReplayError: pubky-noise handshake restore failed
Failed to queue private Paykit endpoints …
→ Encrypted Link recovery is required for counterparty <pubky-id>
Deferred private Paykit endpoint publish / Private Paykit is not available

First failures appear immediately after profile re-create (~12:23 iOS, ~12:26 Android on contact re-add).

Contact payments fall back to public

Payments use a public BIP21 unified invoice from the contact’s published profile — not an encrypted private payment list:

  • Shared public address in logs: bcrt1q2h4c7ghs2lj3glrm77mxdae3w2r5h6f3ph258l?lightning=lnbcrt1…
  • Android (AppViewModel): Handling decoded scan data: OnChain(… params={lightning=lnbcrt1…})PaymentSuccessful
  • iOS (LightningService / SendConfirmationView): Paying bolt11: lnbcrt1…Lightning payment successful

Second profile delete blocked

Profile delete runs private Paykit endpoint cleanup first. With private Paykit already broken, cleanup throws PrivateUnavailable and delete aborts before homeserver sign-out.

Android (EditProfileViewModelPrivatePaykitRepo.removePublishedEndpointsForCleanup):

Failed to remove private Paykit endpoints during 'EditProfileViewModel'
[PrivateUnavailable='Private Paykit is not available']

iOS (PubkyProfileManager.deleteProfileremovePrivatePaykitEndpoints):

Failed to remove private Paykit endpoints before clearing session: privateUnavailable
ERROR Failed to delete profile: privateUnavailable - EditProfileView

Profile reset sequence (both sides)

  1. Profile delete → contacts removed, PAYKIT_SESSION / PAYKIT_SDK_STATE cleared
  2. Re-create → homeserver returns 409 User already exists → app signs in with existing key (same pubky identity)
  3. Public Paykit endpoints sync; no successful private encrypted-link handshake in logs
  4. After re-adding contact, RestoreReplayError persists through contact payments
  5. Second delete attempt fails — user stuck unless disconnect/retry workaround is used

Likely cause: local Paykit SDK state is wiped on delete/re-create, but encrypted-link handshake state is inconsistent across peers. SDK reports recovery is required; the app logs warnings, skips private publish, and resolves contact payments via public endpoints (intentional fallback).

Useful grep patterns:RestoreReplayError, Encrypted Link recovery, PrivateUnavailable, Failed to delete profile, Handling decoded scan data: OnChain


Verdict

ScopeResult
Session 1 — fresh profiles: contacts, on-chain + LN, private receive (“Received from contact”)✅ Pass (smoke)
Session 2 — profile reset: contact payments work (public fallback)✅ By design
Session 2 — private Paykit restored; “Received from contact” on receiveRegression
Session 2 — second profile delete blocked (PrivateUnavailable)Regression

Not approving on “private contact payments survive profile delete/re-add.” Session 1 private Paykit looks fine; session 2 regresses on private Paykit recovery and blocks a second profile delete.

@ben-kaufman

ben-kaufman commented Jul 2, 2026

Copy link
Copy Markdown
ContributorAuthor

Fixed in 82bb55cf6 on Android and 51b7c2ce on iOS.

Main thing is we now use Paykit v0.1.0-rc23, which includes the SDK fix for the stale recovery-required encrypted-link state after deleting/recreating a profile. It also fixes the Ring startSignInAuth Tokio runtime crash, so Android is pinned to rc23 now too.

I also fixed the related app-side edges:

  • sign out/delete no longer get blocked if private cleanup is temporarily unavailable
  • pending private drain retries now keep all queued peers instead of replacing older ones
  • auth approval uses the capabilities from the actual auth URL
  • if Ring auth completes but the app flow is canceled/superseded, we clear that session
  • blank SDK profile names fall back to the saved contact label

Public fallback while private recovery/link work is unavailable is still intentional so contact payments can still complete. Ring is still public-only for now; this fixes the crash path, not full Ring private payments support.

Comment threadapp/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt Outdated
Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt Outdated
@piotr-iohk

Copy link
Copy Markdown
Collaborator

@ben-kaufman is pubky-ring option disabled?
Gating_no_profile_pubky_profile_1_-_Contactsprofile_entry_points_lead_to_choice_screen-2026-07-02T10-13-40-607Z

@ben-kaufman

Copy link
Copy Markdown
ContributorAuthor

@piotr-iohk Added it back for now, but we will likely remove it, still waiting for final decision on that...

@piotr-iohk

Copy link
Copy Markdown
Collaborator

@piotr-iohk Added it back for now, but we will likely remove it, still waiting for final decision on that...

OK, atm clicking at Import with Pubky ring results in error toast. Not sure then if we want to resolve that or just leave for now? that is on both iOS and Android

Screen.Recording.2026-07-03.at.12.44.46.mov

@piotr-iohk

Copy link
Copy Markdown
Collaborator

Manual regression retest (Jul 3, post rc23)

Environment: regtest, staging
PRs:bitkit-android #1040 · bitkit-ios #606
Build:codex/paykit-sdk-native-integration, Paykit v0.1.0-rc23

Logs:

Same flow as Jul 1: create profiles → add contacts → LN + on-chain (verify private) → delete → re-create (same pubky) → re-add → LN + on-chain → delete again.


Results

StepAndroidiOS
Session 1 — profiles, contacts, LN + on-chain
Session 1 — private receive (“Received from [contact]”)
Session 1 — RestoreReplayError in logsNot seenNot seen
Delete → re-create → re-add contact
Session 2 — LN + on-chain (payments complete)
Session 2 — private Paykit / “Received from [contact]”
Session 2 — RestoreReplayError after re-add
Second profile delete (while private Paykit broken)

Session 2 — private Paykit still broken after profile reset

After delete/re-create/re-add, private link fails again on both platforms:

RestoreReplayError: failed to restore Encrypted Link handshake
Encrypted Link recovery is required for counterparty …
Private Paykit is not available (deferred publish)

Contact payments still complete via public fallback (by design). On Android, post-reset sends resolve to public BIP21 bcrt1qd8yaa9mwfcr5wwqyd999wmuj2vpyfs4s5emuy4?lightning=… after RestoreReplayError on the contact payment path — same pattern as Jul 1. UI: no “Received from [contact]” on incoming activity.

First failures after re-add: ~10:52 Android, ~10:52 UTC iOS.


Fixed since Jul 1 — profile delete no longer blocked

Second delete succeeds even when private cleanup fails. Logs show PrivateUnavailable warnings during cleanup, but noFailed to delete profile: privateUnavailable (iOS) and profile/session clears (Deleted all contacts, PAYKIT_SESSION removed). Jul 1 blocker is resolved.


Verdict

ScopeResult
Session 1 smoke (private contact payments)✅ Pass
Public fallback when private unavailable✅ By design
Private Paykit recovery after profile delete/re-addStill failing (rc23 did not fix this in manual test)
Profile delete when private cleanup failsFixed

Not approving on “private contact payments survive profile delete/re-add.” Happy to re-test after another SDK/app fix; delete trap fix looks good.

Useful grep patterns:RestoreReplayError, Encrypted Link recovery, PrivateUnavailable, Handling decoded scan data: OnChain, Deleted all contacts

@ben-kaufman

Copy link
Copy Markdown
ContributorAuthor

Fixed and tested now. I reran the Android rc26 E2E with two fresh dev installs: Bitkit profiles on both sides, Pay Contacts enabled, contacts added/resolved both ways, Alice paid Bob from Send -> Contact, and Bob's received activity was assigned to Alice with the contact chip + Detach action. I also checked the app logs/DB for the run: no no-endpoint/public-fallback/private-unavailable/send-failure markers, and both latest activity rows have the expected contact keys.

@piotr-iohk

Copy link
Copy Markdown
Collaborator

Manual regression retest (Jul 7)

Environment: regtest, staging
PRs:bitkit-android #1040 · bitkit-ios #606
Build:codex/paykit-sdk-native-integration, Paykit v0.1.0-rc23

Logs:

Cross-platform pair: Android ↔ iOS sim. Same flow as prior retests (Jul 1 / Jul 3) plus PR QA checklist from #1040.


PR QA checklist

#TestAndroidiOS
1Create/edit profile → add contact → contact survives restart
2Send → Contact → pay (private first, public fallback ok)
3Backup/restore wallet with Pubky → pay contact
4Settings → Payment Preference → toggle public/private
5Sign out / delete / disconnect — cleanup then local state cleared

Session flow (regression focus)

StepAndroidiOS
Session 1 — fresh profiles, contacts, LN + on-chain
Session 1 — private contact payments
Delete → re-create (same pubky, 409 → sign-in) → re-add contact
Session 2 — LN + on-chain after reset
Session 2 — private contact payments (incl. “Received from [contact]”)
Second profile delete in same session

Jul 3 blockers — status in this run:

  • RestoreReplayError / encrypted-link recovery after profile reset → not seen (fixed)
  • Profile delete blocked by PrivateUnavailablenot seen (still fixed)

Log support: multiple PaymentSuccessful / Lightning payment successful on both sides; iOS setContact after incoming payments in session 1 and session 2; Deleted all contacts on both platforms without Failed to delete profile.


Known issue — deferred (Android only)

Pubky Ring profile import on Android fails after Ring returns auth success:

Received Pubky Ring auth success callback
Auth approval failed: code=identity_error, context=complete Pubky auth flow
Screenshot 2026-07-07 at 13 56 09

UI: “Authorization Failed” toast on Join the Pubky Web screen (Import with Pubky Ring).

iOS: Ring import works (Pubky auth completed for pubkyc97…).

Agreed with @ben-kaufman on Slack to merge without blocking on this — Android Ring import tracked as follow-up, not a Paykit SDK regression.


Verdict

ScopeResult
Paykit SDK integration — contact payments, profile lifecycle, backup/restore✅ Pass
Private Paykit recovery after profile delete/re-add (Jul 3 regression)✅ Pass
Profile delete when private cleanup flaky✅ Pass
Android Pubky Ring import❌ Deferred (Android-only, post-merge)

LGTM on #1040 / #606 for merge, modulo deferred Android Ring import.

piotr-iohk
piotr-iohk previously approved these changes Jul 7, 2026

@piotr-iohkpiotr-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.

tACK

@jvsena42jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approved except for one comment that worth addressing

Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt Outdated
@ben-kaufman

ben-kaufman commented Jul 8, 2026

Copy link
Copy Markdown
ContributorAuthor

@jvsena42 Fixed in 0c0dd99. Ring auth completion now returns a failed Result if the auth attempt is canceled/superseded while waiting for approval, instead of throwing or waiting forever. Also cleaned up the Keychain runBlocking nits from the review.

@jvsena42
jvsena42 enabled auto-merge July 8, 2026 13:57
@jvsena42
jvsena42 merged commit b3212d6 into masterJul 8, 2026
31 of 33 checks passed
@jvsena42
jvsena42 deleted the codex/paykit-sdk-native-integration branch July 8, 2026 18:06
@piotr-iohkpiotr-iohk mentioned this pull request Jul 21, 2026
5 tasks
Sign up for freeto 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.

5 participants

@ben-kaufman@piotr-iohk@jvsena42@ovitrif@Jasonvdb
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

refactor: integrate paykit sdk - #1040

Merged
jvsena42 merged 24 commits into
masterfrom
codex/paykit-sdk-native-integration
Jul 8, 2026
Merged

refactor: integrate paykit sdk#1040
jvsena42 merged 24 commits into
masterfrom
codex/paykit-sdk-native-integration

Conversation

@ben-kaufman

@ben-kaufmanben-kaufman commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

This PR:

  1. Replaces Bitkit's custom Paykit private/public payment plumbing with the native Paykit SDK.
  2. Moves Pubky profile, contact, public endpoint, private endpoint, and SDK backup state handling through SDK APIs.
  3. Keeps Bitkit responsible for wallet execution, payment-request mapping, contact attribution, endpoint rotation, and public fallback behavior.
  4. Pins Paykit to the published com.synonym:paykit-android:0.1.0-rc23 artifact.
  5. Adds hardening for auth approval capabilities, canceled Ring auth cleanup, profile label fallback, pending private drain retries, and best-effort profile delete/sign-out cleanup.

Description

  • Adds a Paykit SDK service wrapper for session bootstrap, Ring auth, profile/avatar publishing, contact records, public endpoint sync, private payment list sync, and SDK backup state import/export.
  • Refactors public and private Paykit repositories to resolve and publish payment endpoints through SDK APIs while preserving Bitkit's existing endpoint preference order and local payability checks.
  • Moves private contact link and recovery state into the SDK backup string, while keeping Bitkit-owned address reservations and payment attribution in app storage.
  • Updates Pubky profile/contact loading, profile edits, sign-out/delete cleanup, backup/restore, and wallet wipe flows for the SDK-backed state model.

Preview

N/A - SDK integration; no UI layout change.

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.
  • 5. Profile -> Sign Out / Delete Profile / Disconnect Profile: remote endpoint cleanup runs first, then local Pubky and SDK state clear on success.

Automated Checks

  • ./gradlew compileDevDebugKotlin passed.
  • ./gradlew testDevDebugUnitTest passed.
  • ./gradlew testDevDebugUnitTest --tests to.bitkit.repositories.PrivatePaykitRepoTest passed.
  • ./gradlew detekt passed.
  • git diff --check passed.

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

greptile-appsBot commented Jun 24, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces Bitkit's hand-rolled Paykit plumbing with the published com.synonym:paykit-android:0.1.0-rc21 SDK, removing ~2,200 lines of custom link/handshake/recovery state machine code and delegating session, profile, contact, private-payment-list, and backup-state management to native SDK APIs. Wallet execution logic, public-endpoint fallback, Ring/public-only handling, contact attribution, and receiving-detail rotation remain in Bitkit.

  • PaykitSdkService (713 lines, new): wraps PaykitSdk behind operationMutex, implements SdkStateBlobStore (CAS-style revision check against the keychain) and SdkPubkySessionProvider, exposes backup-state versioning via withStateRevisionTracking.
  • PrivatePaykitRepo / PubkyRepo: substantially slimmed by delegating link/handshake work to the SDK; contact profile overrides and paykitSdkBackupState replace the previous PrivatePaykitContactLinkBackupV1 map in wallet backups.
  • Backup migration: old privatePaykitContactLinks data is silently discarded when restoring pre-SDK backups; existing contact-link sessions are not migrated to the new SDK state format.

Confidence Score: 4/5

The core payment flow and session lifecycle look structurally sound; the main risks are edge cases in the new blocking-inside-synchronized SDK state store and empty contact names when the SDK returns a profile with no display data.

The architectural shift is large but well-scoped: the SDK takes over state management that was previously hand-coded, and the delegation boundary is clear. The new PaykitSdkStateBlobStore uses runBlocking(ioDispatcher) inside a synchronized block — not a deadlock under normal load but a thread-starvation risk under sustained IO pressure. PaykitSdkSessionProvider.clearSessionAccess() uses a bare runBlocking {} without a dispatcher, which could misbehave if called from an unusual thread context. The backup restore path for legacy (pre-SDK) backups silently swallows SDK state-clearing errors. The contact-name-empty edge case is a UI regression when the SDK's profile record lacks both displayName and decodable extraJson. None of these are showstoppers, but the blocking-coroutine nesting deserves attention before shipping to broad audiences.

PaykitSdkService.kt (the PaykitSdkStateBlobStore and PaykitSdkSessionProvider inner classes), BackupRepo.kt (legacy restore path around line 619), and PubkyRepo.kt (contactProfile method).

Important Files Changed

FilenameOverview
app/src/main/java/to/bitkit/services/PaykitSdkService.ktNew singleton service wrapping the Paykit SDK; mixes runBlocking inside a synchronized block (saveStateBlobAtomically) and has a bare runBlocking in PaykitSdkSessionProvider.clearSessionAccess().
app/src/main/java/to/bitkit/data/keychain/Keychain.ktAdds a new synchronous upsert(ByteArray) method using runBlocking(this.coroutineContext); consistent with the existing snapshot pattern but called from a synchronized block, risking thread starvation under IO saturation.
app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.ktSubstantially trimmed by delegating link/handshake/recovery state to the SDK; backup snapshot now delegates to PaykitSdkService.exportBackupState(); logic looks correct.
app/src/main/java/to/bitkit/repositories/PubkyRepo.ktDelegates session/profile/contact operations to PaykitSdkService; introduces contactProfileOverrides in PubkyStore and snapshotContactProfileOverrides/restoreContactProfileOverrides for backup; contact name may be empty when paykitProfile has no displayName and no extraJson.
app/src/main/java/to/bitkit/repositories/BackupRepo.ktBackup listeners refactored to observeBackupChanges helper; wallet restore silently swallows SDK state-clearing errors for legacy backups (null paykitSdkBackupState).
app/src/main/java/to/bitkit/services/PubkyService.ktThin wrapper now fully delegates to PaykitSdkService; straightforward and correct.
gradle/libs.versions.tomlBumps paykit-android from rc8 to rc21; no other dependency changes.
app/src/main/java/to/bitkit/models/BackupPayloads.ktReplaces PrivatePaykitContactLinkBackupV1 map with a single paykitSdkBackupState string and adds pubkyContactProfileOverrides; old backup fields removed with no migration path for existing contact-link data.
app/src/main/java/to/bitkit/models/PubkyProfile.ktAdapts to SDK PubkyProfile/PaykitProfile types; fromPaykitProfile may produce an empty contact name if displayName and extraJson are both absent.
app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.ktWipe sequence unchanged in substance; closeAndClear() now delegates SDK state clearing, then keychain.wipe() removes all persisted state.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant App as App/UI
participant PPR as PrivatePaykitRepo
participant SDK as PaykitSdkService
participant PaykitSdk as PaykitSdk (native)
participant Keychain as Keychain
participant BR as BackupRepo
App->>PPR: prepareSavedContacts(publicKeys)
PPR->>SDK: ensureLinkWithPeer(counterparty)
SDK->>PaykitSdk: ensureLinkWithPeer()
PaykitSdk->>Keychain: saveStateBlobAtomically() [synchronized + runBlocking]
SDK->>BR: backupStateVersion++ (via withStateRevisionTracking)
PPR->>SDK: syncPrivatePaymentListsWithReservations(updates)
SDK->>PaykitSdk: syncPrivatePaymentListsWithReservationsAndProcessOutbound()
PaykitSdk->>Keychain: saveStateBlobAtomically()
SDK->>BR: backupStateVersion++
App->>PPR: beginSavedContactPayment(publicKey)
PPR->>SDK: prepareAndResolveContactPayment(counterparty)
SDK->>PaykitSdk: prepareAndResolveContactPayment()
PaykitSdk-->>SDK: ContactPaymentResolution
SDK-->>PPR: PaykitContactPaymentResolution
PPR-->>App: PublicPaykitPaymentResult
BR->>PPR: backupSnapshot()
PPR->>SDK: exportBackupState()
SDK->>PaykitSdk: exportBackupString()
PaykitSdk-->>SDK: String (opaque blob)
SDK-->>BR: paykitSdkBackupState
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 App as App/UI
participant PPR as PrivatePaykitRepo
participant SDK as PaykitSdkService
participant PaykitSdk as PaykitSdk (native)
participant Keychain as Keychain
participant BR as BackupRepo
App->>PPR: prepareSavedContacts(publicKeys)
PPR->>SDK: ensureLinkWithPeer(counterparty)
SDK->>PaykitSdk: ensureLinkWithPeer()
PaykitSdk->>Keychain: saveStateBlobAtomically() [synchronized + runBlocking]
SDK->>BR: backupStateVersion++ (via withStateRevisionTracking)
PPR->>SDK: syncPrivatePaymentListsWithReservations(updates)
SDK->>PaykitSdk: syncPrivatePaymentListsWithReservationsAndProcessOutbound()
PaykitSdk->>Keychain: saveStateBlobAtomically()
SDK->>BR: backupStateVersion++
App->>PPR: beginSavedContactPayment(publicKey)
PPR->>SDK: prepareAndResolveContactPayment(counterparty)
SDK->>PaykitSdk: prepareAndResolveContactPayment()
PaykitSdk-->>SDK: ContactPaymentResolution
SDK-->>PPR: PaykitContactPaymentResolution
PPR-->>App: PublicPaykitPaymentResult
BR->>PPR: backupSnapshot()
PPR->>SDK: exportBackupState()
SDK->>PaykitSdk: exportBackupString()
PaykitSdk-->>SDK: String (opaque blob)
SDK-->>BR: paykitSdkBackupState
Loading

Comments Outside Diff (1)

  1. app/src/main/java/to/bitkit/repositories/BackupRepo.kt, line 619-628 (link)

    P2SDK state-clear failure silently ignored during legacy backup restore

    When paykitSdkBackupState is null (restoring a backup created before this PR), privateRepo.restoreBackup(null) is called and any failure is only logged via onFailure { Logger.warn(...) } — execution continues regardless. Inside restoreBackup(null), paykitSdkService.clearState() deletes the PAYKIT_SDK_STATE keychain entry. If this deletion fails (e.g., keystore error), the stale SDK state persists while the rest of the wallet is restored from the new backup, leaving contact-link and session state out of sync with the freshly restored wallet. The successful path (paykitSdkBackupState != null) uses .getOrThrow() — the legacy path should follow the same convention or at least propagate the failure to surface the inconsistency.

Reviews (1): Last reviewed commit: "fix: preserve paykit cancellation" | Re-trigger Greptile

Comment threadapp/src/main/java/to/bitkit/services/PaykitSdkService.kt
Comment threadapp/src/main/java/to/bitkit/services/PaykitSdkService.kt
Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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:8202a59774

ℹ️ 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 threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt
Comment threadapp/src/main/java/to/bitkit/services/PaykitSdkService.kt Outdated
Comment threadapp/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt Outdated
@ben-kaufman

Copy link
Copy Markdown
ContributorAuthor

For the legacy backup migration note: this is intentional for this PR. The old private Paykit link backup format never shipped, so there is no production data to migrate. Treating it as if it never existed keeps the restore path simpler.

@ovitrifovitrif 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.

Left one inline comment.

Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@piotr-iohk

Copy link
Copy Markdown
Collaborator

That is not necessarily due to this change, because I saw it on other PR also - however e2e tests here failed partially because of this. The failure is intermittent and most of the time tests pass after re-runs.

To reproduce:

  • create a profile.
  • delete profile
  • recreate profile

Result after hitting "Continue" on the following screen:
Screenshot 2026-06-25 at 14 03 04

Attaching logs from e2e run where this happened:
bitkit_2026-06-24_17-37-36.log
logcat.txt

@ovitrifovitrif added this to the 2.5.0 milestone Jun 25, 2026
@ben-kaufmanChatGPT Codex Connector

Copy link
Copy Markdown
ContributorAuthor

Fixed now in 041548681.

Root cause was Android public Paykit publishing only refreshed the reusable on-chain address if the cached address was already reserved/unavailable. In the delete profile -> recreate profile flow, Lightning receive could be unavailable and the cached reusable on-chain address could still be blank, so endpoint sync concluded there were no supported endpoints and showed the toast.

I changed public Paykit endpoint sync to ensure a reusable on-chain address exists before deciding there is no publishable endpoint, and added regression coverage for the blank-address case. Also merged latest master and resolved the version-catalog conflict by keeping bitkit-core 0.1.75 from master plus Paykit 0.1.0-rc21 from this PR.

Checked:

  • ./gradlew testDevDebugUnitTest --tests to.bitkit.repositories.PublicPaykitRepoTest --tests to.bitkit.repositories.WalletRepoTest
  • ./gradlew compileDevDebugKotlin
  • ./gradlew detekt
  • git diff --check

GitHub now reports the PR as mergeable.

@jvsena42
jvsena42 self-requested a review July 1, 2026 12:46
@jvsena42

jvsena42 commented Jul 1, 2026

Copy link
Copy Markdown
Member

⚠️ Ring sign-in crashes: there is no reactor running, must be called from the context of a Tokio 1.x runtime

Reproduced when tapping "Sign in with Pubky Ring":

Screen_recording_20260701_095809.webm
ERROR [PubkyChoiceViewModel.kt:101] Starting Ring auth failed
[AppError='there is no reactor running, must be called from the context of a Tokio 1.x runtime']

Call chain

PubkyChoiceViewModel.startRingAuth()
→ PubkyRepo.startAuthentication() (PubkyRepo.kt:268)
→ PubkyService.startAuth() (PubkyService.kt:88)
→ PaykitSdkService.startAuth() (PaykitSdkService.kt:201)
→ PubkySessionBootstrap().startSignInAuth(...) ← panics here

Root cause (SDK binding, not app code)

Decompiled paykit-android:0.1.0-rc21 to confirm:

  • startSignInAuth / startSignUpAuth / resumeAuth are exported as synchronous FFI calls (uniffiRustCallWithError). UniFFI does not enter a Tokio runtime around blocking calls.
  • The bootstrap functions we use elsewhere — signIn, signUp, importSession, complete, approveAuth — are suspend, driven through UniFFI's async scaffolding on the SDK's Tokio runtime, so a reactor is present.

The Rust impl of startSignInAuth needs a Tokio reactor (builds the relay/network client for the Ring flow), but because it's a blocking export it runs on our core-queue thread with no runtime entered → panic. Pure-crypto sync functions in the same SDK (derivePubkySecretKey, pubkyPublicKeyFromSecret, parsePubkyAuthUrl) work fine because they touch no reactor.

The Ring startSignInAuth API did not exist in rc8 — it's new in rc21.

No clean app-side fix

Kotlin can't enter a Tokio reactor for a blocking UniFFI call, and there is no suspend alternative for starting the flow (only sync startSignInAuth/startSignUpAuth/resumeAuth exist), so withContext(ioDispatcher) / ServiceQueue.CORE don't help.

Fix belongs in paykit-rs: export the start-auth bootstrap functions as async, or have the Rust side enter/hold a runtime (Handle::enter()) inside them. Also worth checking whether a newer paykit-android rc already makes these async before pinning.

@jvsena42jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@piotr-iohk

Copy link
Copy Markdown
Collaborator

Manual regression — Paykit / contact payments

Environment: regtest, staging
Pair tested: Android (pubkyraoz…) ↔ iOS (pubkytrb4ja…)
Logs attached:
ios: bitkit_logs_2026-07-01_13-20-03.zip
android: logs.zip


Test setup

DevicePlatformProfile (pubky)LN node ID
AAndroidpubkyraozwuopbt5pa3e8ki4kqeec8rmw7giruqicw53zehk3uef71agy02f2dc5c…
BiOSpubkytrb4ja4aorm19xsiouw5hmq6ecfp1xprbdkh8x9jqe9edmrwtz1o021714b0…

Session 1 — fresh profiles (smoke)

TestAndroidiOS
Create Pubky profile
Paykit session / identity
Add contact (scan pubky)
Open LN channel (Blocktank)
On-chain send✅ (9a042478…)
LN send to/from contact
Activity sync
Incoming activity shows “Received from [contact]”
RestoreReplayError in logsNot seenNot seen

Session 1 looked good for basic contact + payment flows cross-platform.

Private Paykit in session 1: Incoming activity showing “Received from [contact]” indicates the receive path worked — that label is only set when the payment matches a private Paykit invoice/address (not a generic public profile invoice). There are no private Paykit link errors in session 1 logs on either platform. Send-side logs showing Handling decoded scan data: OnChain(…?lightning=lnbcrt1…) do not by themselves prove public vs private; that is how the send flow represents the payment request.


Session 2 — profile delete, re-create, re-add contacts, second delete blocked

StepAndroidiOS
Delete profile (1st)✅ ~12:23✅ ~12:22 (Deleted all contacts, keychain cleared)
Re-create profile (same pubky key)✅ 409 → sign-in retry✅ 409 → sign-in retry
Re-add contact✅ ~12:26
Contact LN send A → B
Contact LN send B → A
Incoming activity shows “Received from [contact]”
Private Paykit link (no RestoreReplayError)
Delete profile again (2nd attempt)❌ ~13:17–13:18❌ ~13:17 UTC
2nd delete error“Private Paykit is not available.”“Private Paykit is not available.”

Delete profile:

Screen.Recording.2026-07-01.at.15.18.17.mov

Regression — private Paykit broken after profile reset

Session 1: Private Paykit appears to work (receive-side “Received from contact” + no link errors).
Session 2: After deleting/re-creating profiles (same pubky keys) and re-adding contacts, contact LN sends still succeed but private Paykit does not recover. Incoming activity no longer shows “Received from [contact]” — consistent with payments hitting public endpoints instead of private ones. Public fallback is by design (includePublicEndpoints = true); no in-app warning is expected for payments.

Later in the same session, a second profile delete also failed on both platforms — private Paykit cleanup runs before delete and throws PrivateUnavailable, blocking sign-out entirely.

Private Paykit errors (identical on both platforms)

Every private Paykit attempt (prepare, channel usable / refresh, foreground, contact payment) logs:

Failed to prepare private Paykit link for '<contact>'
→ RestoreReplayError: pubky-noise handshake restore failed
Failed to queue private Paykit endpoints …
→ Encrypted Link recovery is required for counterparty <pubky-id>
Deferred private Paykit endpoint publish / Private Paykit is not available

First failures appear immediately after profile re-create (~12:23 iOS, ~12:26 Android on contact re-add).

Contact payments fall back to public

Payments use a public BIP21 unified invoice from the contact’s published profile — not an encrypted private payment list:

  • Shared public address in logs: bcrt1q2h4c7ghs2lj3glrm77mxdae3w2r5h6f3ph258l?lightning=lnbcrt1…
  • Android (AppViewModel): Handling decoded scan data: OnChain(… params={lightning=lnbcrt1…})PaymentSuccessful
  • iOS (LightningService / SendConfirmationView): Paying bolt11: lnbcrt1…Lightning payment successful

Second profile delete blocked

Profile delete runs private Paykit endpoint cleanup first. With private Paykit already broken, cleanup throws PrivateUnavailable and delete aborts before homeserver sign-out.

Android (EditProfileViewModelPrivatePaykitRepo.removePublishedEndpointsForCleanup):

Failed to remove private Paykit endpoints during 'EditProfileViewModel'
[PrivateUnavailable='Private Paykit is not available']

iOS (PubkyProfileManager.deleteProfileremovePrivatePaykitEndpoints):

Failed to remove private Paykit endpoints before clearing session: privateUnavailable
ERROR Failed to delete profile: privateUnavailable - EditProfileView

Profile reset sequence (both sides)

  1. Profile delete → contacts removed, PAYKIT_SESSION / PAYKIT_SDK_STATE cleared
  2. Re-create → homeserver returns 409 User already exists → app signs in with existing key (same pubky identity)
  3. Public Paykit endpoints sync; no successful private encrypted-link handshake in logs
  4. After re-adding contact, RestoreReplayError persists through contact payments
  5. Second delete attempt fails — user stuck unless disconnect/retry workaround is used

Likely cause: local Paykit SDK state is wiped on delete/re-create, but encrypted-link handshake state is inconsistent across peers. SDK reports recovery is required; the app logs warnings, skips private publish, and resolves contact payments via public endpoints (intentional fallback).

Useful grep patterns:RestoreReplayError, Encrypted Link recovery, PrivateUnavailable, Failed to delete profile, Handling decoded scan data: OnChain


Verdict

ScopeResult
Session 1 — fresh profiles: contacts, on-chain + LN, private receive (“Received from contact”)✅ Pass (smoke)
Session 2 — profile reset: contact payments work (public fallback)✅ By design
Session 2 — private Paykit restored; “Received from contact” on receiveRegression
Session 2 — second profile delete blocked (PrivateUnavailable)Regression

Not approving on “private contact payments survive profile delete/re-add.” Session 1 private Paykit looks fine; session 2 regresses on private Paykit recovery and blocks a second profile delete.

@ben-kaufman

ben-kaufman commented Jul 2, 2026

Copy link
Copy Markdown
ContributorAuthor

Fixed in 82bb55cf6 on Android and 51b7c2ce on iOS.

Main thing is we now use Paykit v0.1.0-rc23, which includes the SDK fix for the stale recovery-required encrypted-link state after deleting/recreating a profile. It also fixes the Ring startSignInAuth Tokio runtime crash, so Android is pinned to rc23 now too.

I also fixed the related app-side edges:

  • sign out/delete no longer get blocked if private cleanup is temporarily unavailable
  • pending private drain retries now keep all queued peers instead of replacing older ones
  • auth approval uses the capabilities from the actual auth URL
  • if Ring auth completes but the app flow is canceled/superseded, we clear that session
  • blank SDK profile names fall back to the saved contact label

Public fallback while private recovery/link work is unavailable is still intentional so contact payments can still complete. Ring is still public-only for now; this fixes the crash path, not full Ring private payments support.

Comment threadapp/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt Outdated
Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt Outdated
@piotr-iohk

Copy link
Copy Markdown
Collaborator

@ben-kaufman is pubky-ring option disabled?
Gating_no_profile_pubky_profile_1_-_Contactsprofile_entry_points_lead_to_choice_screen-2026-07-02T10-13-40-607Z

@ben-kaufman

Copy link
Copy Markdown
ContributorAuthor

@piotr-iohk Added it back for now, but we will likely remove it, still waiting for final decision on that...

@piotr-iohk

Copy link
Copy Markdown
Collaborator

@piotr-iohk Added it back for now, but we will likely remove it, still waiting for final decision on that...

OK, atm clicking at Import with Pubky ring results in error toast. Not sure then if we want to resolve that or just leave for now? that is on both iOS and Android

Screen.Recording.2026-07-03.at.12.44.46.mov

@piotr-iohk

Copy link
Copy Markdown
Collaborator

Manual regression retest (Jul 3, post rc23)

Environment: regtest, staging
PRs:bitkit-android #1040 · bitkit-ios #606
Build:codex/paykit-sdk-native-integration, Paykit v0.1.0-rc23

Logs:

Same flow as Jul 1: create profiles → add contacts → LN + on-chain (verify private) → delete → re-create (same pubky) → re-add → LN + on-chain → delete again.


Results

StepAndroidiOS
Session 1 — profiles, contacts, LN + on-chain
Session 1 — private receive (“Received from [contact]”)
Session 1 — RestoreReplayError in logsNot seenNot seen
Delete → re-create → re-add contact
Session 2 — LN + on-chain (payments complete)
Session 2 — private Paykit / “Received from [contact]”
Session 2 — RestoreReplayError after re-add
Second profile delete (while private Paykit broken)

Session 2 — private Paykit still broken after profile reset

After delete/re-create/re-add, private link fails again on both platforms:

RestoreReplayError: failed to restore Encrypted Link handshake
Encrypted Link recovery is required for counterparty …
Private Paykit is not available (deferred publish)

Contact payments still complete via public fallback (by design). On Android, post-reset sends resolve to public BIP21 bcrt1qd8yaa9mwfcr5wwqyd999wmuj2vpyfs4s5emuy4?lightning=… after RestoreReplayError on the contact payment path — same pattern as Jul 1. UI: no “Received from [contact]” on incoming activity.

First failures after re-add: ~10:52 Android, ~10:52 UTC iOS.


Fixed since Jul 1 — profile delete no longer blocked

Second delete succeeds even when private cleanup fails. Logs show PrivateUnavailable warnings during cleanup, but noFailed to delete profile: privateUnavailable (iOS) and profile/session clears (Deleted all contacts, PAYKIT_SESSION removed). Jul 1 blocker is resolved.


Verdict

ScopeResult
Session 1 smoke (private contact payments)✅ Pass
Public fallback when private unavailable✅ By design
Private Paykit recovery after profile delete/re-addStill failing (rc23 did not fix this in manual test)
Profile delete when private cleanup failsFixed

Not approving on “private contact payments survive profile delete/re-add.” Happy to re-test after another SDK/app fix; delete trap fix looks good.

Useful grep patterns:RestoreReplayError, Encrypted Link recovery, PrivateUnavailable, Handling decoded scan data: OnChain, Deleted all contacts

@ben-kaufman

Copy link
Copy Markdown
ContributorAuthor

Fixed and tested now. I reran the Android rc26 E2E with two fresh dev installs: Bitkit profiles on both sides, Pay Contacts enabled, contacts added/resolved both ways, Alice paid Bob from Send -> Contact, and Bob's received activity was assigned to Alice with the contact chip + Detach action. I also checked the app logs/DB for the run: no no-endpoint/public-fallback/private-unavailable/send-failure markers, and both latest activity rows have the expected contact keys.

@piotr-iohk

Copy link
Copy Markdown
Collaborator

Manual regression retest (Jul 7)

Environment: regtest, staging
PRs:bitkit-android #1040 · bitkit-ios #606
Build:codex/paykit-sdk-native-integration, Paykit v0.1.0-rc23

Logs:

Cross-platform pair: Android ↔ iOS sim. Same flow as prior retests (Jul 1 / Jul 3) plus PR QA checklist from #1040.


PR QA checklist

#TestAndroidiOS
1Create/edit profile → add contact → contact survives restart
2Send → Contact → pay (private first, public fallback ok)
3Backup/restore wallet with Pubky → pay contact
4Settings → Payment Preference → toggle public/private
5Sign out / delete / disconnect — cleanup then local state cleared

Session flow (regression focus)

StepAndroidiOS
Session 1 — fresh profiles, contacts, LN + on-chain
Session 1 — private contact payments
Delete → re-create (same pubky, 409 → sign-in) → re-add contact
Session 2 — LN + on-chain after reset
Session 2 — private contact payments (incl. “Received from [contact]”)
Second profile delete in same session

Jul 3 blockers — status in this run:

  • RestoreReplayError / encrypted-link recovery after profile reset → not seen (fixed)
  • Profile delete blocked by PrivateUnavailablenot seen (still fixed)

Log support: multiple PaymentSuccessful / Lightning payment successful on both sides; iOS setContact after incoming payments in session 1 and session 2; Deleted all contacts on both platforms without Failed to delete profile.


Known issue — deferred (Android only)

Pubky Ring profile import on Android fails after Ring returns auth success:

Received Pubky Ring auth success callback
Auth approval failed: code=identity_error, context=complete Pubky auth flow
Screenshot 2026-07-07 at 13 56 09

UI: “Authorization Failed” toast on Join the Pubky Web screen (Import with Pubky Ring).

iOS: Ring import works (Pubky auth completed for pubkyc97…).

Agreed with @ben-kaufman on Slack to merge without blocking on this — Android Ring import tracked as follow-up, not a Paykit SDK regression.


Verdict

ScopeResult
Paykit SDK integration — contact payments, profile lifecycle, backup/restore✅ Pass
Private Paykit recovery after profile delete/re-add (Jul 3 regression)✅ Pass
Profile delete when private cleanup flaky✅ Pass
Android Pubky Ring import❌ Deferred (Android-only, post-merge)

LGTM on #1040 / #606 for merge, modulo deferred Android Ring import.

piotr-iohk
piotr-iohk previously approved these changes Jul 7, 2026

@piotr-iohkpiotr-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.

tACK

@jvsena42jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approved except for one comment that worth addressing

Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt Outdated
@ben-kaufman

ben-kaufman commented Jul 8, 2026

Copy link
Copy Markdown
ContributorAuthor

@jvsena42 Fixed in 0c0dd99. Ring auth completion now returns a failed Result if the auth attempt is canceled/superseded while waiting for approval, instead of throwing or waiting forever. Also cleaned up the Keychain runBlocking nits from the review.

@jvsena42
jvsena42 enabled auto-merge July 8, 2026 13:57
@jvsena42
jvsena42 merged commit b3212d6 into masterJul 8, 2026
31 of 33 checks passed
@jvsena42
jvsena42 deleted the codex/paykit-sdk-native-integration branch July 8, 2026 18:06
@piotr-iohkpiotr-iohk mentioned this pull request Jul 21, 2026
5 tasks
Sign up for freeto 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.

5 participants

@ben-kaufman@piotr-iohk@jvsena42@ovitrif@Jasonvdb
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

refactor: integrate paykit sdk - #1040

Merged
jvsena42 merged 24 commits into
masterfrom
codex/paykit-sdk-native-integration
Jul 8, 2026
Merged

refactor: integrate paykit sdk#1040
jvsena42 merged 24 commits into
masterfrom
codex/paykit-sdk-native-integration

Conversation

@ben-kaufman

@ben-kaufmanben-kaufman commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

This PR:

  1. Replaces Bitkit's custom Paykit private/public payment plumbing with the native Paykit SDK.
  2. Moves Pubky profile, contact, public endpoint, private endpoint, and SDK backup state handling through SDK APIs.
  3. Keeps Bitkit responsible for wallet execution, payment-request mapping, contact attribution, endpoint rotation, and public fallback behavior.
  4. Pins Paykit to the published com.synonym:paykit-android:0.1.0-rc23 artifact.
  5. Adds hardening for auth approval capabilities, canceled Ring auth cleanup, profile label fallback, pending private drain retries, and best-effort profile delete/sign-out cleanup.

Description

  • Adds a Paykit SDK service wrapper for session bootstrap, Ring auth, profile/avatar publishing, contact records, public endpoint sync, private payment list sync, and SDK backup state import/export.
  • Refactors public and private Paykit repositories to resolve and publish payment endpoints through SDK APIs while preserving Bitkit's existing endpoint preference order and local payability checks.
  • Moves private contact link and recovery state into the SDK backup string, while keeping Bitkit-owned address reservations and payment attribution in app storage.
  • Updates Pubky profile/contact loading, profile edits, sign-out/delete cleanup, backup/restore, and wallet wipe flows for the SDK-backed state model.

Preview

N/A - SDK integration; no UI layout change.

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.
  • 5. Profile -> Sign Out / Delete Profile / Disconnect Profile: remote endpoint cleanup runs first, then local Pubky and SDK state clear on success.

Automated Checks

  • ./gradlew compileDevDebugKotlin passed.
  • ./gradlew testDevDebugUnitTest passed.
  • ./gradlew testDevDebugUnitTest --tests to.bitkit.repositories.PrivatePaykitRepoTest passed.
  • ./gradlew detekt passed.
  • git diff --check passed.

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

greptile-appsBot commented Jun 24, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces Bitkit's hand-rolled Paykit plumbing with the published com.synonym:paykit-android:0.1.0-rc21 SDK, removing ~2,200 lines of custom link/handshake/recovery state machine code and delegating session, profile, contact, private-payment-list, and backup-state management to native SDK APIs. Wallet execution logic, public-endpoint fallback, Ring/public-only handling, contact attribution, and receiving-detail rotation remain in Bitkit.

  • PaykitSdkService (713 lines, new): wraps PaykitSdk behind operationMutex, implements SdkStateBlobStore (CAS-style revision check against the keychain) and SdkPubkySessionProvider, exposes backup-state versioning via withStateRevisionTracking.
  • PrivatePaykitRepo / PubkyRepo: substantially slimmed by delegating link/handshake work to the SDK; contact profile overrides and paykitSdkBackupState replace the previous PrivatePaykitContactLinkBackupV1 map in wallet backups.
  • Backup migration: old privatePaykitContactLinks data is silently discarded when restoring pre-SDK backups; existing contact-link sessions are not migrated to the new SDK state format.

Confidence Score: 4/5

The core payment flow and session lifecycle look structurally sound; the main risks are edge cases in the new blocking-inside-synchronized SDK state store and empty contact names when the SDK returns a profile with no display data.

The architectural shift is large but well-scoped: the SDK takes over state management that was previously hand-coded, and the delegation boundary is clear. The new PaykitSdkStateBlobStore uses runBlocking(ioDispatcher) inside a synchronized block — not a deadlock under normal load but a thread-starvation risk under sustained IO pressure. PaykitSdkSessionProvider.clearSessionAccess() uses a bare runBlocking {} without a dispatcher, which could misbehave if called from an unusual thread context. The backup restore path for legacy (pre-SDK) backups silently swallows SDK state-clearing errors. The contact-name-empty edge case is a UI regression when the SDK's profile record lacks both displayName and decodable extraJson. None of these are showstoppers, but the blocking-coroutine nesting deserves attention before shipping to broad audiences.

PaykitSdkService.kt (the PaykitSdkStateBlobStore and PaykitSdkSessionProvider inner classes), BackupRepo.kt (legacy restore path around line 619), and PubkyRepo.kt (contactProfile method).

Important Files Changed

FilenameOverview
app/src/main/java/to/bitkit/services/PaykitSdkService.ktNew singleton service wrapping the Paykit SDK; mixes runBlocking inside a synchronized block (saveStateBlobAtomically) and has a bare runBlocking in PaykitSdkSessionProvider.clearSessionAccess().
app/src/main/java/to/bitkit/data/keychain/Keychain.ktAdds a new synchronous upsert(ByteArray) method using runBlocking(this.coroutineContext); consistent with the existing snapshot pattern but called from a synchronized block, risking thread starvation under IO saturation.
app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.ktSubstantially trimmed by delegating link/handshake/recovery state to the SDK; backup snapshot now delegates to PaykitSdkService.exportBackupState(); logic looks correct.
app/src/main/java/to/bitkit/repositories/PubkyRepo.ktDelegates session/profile/contact operations to PaykitSdkService; introduces contactProfileOverrides in PubkyStore and snapshotContactProfileOverrides/restoreContactProfileOverrides for backup; contact name may be empty when paykitProfile has no displayName and no extraJson.
app/src/main/java/to/bitkit/repositories/BackupRepo.ktBackup listeners refactored to observeBackupChanges helper; wallet restore silently swallows SDK state-clearing errors for legacy backups (null paykitSdkBackupState).
app/src/main/java/to/bitkit/services/PubkyService.ktThin wrapper now fully delegates to PaykitSdkService; straightforward and correct.
gradle/libs.versions.tomlBumps paykit-android from rc8 to rc21; no other dependency changes.
app/src/main/java/to/bitkit/models/BackupPayloads.ktReplaces PrivatePaykitContactLinkBackupV1 map with a single paykitSdkBackupState string and adds pubkyContactProfileOverrides; old backup fields removed with no migration path for existing contact-link data.
app/src/main/java/to/bitkit/models/PubkyProfile.ktAdapts to SDK PubkyProfile/PaykitProfile types; fromPaykitProfile may produce an empty contact name if displayName and extraJson are both absent.
app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.ktWipe sequence unchanged in substance; closeAndClear() now delegates SDK state clearing, then keychain.wipe() removes all persisted state.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant App as App/UI
participant PPR as PrivatePaykitRepo
participant SDK as PaykitSdkService
participant PaykitSdk as PaykitSdk (native)
participant Keychain as Keychain
participant BR as BackupRepo
App->>PPR: prepareSavedContacts(publicKeys)
PPR->>SDK: ensureLinkWithPeer(counterparty)
SDK->>PaykitSdk: ensureLinkWithPeer()
PaykitSdk->>Keychain: saveStateBlobAtomically() [synchronized + runBlocking]
SDK->>BR: backupStateVersion++ (via withStateRevisionTracking)
PPR->>SDK: syncPrivatePaymentListsWithReservations(updates)
SDK->>PaykitSdk: syncPrivatePaymentListsWithReservationsAndProcessOutbound()
PaykitSdk->>Keychain: saveStateBlobAtomically()
SDK->>BR: backupStateVersion++
App->>PPR: beginSavedContactPayment(publicKey)
PPR->>SDK: prepareAndResolveContactPayment(counterparty)
SDK->>PaykitSdk: prepareAndResolveContactPayment()
PaykitSdk-->>SDK: ContactPaymentResolution
SDK-->>PPR: PaykitContactPaymentResolution
PPR-->>App: PublicPaykitPaymentResult
BR->>PPR: backupSnapshot()
PPR->>SDK: exportBackupState()
SDK->>PaykitSdk: exportBackupString()
PaykitSdk-->>SDK: String (opaque blob)
SDK-->>BR: paykitSdkBackupState
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 App as App/UI
participant PPR as PrivatePaykitRepo
participant SDK as PaykitSdkService
participant PaykitSdk as PaykitSdk (native)
participant Keychain as Keychain
participant BR as BackupRepo
App->>PPR: prepareSavedContacts(publicKeys)
PPR->>SDK: ensureLinkWithPeer(counterparty)
SDK->>PaykitSdk: ensureLinkWithPeer()
PaykitSdk->>Keychain: saveStateBlobAtomically() [synchronized + runBlocking]
SDK->>BR: backupStateVersion++ (via withStateRevisionTracking)
PPR->>SDK: syncPrivatePaymentListsWithReservations(updates)
SDK->>PaykitSdk: syncPrivatePaymentListsWithReservationsAndProcessOutbound()
PaykitSdk->>Keychain: saveStateBlobAtomically()
SDK->>BR: backupStateVersion++
App->>PPR: beginSavedContactPayment(publicKey)
PPR->>SDK: prepareAndResolveContactPayment(counterparty)
SDK->>PaykitSdk: prepareAndResolveContactPayment()
PaykitSdk-->>SDK: ContactPaymentResolution
SDK-->>PPR: PaykitContactPaymentResolution
PPR-->>App: PublicPaykitPaymentResult
BR->>PPR: backupSnapshot()
PPR->>SDK: exportBackupState()
SDK->>PaykitSdk: exportBackupString()
PaykitSdk-->>SDK: String (opaque blob)
SDK-->>BR: paykitSdkBackupState
Loading

Comments Outside Diff (1)

  1. app/src/main/java/to/bitkit/repositories/BackupRepo.kt, line 619-628 (link)

    P2SDK state-clear failure silently ignored during legacy backup restore

    When paykitSdkBackupState is null (restoring a backup created before this PR), privateRepo.restoreBackup(null) is called and any failure is only logged via onFailure { Logger.warn(...) } — execution continues regardless. Inside restoreBackup(null), paykitSdkService.clearState() deletes the PAYKIT_SDK_STATE keychain entry. If this deletion fails (e.g., keystore error), the stale SDK state persists while the rest of the wallet is restored from the new backup, leaving contact-link and session state out of sync with the freshly restored wallet. The successful path (paykitSdkBackupState != null) uses .getOrThrow() — the legacy path should follow the same convention or at least propagate the failure to surface the inconsistency.

Reviews (1): Last reviewed commit: "fix: preserve paykit cancellation" | Re-trigger Greptile

Comment threadapp/src/main/java/to/bitkit/services/PaykitSdkService.kt
Comment threadapp/src/main/java/to/bitkit/services/PaykitSdkService.kt
Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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:8202a59774

ℹ️ 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 threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt
Comment threadapp/src/main/java/to/bitkit/services/PaykitSdkService.kt Outdated
Comment threadapp/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt Outdated
@ben-kaufman

Copy link
Copy Markdown
ContributorAuthor

For the legacy backup migration note: this is intentional for this PR. The old private Paykit link backup format never shipped, so there is no production data to migrate. Treating it as if it never existed keeps the restore path simpler.

@ovitrifovitrif 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.

Left one inline comment.

Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@piotr-iohk

Copy link
Copy Markdown
Collaborator

That is not necessarily due to this change, because I saw it on other PR also - however e2e tests here failed partially because of this. The failure is intermittent and most of the time tests pass after re-runs.

To reproduce:

  • create a profile.
  • delete profile
  • recreate profile

Result after hitting "Continue" on the following screen:
Screenshot 2026-06-25 at 14 03 04

Attaching logs from e2e run where this happened:
bitkit_2026-06-24_17-37-36.log
logcat.txt

@ovitrifovitrif added this to the 2.5.0 milestone Jun 25, 2026
@ben-kaufmanChatGPT Codex Connector

Copy link
Copy Markdown
ContributorAuthor

Fixed now in 041548681.

Root cause was Android public Paykit publishing only refreshed the reusable on-chain address if the cached address was already reserved/unavailable. In the delete profile -> recreate profile flow, Lightning receive could be unavailable and the cached reusable on-chain address could still be blank, so endpoint sync concluded there were no supported endpoints and showed the toast.

I changed public Paykit endpoint sync to ensure a reusable on-chain address exists before deciding there is no publishable endpoint, and added regression coverage for the blank-address case. Also merged latest master and resolved the version-catalog conflict by keeping bitkit-core 0.1.75 from master plus Paykit 0.1.0-rc21 from this PR.

Checked:

  • ./gradlew testDevDebugUnitTest --tests to.bitkit.repositories.PublicPaykitRepoTest --tests to.bitkit.repositories.WalletRepoTest
  • ./gradlew compileDevDebugKotlin
  • ./gradlew detekt
  • git diff --check

GitHub now reports the PR as mergeable.

@jvsena42
jvsena42 self-requested a review July 1, 2026 12:46
@jvsena42

jvsena42 commented Jul 1, 2026

Copy link
Copy Markdown
Member

⚠️ Ring sign-in crashes: there is no reactor running, must be called from the context of a Tokio 1.x runtime

Reproduced when tapping "Sign in with Pubky Ring":

Screen_recording_20260701_095809.webm
ERROR [PubkyChoiceViewModel.kt:101] Starting Ring auth failed
[AppError='there is no reactor running, must be called from the context of a Tokio 1.x runtime']

Call chain

PubkyChoiceViewModel.startRingAuth()
→ PubkyRepo.startAuthentication() (PubkyRepo.kt:268)
→ PubkyService.startAuth() (PubkyService.kt:88)
→ PaykitSdkService.startAuth() (PaykitSdkService.kt:201)
→ PubkySessionBootstrap().startSignInAuth(...) ← panics here

Root cause (SDK binding, not app code)

Decompiled paykit-android:0.1.0-rc21 to confirm:

  • startSignInAuth / startSignUpAuth / resumeAuth are exported as synchronous FFI calls (uniffiRustCallWithError). UniFFI does not enter a Tokio runtime around blocking calls.
  • The bootstrap functions we use elsewhere — signIn, signUp, importSession, complete, approveAuth — are suspend, driven through UniFFI's async scaffolding on the SDK's Tokio runtime, so a reactor is present.

The Rust impl of startSignInAuth needs a Tokio reactor (builds the relay/network client for the Ring flow), but because it's a blocking export it runs on our core-queue thread with no runtime entered → panic. Pure-crypto sync functions in the same SDK (derivePubkySecretKey, pubkyPublicKeyFromSecret, parsePubkyAuthUrl) work fine because they touch no reactor.

The Ring startSignInAuth API did not exist in rc8 — it's new in rc21.

No clean app-side fix

Kotlin can't enter a Tokio reactor for a blocking UniFFI call, and there is no suspend alternative for starting the flow (only sync startSignInAuth/startSignUpAuth/resumeAuth exist), so withContext(ioDispatcher) / ServiceQueue.CORE don't help.

Fix belongs in paykit-rs: export the start-auth bootstrap functions as async, or have the Rust side enter/hold a runtime (Handle::enter()) inside them. Also worth checking whether a newer paykit-android rc already makes these async before pinning.

@jvsena42jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@piotr-iohk

Copy link
Copy Markdown
Collaborator

Manual regression — Paykit / contact payments

Environment: regtest, staging
Pair tested: Android (pubkyraoz…) ↔ iOS (pubkytrb4ja…)
Logs attached:
ios: bitkit_logs_2026-07-01_13-20-03.zip
android: logs.zip


Test setup

DevicePlatformProfile (pubky)LN node ID
AAndroidpubkyraozwuopbt5pa3e8ki4kqeec8rmw7giruqicw53zehk3uef71agy02f2dc5c…
BiOSpubkytrb4ja4aorm19xsiouw5hmq6ecfp1xprbdkh8x9jqe9edmrwtz1o021714b0…

Session 1 — fresh profiles (smoke)

TestAndroidiOS
Create Pubky profile
Paykit session / identity
Add contact (scan pubky)
Open LN channel (Blocktank)
On-chain send✅ (9a042478…)
LN send to/from contact
Activity sync
Incoming activity shows “Received from [contact]”
RestoreReplayError in logsNot seenNot seen

Session 1 looked good for basic contact + payment flows cross-platform.

Private Paykit in session 1: Incoming activity showing “Received from [contact]” indicates the receive path worked — that label is only set when the payment matches a private Paykit invoice/address (not a generic public profile invoice). There are no private Paykit link errors in session 1 logs on either platform. Send-side logs showing Handling decoded scan data: OnChain(…?lightning=lnbcrt1…) do not by themselves prove public vs private; that is how the send flow represents the payment request.


Session 2 — profile delete, re-create, re-add contacts, second delete blocked

StepAndroidiOS
Delete profile (1st)✅ ~12:23✅ ~12:22 (Deleted all contacts, keychain cleared)
Re-create profile (same pubky key)✅ 409 → sign-in retry✅ 409 → sign-in retry
Re-add contact✅ ~12:26
Contact LN send A → B
Contact LN send B → A
Incoming activity shows “Received from [contact]”
Private Paykit link (no RestoreReplayError)
Delete profile again (2nd attempt)❌ ~13:17–13:18❌ ~13:17 UTC
2nd delete error“Private Paykit is not available.”“Private Paykit is not available.”

Delete profile:

Screen.Recording.2026-07-01.at.15.18.17.mov

Regression — private Paykit broken after profile reset

Session 1: Private Paykit appears to work (receive-side “Received from contact” + no link errors).
Session 2: After deleting/re-creating profiles (same pubky keys) and re-adding contacts, contact LN sends still succeed but private Paykit does not recover. Incoming activity no longer shows “Received from [contact]” — consistent with payments hitting public endpoints instead of private ones. Public fallback is by design (includePublicEndpoints = true); no in-app warning is expected for payments.

Later in the same session, a second profile delete also failed on both platforms — private Paykit cleanup runs before delete and throws PrivateUnavailable, blocking sign-out entirely.

Private Paykit errors (identical on both platforms)

Every private Paykit attempt (prepare, channel usable / refresh, foreground, contact payment) logs:

Failed to prepare private Paykit link for '<contact>'
→ RestoreReplayError: pubky-noise handshake restore failed
Failed to queue private Paykit endpoints …
→ Encrypted Link recovery is required for counterparty <pubky-id>
Deferred private Paykit endpoint publish / Private Paykit is not available

First failures appear immediately after profile re-create (~12:23 iOS, ~12:26 Android on contact re-add).

Contact payments fall back to public

Payments use a public BIP21 unified invoice from the contact’s published profile — not an encrypted private payment list:

  • Shared public address in logs: bcrt1q2h4c7ghs2lj3glrm77mxdae3w2r5h6f3ph258l?lightning=lnbcrt1…
  • Android (AppViewModel): Handling decoded scan data: OnChain(… params={lightning=lnbcrt1…})PaymentSuccessful
  • iOS (LightningService / SendConfirmationView): Paying bolt11: lnbcrt1…Lightning payment successful

Second profile delete blocked

Profile delete runs private Paykit endpoint cleanup first. With private Paykit already broken, cleanup throws PrivateUnavailable and delete aborts before homeserver sign-out.

Android (EditProfileViewModelPrivatePaykitRepo.removePublishedEndpointsForCleanup):

Failed to remove private Paykit endpoints during 'EditProfileViewModel'
[PrivateUnavailable='Private Paykit is not available']

iOS (PubkyProfileManager.deleteProfileremovePrivatePaykitEndpoints):

Failed to remove private Paykit endpoints before clearing session: privateUnavailable
ERROR Failed to delete profile: privateUnavailable - EditProfileView

Profile reset sequence (both sides)

  1. Profile delete → contacts removed, PAYKIT_SESSION / PAYKIT_SDK_STATE cleared
  2. Re-create → homeserver returns 409 User already exists → app signs in with existing key (same pubky identity)
  3. Public Paykit endpoints sync; no successful private encrypted-link handshake in logs
  4. After re-adding contact, RestoreReplayError persists through contact payments
  5. Second delete attempt fails — user stuck unless disconnect/retry workaround is used

Likely cause: local Paykit SDK state is wiped on delete/re-create, but encrypted-link handshake state is inconsistent across peers. SDK reports recovery is required; the app logs warnings, skips private publish, and resolves contact payments via public endpoints (intentional fallback).

Useful grep patterns:RestoreReplayError, Encrypted Link recovery, PrivateUnavailable, Failed to delete profile, Handling decoded scan data: OnChain


Verdict

ScopeResult
Session 1 — fresh profiles: contacts, on-chain + LN, private receive (“Received from contact”)✅ Pass (smoke)
Session 2 — profile reset: contact payments work (public fallback)✅ By design
Session 2 — private Paykit restored; “Received from contact” on receiveRegression
Session 2 — second profile delete blocked (PrivateUnavailable)Regression

Not approving on “private contact payments survive profile delete/re-add.” Session 1 private Paykit looks fine; session 2 regresses on private Paykit recovery and blocks a second profile delete.

@ben-kaufman

ben-kaufman commented Jul 2, 2026

Copy link
Copy Markdown
ContributorAuthor

Fixed in 82bb55cf6 on Android and 51b7c2ce on iOS.

Main thing is we now use Paykit v0.1.0-rc23, which includes the SDK fix for the stale recovery-required encrypted-link state after deleting/recreating a profile. It also fixes the Ring startSignInAuth Tokio runtime crash, so Android is pinned to rc23 now too.

I also fixed the related app-side edges:

  • sign out/delete no longer get blocked if private cleanup is temporarily unavailable
  • pending private drain retries now keep all queued peers instead of replacing older ones
  • auth approval uses the capabilities from the actual auth URL
  • if Ring auth completes but the app flow is canceled/superseded, we clear that session
  • blank SDK profile names fall back to the saved contact label

Public fallback while private recovery/link work is unavailable is still intentional so contact payments can still complete. Ring is still public-only for now; this fixes the crash path, not full Ring private payments support.

Comment threadapp/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt Outdated
Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt Outdated
@piotr-iohk

Copy link
Copy Markdown
Collaborator

@ben-kaufman is pubky-ring option disabled?
Gating_no_profile_pubky_profile_1_-_Contactsprofile_entry_points_lead_to_choice_screen-2026-07-02T10-13-40-607Z

@ben-kaufman

Copy link
Copy Markdown
ContributorAuthor

@piotr-iohk Added it back for now, but we will likely remove it, still waiting for final decision on that...

@piotr-iohk

Copy link
Copy Markdown
Collaborator

@piotr-iohk Added it back for now, but we will likely remove it, still waiting for final decision on that...

OK, atm clicking at Import with Pubky ring results in error toast. Not sure then if we want to resolve that or just leave for now? that is on both iOS and Android

Screen.Recording.2026-07-03.at.12.44.46.mov

@piotr-iohk

Copy link
Copy Markdown
Collaborator

Manual regression retest (Jul 3, post rc23)

Environment: regtest, staging
PRs:bitkit-android #1040 · bitkit-ios #606
Build:codex/paykit-sdk-native-integration, Paykit v0.1.0-rc23

Logs:

Same flow as Jul 1: create profiles → add contacts → LN + on-chain (verify private) → delete → re-create (same pubky) → re-add → LN + on-chain → delete again.


Results

StepAndroidiOS
Session 1 — profiles, contacts, LN + on-chain
Session 1 — private receive (“Received from [contact]”)
Session 1 — RestoreReplayError in logsNot seenNot seen
Delete → re-create → re-add contact
Session 2 — LN + on-chain (payments complete)
Session 2 — private Paykit / “Received from [contact]”
Session 2 — RestoreReplayError after re-add
Second profile delete (while private Paykit broken)

Session 2 — private Paykit still broken after profile reset

After delete/re-create/re-add, private link fails again on both platforms:

RestoreReplayError: failed to restore Encrypted Link handshake
Encrypted Link recovery is required for counterparty …
Private Paykit is not available (deferred publish)

Contact payments still complete via public fallback (by design). On Android, post-reset sends resolve to public BIP21 bcrt1qd8yaa9mwfcr5wwqyd999wmuj2vpyfs4s5emuy4?lightning=… after RestoreReplayError on the contact payment path — same pattern as Jul 1. UI: no “Received from [contact]” on incoming activity.

First failures after re-add: ~10:52 Android, ~10:52 UTC iOS.


Fixed since Jul 1 — profile delete no longer blocked

Second delete succeeds even when private cleanup fails. Logs show PrivateUnavailable warnings during cleanup, but noFailed to delete profile: privateUnavailable (iOS) and profile/session clears (Deleted all contacts, PAYKIT_SESSION removed). Jul 1 blocker is resolved.


Verdict

ScopeResult
Session 1 smoke (private contact payments)✅ Pass
Public fallback when private unavailable✅ By design
Private Paykit recovery after profile delete/re-addStill failing (rc23 did not fix this in manual test)
Profile delete when private cleanup failsFixed

Not approving on “private contact payments survive profile delete/re-add.” Happy to re-test after another SDK/app fix; delete trap fix looks good.

Useful grep patterns:RestoreReplayError, Encrypted Link recovery, PrivateUnavailable, Handling decoded scan data: OnChain, Deleted all contacts

@ben-kaufman

Copy link
Copy Markdown
ContributorAuthor

Fixed and tested now. I reran the Android rc26 E2E with two fresh dev installs: Bitkit profiles on both sides, Pay Contacts enabled, contacts added/resolved both ways, Alice paid Bob from Send -> Contact, and Bob's received activity was assigned to Alice with the contact chip + Detach action. I also checked the app logs/DB for the run: no no-endpoint/public-fallback/private-unavailable/send-failure markers, and both latest activity rows have the expected contact keys.

@piotr-iohk

Copy link
Copy Markdown
Collaborator

Manual regression retest (Jul 7)

Environment: regtest, staging
PRs:bitkit-android #1040 · bitkit-ios #606
Build:codex/paykit-sdk-native-integration, Paykit v0.1.0-rc23

Logs:

Cross-platform pair: Android ↔ iOS sim. Same flow as prior retests (Jul 1 / Jul 3) plus PR QA checklist from #1040.


PR QA checklist

#TestAndroidiOS
1Create/edit profile → add contact → contact survives restart
2Send → Contact → pay (private first, public fallback ok)
3Backup/restore wallet with Pubky → pay contact
4Settings → Payment Preference → toggle public/private
5Sign out / delete / disconnect — cleanup then local state cleared

Session flow (regression focus)

StepAndroidiOS
Session 1 — fresh profiles, contacts, LN + on-chain
Session 1 — private contact payments
Delete → re-create (same pubky, 409 → sign-in) → re-add contact
Session 2 — LN + on-chain after reset
Session 2 — private contact payments (incl. “Received from [contact]”)
Second profile delete in same session

Jul 3 blockers — status in this run:

  • RestoreReplayError / encrypted-link recovery after profile reset → not seen (fixed)
  • Profile delete blocked by PrivateUnavailablenot seen (still fixed)

Log support: multiple PaymentSuccessful / Lightning payment successful on both sides; iOS setContact after incoming payments in session 1 and session 2; Deleted all contacts on both platforms without Failed to delete profile.


Known issue — deferred (Android only)

Pubky Ring profile import on Android fails after Ring returns auth success:

Received Pubky Ring auth success callback
Auth approval failed: code=identity_error, context=complete Pubky auth flow
Screenshot 2026-07-07 at 13 56 09

UI: “Authorization Failed” toast on Join the Pubky Web screen (Import with Pubky Ring).

iOS: Ring import works (Pubky auth completed for pubkyc97…).

Agreed with @ben-kaufman on Slack to merge without blocking on this — Android Ring import tracked as follow-up, not a Paykit SDK regression.


Verdict

ScopeResult
Paykit SDK integration — contact payments, profile lifecycle, backup/restore✅ Pass
Private Paykit recovery after profile delete/re-add (Jul 3 regression)✅ Pass
Profile delete when private cleanup flaky✅ Pass
Android Pubky Ring import❌ Deferred (Android-only, post-merge)

LGTM on #1040 / #606 for merge, modulo deferred Android Ring import.

piotr-iohk
piotr-iohk previously approved these changes Jul 7, 2026

@piotr-iohkpiotr-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.

tACK

@jvsena42jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approved except for one comment that worth addressing

Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/data/keychain/Keychain.kt Outdated
Comment threadapp/src/main/java/to/bitkit/repositories/PubkyRepo.kt Outdated
@ben-kaufman

ben-kaufman commented Jul 8, 2026

Copy link
Copy Markdown
ContributorAuthor

@jvsena42 Fixed in 0c0dd99. Ring auth completion now returns a failed Result if the auth attempt is canceled/superseded while waiting for approval, instead of throwing or waiting forever. Also cleaned up the Keychain runBlocking nits from the review.

@jvsena42
jvsena42 enabled auto-merge July 8, 2026 13:57
@jvsena42
jvsena42 merged commit b3212d6 into masterJul 8, 2026
31 of 33 checks passed
@jvsena42
jvsena42 deleted the codex/paykit-sdk-native-integration branch July 8, 2026 18:06
@piotr-iohkpiotr-iohk mentioned this pull request Jul 21, 2026
5 tasks
Sign up for freeto 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.

5 participants

@ben-kaufman@piotr-iohk@jvsena42@ovitrif@Jasonvdb