Skip to content

feat: support paykit receiver paths#1066

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

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

Conversation

@ben-kaufman

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

Copy link
Copy Markdown
Contributor

This PR:

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

Description

Paykit now supports multiple apps under one Pubky identity. This change adopts that model without changing Bitkit's contact UI: a person remains one contact, while their saved SDK contact record can track multiple receiver paths.

Private links are maintained for supported receivers that advertise private capabilities. Receiving details are published independently per receiver and only when that receiver can send payments, so a bitkit/server receiver can be discovered and linked without receiving wallet invoices or addresses. Public contact payments and fallback remain scoped to bitkit/wallet.

Receiver-record failures are isolated per contact, and rescanning an existing contact refreshes only that contact while showing the validation result immediately.

No migration is included because the receiver-path Paykit state has not shipped and existing development state can be discarded.

Companion iOS PR: synonymdev/bitkit-ios#620

Preview

N/A

QA Notes

Manual Tests

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

Automated Checks

  • PrivatePaykitRepoTest.kt and PublicPaykitRepoTest.kt cover receiver-scoped resolution, publication, cleanup, reservations, and fallback behavior.
  • RefreshContactPaykitReceiversUseCaseTest.kt, PrivatePaykitRepoTest.kt, and contact view-model tests cover targeted receiver refresh and per-contact failure isolation.
  • Full testDevDebugUnitTest, compileDevDebugKotlin, and detekt passed locally.
  • git diff --check passed.

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

Copy link
Copy Markdown

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

@greptile-apps

greptile-apps Bot commented Jul 10, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds receiver-path support to Paykit contact payments. The main changes are:

  • Paykit is updated to the receiver-path-aware SDK APIs.
  • Contacts now store supported bitkit/wallet and bitkit/server receiver paths.
  • Private links, invoices, addresses, retries, and cleanup are scoped by receiver path.
  • The app publishes or removes the local wallet receiver marker based on sharing settings.
  • Contact import and existing-contact refresh flows now refresh receiver discovery.

Confidence Score: 4/5

The receiver-path flow has state cleanup and marker lifecycle bugs that need fixes before merging.

  • Turning off public sharing can remove the wallet receiver marker while private sharing is still expected to work.
  • A single-contact refresh can leave other saved contacts missing from the in-memory rotation set after restart.
  • Receiver cleanup can miss saved paths that are not present in linked-peer state or local published-path cache.

PrivatePaykitRepo.kt and SettingsViewModel.kt

Important Files Changed

Filename Overview
app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt Adds receiver-path scoping for private Paykit publication, retries, invoices, address reservations, and cleanup; several changed state paths can leave contacts or receiver paths out of later cleanup and rotation.
app/src/main/java/to/bitkit/services/PaykitSdkService.kt Adapts Paykit SDK calls to receiver-path-aware APIs and adds receiver marker publication and discovery helpers.
app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt Scopes public contact resolution to the wallet receiver path and centralizes local receiver marker sync.
app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt Updates sharing cleanup and marker synchronization, with one path that can remove the marker while private sharing should remain discoverable.
app/src/main/java/to/bitkit/usecases/RefreshContactPaykitReceiversUseCase.kt Adds a targeted receiver refresh use case for rescanning or opening an existing contact.

Reviews (1): Last reviewed commit: "fix: harden paykit receiver refresh" | Re-trigger Greptile

Logger.warn("Failed to remove public Paykit endpoints after disabling Paykit UI", it, context = TAG)
}
} else if (hadPrivatePaykitState) {
publicPaykitRepo.syncLocalReceiverMarker(publicSharingEnabled = false, privateSharingEnabled = false)

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 Private Marker Gets Removed

When public sharing is turned off while private contact payments remain enabled, this call forces both sharing flags to false. syncLocalReceiverMarker() treats that as not discoverable and removes the wallet receiver marker, so other users can no longer discover this wallet's private Paykit capabilities.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This call only runs when the entire Paykit feature is disabled, so false/false and marker removal are intentional here. Turning off public sharing alone goes through syncPublishedEndpoints(false), which leaves the private setting in place, so syncLocalReceiverMarker keeps the marker while private sharing is enabled.

runSuspendCatching {
val keys = rememberSavedContacts(publicKeys, replacing = true)
if (!canPublishPrivateEndpoints()) return@runSuspendCatching
val keys = rememberSavedContacts(listOf(publicKey), replacing = false)

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 Saved Contact Set Stays Partial

This refresh path now adds only the scanned contact to the in-memory knownSavedContactKeys set. After a cold start, refreshing contact A no longer repopulates the full saved contact list, so later invoice or on-chain activity for saved contact B can be skipped by the rotation paths that check publicKey in knownSavedContactKeys.

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.

Good catch. Fixed in 48df7a477: targeted refresh now registers the full current saved-contact set while still republishing only the selected contact. The use-case test verifies that the complete set is passed.

Comment on lines +1107 to +1116
private suspend fun receiverPathsForCleanup(publicKey: String): List<String> {
val paths = (
linkedReceiverPaths(publicKey) +
publishedPrivatePaymentReceiverPaths(publicKey)
)
.filter { it in PaykitReceiverPaths.supported }
.distinct()
.sorted()
return paths
}

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 Cleanup Misses Saved Receiver Paths

Endpoint removal now clears only receiver paths found in linked peers or in the local published-path cache. If a contact has a saved bitkit/server receiver whose link record is absent and whose published cache was cleared or never recorded locally, deleting the contact or disabling sharing will not call clearPrivatePaymentList for that receiver path, leaving stale private payment details available remotely.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is intentional. A saved receiver path only means the receiver was discovered, not that private payment details were shared. We clear every path that is linked or recorded as published. Any path that received a private list has linked state, while queued publication is also retained in local publication state. Clearing every saved path could instead fail cleanup for receivers that never linked or received a list.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

That makes sense — the invariant holds: any path that received a private list will have linked state, and any queued publication is retained locally, so the union of those two sets is the complete cleanup scope. Acknowledged, this is correct behavior.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

Comment on lines 839 to 844
.mapNotNull { peer ->
normalizedPublicKey(peer.counterparty)?.let { publicKey -> publicKey to peer.state }
normalizedPublicKey(peer.counterparty)?.let { publicKey ->
PrivateMessageDrainRetryKey(publicKey, peer.counterpartyReceiverPath) to peer.state
}
}
.toMap()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Duplicate Peer State Is Lost

This map now keeps only one LinkedPeerState per (publicKey, receiverPath). If the SDK returns duplicate peer records for the same receiver path, such as one LINKED entry and one BLOCKED entry during recovery, the last entry wins and the retry decision can stop draining pending outbound messages even though a linked peer still exists.

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.

The SDK cannot return duplicate records for this key. rc33 stores linked_peers in a HashMap<(PubkyPublicKey, PaykitReceiverPath), LinkedPeerRecord> and linkedPeers() returns its values, so each (publicKey, receiverPath) pair is unique. Converting the result to a map is safe here.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant