Skip to content

feat: support incoming paykit requests - #1098

Open
ben-kaufman wants to merge 4 commits into
masterfrom
codex/paykit-incoming-payment-requests-rc39
Open

feat: support incoming paykit requests#1098
ben-kaufman wants to merge 4 commits into
masterfrom
codex/paykit-incoming-payment-requests-rc39

Conversation

@ben-kaufman

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

Copy link
Copy Markdown
Contributor

Description

This PR builds on #1084 to support incoming Paykit payment requests:

  • Updates Paykit to 0.1.0-rc39 and uses its separate public and private payment resolution APIs.
  • Receives supported one-time Bitcoin payment requests while Bitkit is active, discarding expired or unsupported requests.
  • Reuses the existing send confirmation and payment flow, with a Payment Request title, fixed requested amount, and requesting contact.
  • Accepts a request only after the user approves it, before submitting the payment.
  • Uses public payment details only when no Noise channel is established and never falls back from private to public resolution.
  • Persists consumed private payment-list versions before submission, treats submitted, pending, and uncertain payments as consumed, waits for newer details, and serializes private payments per contact and receiver path.
  • Advertises payment-request support with the live Paykit session.

Payment proofs and receipts remain out of scope.

References:

Preview

N/A — the existing payment UI is reused, and no media is attached.

QA Notes

Manual Tests

  • 1. Receive a request from a linked Paykit contact while Bitkit is active: the Payment Request confirmation opens with the contact and requested amount.
  • 2. Swipe to pay: the request is accepted and payment continues through the existing send flow.
  • 3. Receive or retain a request past its expiration: it is discarded and not presented.
  • 4. Attempt another private payment before the contact publishes a newer payment list: Bitkit waits and does not reuse the consumed details or fall back to public details.

Automated Checks

  • PaykitPaymentRequestRepoTest.kt: request mapping, expiry, lifecycle, and acceptance.
  • PrivatePaykitRepoTest.kt: separate resolution, consumed-list persistence, and prevention of private payment-detail reuse.
  • AppViewModelSendFlowTest.kt: presentation, polling, approval order, expiry, and duplicate or pending consumption.
  • Gradle compilation, affected unit tests, and Detekt passed.

@greptile-apps

greptile-apps Bot commented Jul 22, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds support for incoming Paykit payment requests. The main changes are:

  • Polls for supported requests while the app is active.
  • Reuses the send confirmation flow with fixed request details.
  • Separates private and public payment resolution.
  • Persists consumed private payment-list versions.
  • Updates Paykit capabilities, SDK adapters, and related tests.

Confidence Score: 4/5

The legacy backup restore path needs a compatibility fix before merging.

  • Backups created by previous releases are raw SDK-state strings.
  • The updated restore path requires the new JSON envelope and fails before restoring old data.
  • The request lifecycle and private payment flow otherwise have consistent guards and state handling.

app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt

Important Files Changed

Filename Overview
app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt Adds private resolution and consumed-version persistence, but the new backup envelope breaks restoration of legacy backups.
app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt Adds synchronized request intake, filtering, expiration, and acceptance.
app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt Connects request polling and presentation to the existing payment flow.
app/src/main/java/to/bitkit/services/PaykitSdkService.kt Adapts the service to separate public and private Paykit APIs and advertises request support.

Sequence Diagram

sequenceDiagram
    participant Contact as Paykit Contact
    participant SDK as Paykit SDK
    participant Requests as Payment Request Repo
    participant App as App View Model
    participant Private as Private Paykit Repo
    participant Wallet as Send Flow

    Contact->>SDK: Publish payment request
    Requests->>SDK: Receive and query requests
    Requests-->>App: Emit actionable request
    App->>Private: Resolve private payment details
    Private->>SDK: Resolve after consumed version
    SDK-->>Private: Endpoint and list version
    Private-->>App: Open payment details
    App-->>Wallet: Show confirmation
    Wallet->>App: User approves
    App->>Requests: Accept request
    App->>Private: Persist consumed version
    App->>Wallet: Submit payment
Loading

Reviews (1): Last reviewed commit: "feat: support incoming paykit requests" | Re-trigger Greptile

