fix: clean up deleted private contacts - #830
ben-kaufman wants to merge 6 commits into
Conversation
|
| let record = try await sdk.saveContact(update: Paykit.ContactUpdate(publicKey: publicKey, receiverPaths: contactPaths, label: label)) | ||
| if restorePrivateConnection { | ||
| for peer in try await sdk.linkedPeers() where peer.state == .blocked && PubkyPublicKeyFormat.matches(peer.counterparty, publicKey) { | ||
| _ = try await sdk.unblockPeer(counterparty: peer.counterparty, counterpartyReceiverPath: peer.counterpartyReceiverPath) |
There was a problem hiding this comment.
Failed re-add can strand contact If
linkedPeers() or unblockPeer() throws during an explicit re-add, this code has already saved the contact record but reports that adding it failed. After Contacts reloads that record, another add is rejected as a duplicate, so the user cannot retry restoring the blocked private connection through the add flow.
Knowledge Base Used: Contacts and Pubky identity
There was a problem hiding this comment.
Fixed by restoring blocked receivers before saving the contact. If peer lookup or an unblock fails, the contact is not persisted, so retrying the explicit add works. The lifecycle regression covers lookup failure and partial unblock failure.
Commit: 75e8c9c.
| markApprovedForPayment: true, | ||
| preservePending: !request.requiresAcceptance | ||
| ) { | ||
| try await service.ensurePaymentAllowed($0) |
There was a problem hiding this comment.
Approved requests bypass blocking If a request is approved and its peer is then blocked while the send sheet remains open, the send flow skips
prepareForPayment, which is the only place this new check runs. Confirmation can therefore dispatch payment to the blocked contact without checking again.
Knowledge Base Used: Payment request management
There was a problem hiding this comment.
Fixed the cached-approval path: every preparation checks current peer blocking without repeating acceptance or private-list consumption. Lightning, LNURL, onchain and hardware flows also check after preparation before their first send, and remove the prepared proof when authorization is denied. Added cached-approval regression coverage.
Commit: 75e8c9c.
| guard let report = try await PaykitSdkService.shared.clearPrivatePaymentList(to: publicKey, receiverPath: receiverPath) | ||
| else { continue } |
There was a problem hiding this comment.
Skipped cleanup loses retry If blocking leaves a previously published private list in place, this
continue treats its skipped remote clear as successful cleanup. The app then erases the local publication state and retry marker, so it has no cleanup work left to perform before an explicit re-add unblocks the peer.
Knowledge Base Used: Paykit payment modes
There was a problem hiding this comment.
This is not conditional. PaykitSdkService.removeContact blocks every known receiver path before ContactsManager calls removeSavedContact, so clearPrivatePaymentList returns nil on every deletion, and the deleted contact keeps the last private payment list we sent them. Before this PR the clear was delivered. Keeping the cleanup pending would not help either: blockPeer drops the encrypted link, and the peer stays blocked until an explicit re-add. The clear needs to run before the paths are blocked.
There was a problem hiding this comment.
You’re right — I missed the ordering across the two services. ContactsManager.removeContact invokes PaykitSdkService.removeContact before PrivatePaykitService.removeSavedContact, and the former blocks every known receiver path. Therefore, clearPrivatePaymentList necessarily returns nil during this cleanup; retaining the retry marker would not make it deliverable while the peer remains blocked.
The cleanup must occur before the SDK contact removal/blocking (or otherwise explicitly clear the list before blocking). Given that ordering, the continue here is intentional, and my “skipped cleanup loses retry” comment does not apply. Retracting it.
There was a problem hiding this comment.
Correct about the ordering. Deletion now attempts empty-list delivery to linked receivers inside the existing SDK operation lock before blocking them, so another publication cannot run between withdrawal and revocation. A delivery failure still allows local blocking and contact removal. Post-block cleanup continues to skip network delivery. Tests cover the ordering and failed withdrawal. This preserves the withdrawal attempt, but it cannot retract invoices already shared and can still wait on the SDK network timeout.
Commit: 75e8c9c.
There was a problem hiding this comment.
Resolved. The best-effort clear now runs before blockPeer under operationLock. When it cannot be delivered, the contact keeps the last list, which is the accepted limitation.
jvsena42
left a comment
There was a problem hiding this comment.
Two MEDIUM and one LOW inline, all shared with synonymdev/bitkit-android#1372. I also replied on the cleanup thread: the remote private-list clear is skipped on every deletion. Paykit is on by default on master since #818 (unreleased).
Checked and clean:
removeContact/saveContactrun underoperationLock, so block → remove → unblock cannot interleave.- SDK handshakes reject blocked peers, so a racing link burst cannot re-link.
- The detached delete task is not cancelled with the view.
contactsRevisiondiscards a stale load.- The
restorePrivateConnection || existing != nilguard stops background refreshes recreating a deleted contact, and every explicit add/import passestrue. - Public markers cannot fire under
.localOnly. - Blocked state is scoped per identity.
- A partial block failure is recoverable by retrying the delete, as the new test asserts.
| } | ||
| } | ||
|
|
||
| func removeContact(publicKey: String) async throws -> Paykit.ContactRecord? { |
There was a problem hiding this comment.
Contacts deleted before this change stay linked and keep presenting payment requests after upgrade.
On v2.5.0, removeContact only called sdk.removeContact, which deletes the contact record (contacts.rs:71) and leaves the peer Linked. receive_private_messages_from_linked_peers pulls from every linked peer whether or not a contact exists, and synchronize() hides only .blocked peers (PaykitPaymentRequestService.swift:533). Nothing on the load path (ContactsManager.loadContacts → pruneUnsavedContactState) blocks them. The changelog claim does not hold for anyone who deleted a linked contact before this release. Affects opted-in users on released builds.
Fix: after a successful full load, run a one-time reconciliation that blocks every linkedPeers() entry whose counterparty has no contact record.
Same in synonymdev/bitkit-android#1372.
There was a problem hiding this comment.
Paykit has not launched yet, so no backward compatibility is needed. We are not adding an orphan-peer reconciliation, migration flag, or upgrade cleanup for pre-launch state.
| PaykitPaymentRequest(historyRecord: $0, now: synchronizationDate) | ||
| } | ||
| let subscriptions = records.compactMap { PaykitSubscription(record: $0) } | ||
| let subscriptions = availableRecords.compactMap { PaykitSubscription(record: $0) } |
There was a problem hiding this comment.
Deleting a contact hides an accepted subscription instead of ending it, and re-adding the contact re-presents every missed period.
Once the peer is blocked, the subscription drops out of subscriptions, so cancel() throws requestUnavailable (1447). The SDK also refuses cancel on a blocked peer, so this cannot be fixed by cancelling afterwards. subscriptionAcceptedAt (1738-1743) is never pruned, while dismissedSubscriptionPaymentIds.formIntersection(activeRecurringRequestIds) (1755) drops the hidden subscription's dismissals.
After an explicit re-add unblocks the peer, requests(through:acceptedAt:) regenerates every unpaid period since the original acceptance, including dismissed ones, and each is presented. Nothing pays without a confirm. Before this change the subscription stayed visible and cancellable.
Fix: cancel (or warn about) the counterparty's active payer subscriptions before blockPeer, and move the acceptance anchor to the re-add date on unblock.
Same in synonymdev/bitkit-android#1372.
There was a problem hiding this comment.
Deletion now refuses to proceed while this contact has an active subscription, whether we are payer or payee, and shows "End active subscriptions before deleting this contact." The check runs before endpoint withdrawal or blocking. Canceled or naturally ended subscriptions allow deletion. This keeps the existing cancellation and in-flight proof safeguards intact. Covered in PaykitContactLifecycleTests.swift and the mirrored subscription-deletion journey.
Commit: 75e8c9c.
There was a problem hiding this comment.
Works. One LOW refinement: the guard counts fixed-term subscriptions whose endsAt is in the future (PubkyService.swift:659), but canCancel(at:) returns false whenever recurrence.endsAt != nil (PaykitSubscription.swift:478). A payer who accepted a fixed-term request can neither cancel it nor delete the contact until the term ends, while the refusal tells them to end the subscription. Bitkit creates requests with endsAt: nil, so this needs a proposal from another Paykit client, and isProposalActionable(at:) accepts those. Either allow cancelling fixed-term subscriptions or name the end date in the refusal. Same in synonymdev/bitkit-android#1372.
There was a problem hiding this comment.
Added the fixed-term end date to the deletion refusal. Open-ended subscriptions keep the cancel-first message.
| ) | ||
| await manager.refresh() | ||
| XCTAssertTrue(manager.pendingRequests.isEmpty) | ||
| XCTAssertEqual(manager.historyRequests.map(\.paymentRequestId), [record.paymentRequestId]) |
There was a problem hiding this comment.
This asserts history keeps the blocked contact's request, which production cannot do.
SDK paymentRequests() is "across non-blocked counterparties" (payment_request_counterparties, payment_requests.rs:203-210), so deleting a contact removes its requests from history too. This passes only because the mock does not apply that filter. The journey step "verify 'Before deletion' has no available Pay action" (delete-and-readd-contact.xml:11) passes trivially because the row is gone.
Fix: make the mock drop blocked counterparties and assert history no longer contains the request, and reword the journey step to "is no longer listed". Or keep history for deleted contacts on purpose. Same in synonymdev/bitkit-android#1372.
There was a problem hiding this comment.
Correct about normal SDK visibility. I updated the test to cover both timings: an earlier snapshot cannot leave a blocked request payable, and the next SDK snapshot removes it from history. The journey now waits for refresh and asserts the request is no longer listed. No separate history storage was added.
Commit: 75e8c9c.
jvsena42
left a comment
There was a problem hiding this comment.
Re-checked 75e8c9c and 171d449.
Open: one LOW, posted as a reply on the subscription thread. Fixed-term subscriptions block deletion but cannot be cancelled.
Resolved: the test/journey history thread and the private-list clear ordering.
Checked and clean:
- The
activeSubscriptionrefusal propagates unwrapped, so the toast shows. (On Android it does not; see synonymdev/bitkit-android#1372.) - The re-add rollback re-blocks every originally blocked path on any failure, including cancellation, and rethrows the original error.
- The cached-approval path re-checks through
prepareForPaymentfor Lightning, LNURL, on-chain and HW, and cleans up the proof on denial. - The ContactsManager load loop prunes only after a successful load, and the generation guard stops a stale load from clearing
isLoading.
There was a problem hiding this comment.
Advice: ✅ Approve
Review: diff 17 files.
The paired Android PR differs; see the parity finding.
Findings:
3 inline (2 MEDIUM, 1 LOW)
QA:
Tests queued.
Reviewed by gpt-6-sol-xhigh via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest (author)
|
|
||
| isBroadcastUnresolved = true | ||
| do { | ||
| try await beforeBroadcastAttempt() |
There was a problem hiding this comment.
MEDIUM: Android hardware retries still skip the peer authorization check.
This per-attempt check closes the iOS retry gap. The paired Android PR still calls beforeBroadcast only when its cached payment is not prepared, and that callback holds its peer check. After an Electrum failure, Android can reuse the signed transaction and broadcast after the contact is blocked. Could we move the Android peer check before every hardware broadcast attempt and add the same blocked-retry regression there?
There was a problem hiding this comment.
The iOS retry path is fixed here. The Android hardware retry change belongs in synonymdev/bitkit-android#1372, so I left this iOS branch unchanged.
Closes #838
This PR fixes private payments remaining available after a contact is deleted and contacts reappearing after a successful deletion.
Android companion: synonymdev/bitkit-android#1372
Description
Out of Scope
Design
N/A — no design available. Adds an error message to the existing contact-deletion toast.
Preview
VIDEO_1
QA Notes
Journeys
new
delete-and-readd-contact.xml— deletion survives refresh/restart, private requests stop, and explicit readd reconnects.new
delete-contact-with-active-subscription.xml— deletion requires the subscription to end, and re-add does not revive a canceled subscription.Both journeys are mirrored on iOS and Android and have not been run locally.
Manual Tests
N/A
Automated Checks
PaykitContactLifecycleTests.swift— receiver revocation, withdrawal before block including delivery failure, failed re-add retry, and subscription deletion guards for payer/payee, cancellation and expiry.ContactsManagerTests.swift— invalidated initial loads preserve unrelated contacts and reset stops a superseded load.PaykitPaymentRequestServiceTests.swift— blocked requests are not payable, cached approvals are rechecked without repeating acceptance, and subsequent history refresh matches SDK visibility.Deletion can still wait for existing SDK operations or endpoint-withdrawal network timeouts. No end-to-end deletion latency claim is made.