paykitSdkService.clearState()
} else {
paykitSdkService.restoreBackupState(backup)
val decoded = json.decodeFromString<PrivatePaykitBackup>(backup)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Legacy Backups Cannot Restore

Previous releases stored exportBackupState() as a raw string, but this line now decodes every non-null backup as PrivatePaykitBackup JSON. Restoring a backup created before this change therefore fails during decoding and never calls restoreBackupState(); the restore path needs to recognize and migrate the legacy format.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

No code change here: this raw backup format only exists in the unshipped parent Paykit work, so there are no production backups to migrate. Per the pre-release scope of this stack, we are intentionally not adding migration or backward-compatibility code.

@ben-kaufman
ben-kaufman force-pushed the codex/paykit-watch-only-accounts branch from 5050c1e to edb688f Compare July 22, 2026 10:15
@jvsena42 jvsena42 added this to the 2.5.0 milestone Jul 22, 2026
@ben-kaufman
ben-kaufman force-pushed the codex/paykit-incoming-payment-requests-rc39 branch 4 times, most recently from eda7df2 to 4457134 Compare July 24, 2026 09:57
@ovitrif
ovitrif force-pushed the codex/paykit-watch-only-accounts branch from 57626b7 to 1ceb420 Compare July 27, 2026 15:25
@ben-kaufman
ben-kaufman force-pushed the codex/paykit-incoming-payment-requests-rc39 branch from 4457134 to ece10d4 Compare July 27, 2026 18:44
Comment thread app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Comment thread app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Comment thread app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Comment thread app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt Outdated
Comment thread app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt Outdated
Comment thread app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt Outdated
Comment thread app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
Comment thread app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt Outdated
Comment thread app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt Outdated
@ben-kaufman
ben-kaufman force-pushed the codex/paykit-watch-only-accounts branch 3 times, most recently from 1e944d0 to 75610d7 Compare August 3, 2026 04:39
@ben-kaufman
ben-kaufman force-pushed the codex/paykit-incoming-payment-requests-rc39 branch from cf14578 to 44d58d2 Compare August 3, 2026 07:36

Copy link
Copy Markdown
Contributor Author

Restacked onto the current #1084 head after its force-push. The request/review-fix commits are signed; the retry fix previously referenced as cf14578b1 is now e01a3ce25, with 44d58d2b8 preserving the Paykit rc39 integration across the rewritten base.

The current build failure is inherited unchanged from #1084: both jobs fail in WatchOnlyAccountStore.kt on unresolved serializedExtendedPubkey references at lines 6 and 29. I left #1084 untouched, as it is being handled separately.

@ben-kaufman
ben-kaufman force-pushed the codex/paykit-watch-only-accounts branch from ec8ee8c to 41ddb6b Compare August 3, 2026 11:10
@ben-kaufman
ben-kaufman force-pushed the codex/paykit-incoming-payment-requests-rc39 branch from 44d58d2 to 796bc2b Compare August 3, 2026 11:13
Base automatically changed from codex/paykit-watch-only-accounts to master August 3, 2026 12:30

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

Re-reviewed the changes since my last pass. All 8 comments I raised are addressed — resolved those threads. Verified locally on 796bc2b1: the Paykit unit tests (PaykitPaymentRequestRepoTest, PrivatePaykitRepoTest, PublicPaykitRepoTest, AppViewModelSendFlowTest, PaykitSdkServiceTest, ContactPaymentSettingsRepoTest) all pass and detekt is clean.

A few new findings below. Only the first one blocks.

One extra nit that has no diff line to attach to: PrivatePaykitRepoTest.kt:32import org.mockito.kotlin.anyOrNull is now unused (base had 4 usages, this branch has 0). detekt reports it as NoUnusedImports, but ignoreFailures = true in app/build.gradle.kts keeps CI green.

Comment on lines +1363 to +1366
private suspend fun hasPrivatePaymentAccessForCurrentProfile(): Boolean {
pubkyService.currentPublicKey() ?: return false
return paykitSdkService.hasPrivatePaymentAccess()
}

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.

This dropped the runSuspendCatching {}.getOrDefault(false) that wrapped the helper on master, and that turns a settings toggle into a crash path.

pubkyService.currentPublicKey() reaches PaykitSdkService.currentPublicKey()handle() + handle.initialize(), which throws PaykitException. The public hasPrivatePaymentAccess() (line 121) is called by ContactPaymentSettingsRepo.enable() outside its runSuspendCatching:

val canUsePrivateContactPayments = privatePaykitRepo.hasPrivatePaymentAccess()  // throws
return runSuspendCatching { ... }

So the throw escapes setEnabled()'s Result<Unit> contract and lands in SettingsViewModel.setContactPaymentsEnabled's bare viewModelScope.launch — uncaught coroutine exception, and _isUpdatingContactPayments stays stuck at true. PayContactsViewModel.continueToProfile has a finally so it only loses the toast, but the exception still escapes.

The other callers (canPublishPrivateEndpoints, prepareRelevantPrivateLinksIfAvailable) are all inside a runSuspendCatching, so hasPrivatePaymentAccess() is the single exposure — but it's user-reachable. Please restore the guard:

private suspend fun hasPrivatePaymentAccessForCurrentProfile(): Boolean = runSuspendCatching {
    pubkyService.currentPublicKey() ?: return@runSuspendCatching false
    paykitSdkService.hasPrivatePaymentAccess()
}.getOrDefault(false)

Comment on lines +120 to +121
PublicPaykitPaymentResult.WaitingForUpdatedPaymentList ->
showPayError(R.string.slashtags__error_pay_empty_msg)

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.

slashtags__error_pay_empty_msg means "no payment endpoint found", but the actual state here is "waiting for the contact to publish an updated payment list" — a transient, retryable condition the user could act on by waiting. Worth a dedicated string.

The same branch is duplicated verbatim in SendContactSelectViewModel and AddContactViewModel; folding the PublicPaykitPaymentResult → message mapping into one shared helper would keep the three in sync.

Comment on lines +2585 to +2587
if (!validateAndAcceptIncomingPaymentRequest(contactPaymentContext)) return

consumePrivatePaymentListIfNeeded(contactPaymentContext).onFailure {

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.

Separate from my earlier consume-before-send comment (which you answered — the rc39 contract makes that unavoidable, understood): the concern here is the window between these two calls.

validateAndAcceptIncomingPaymentRequest accepts the request on the Paykit side and removes it from pendingRequests. If consumePrivatePaymentListIfNeeded then fails (e.g. PaymentListAlreadyConsumed), we've accepted a request, made no payment, and there's no path left to retry or decline it — it's simply gone.

Consuming first (or accepting last) would close that gap. incoming payment request is accepted before its private list is consumed pins the current order, so I assume this is deliberate — just want to confirm it's the order you want.

Comment on lines +3686 to +3691
private val PAYKIT_PAYMENT_REQUEST_PRESENTATION_RETRY_DELAYS = listOf(
30.seconds,
60.seconds,
120.seconds,
300.seconds,
)

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.

The retry schedule saturates at 300s and never gives up. A request with expiresAt == null that never resolves will keep issuing beginPaymentRequest network calls every 5 minutes for as long as the app is foregrounded.

A max-attempt cap — after which the request falls back to the normal poll cycle — would bound it.

Comment on lines +690 to +692
if (result !is PublicPaykitPaymentResult.Opened || !paykitPaymentRequestRepo.isPending(request)) {
if (paykitPaymentRequestRepo.isPending(request)) deferPaymentRequestPresentation(request)
return false

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.

Nit: paykitPaymentRequestRepo.isPending(request) is evaluated twice on adjacent lines — worth hoisting into a local.

Also, the Boolean return means two different things: true at line 689 is "abort, a sheet opened" while true at line 703 is "presented". The caller treats both as return, so behaviour is right, but the name openIncomingPaymentRequestIfAvailable reads like it returns "did I open it". A small sealed result, or a rename to something like presentIncomingPaymentRequestOrStop, would make the loop easier to follow.

Comment on lines +57 to +58
fun acceptsLightningInvoiceAmountMsats(amountMsats: ULong?): Boolean =
amountMsats == null || amountSats <= ULong.MAX_VALUE / 1000uL && amountMsats == amountSats * 1000uL

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.

Nit: the amountSats <= ULong.MAX_VALUE / 1000uL guard is already enforced at construction by toSats() (takeIf { it <= ULong.MAX_VALUE / 1000uL }), so it's dead weight here. The unparenthesized ||/&& mix also reads ambiguously even though the precedence is correct — parentheses around the && clause would help.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants