Skip to content

fix: import Shop contacts and refresh on return - #1485

Merged
ovitrif merged 6 commits into
masterfrom
codex/refresh-shared-contacts
Oct 15, 2026
Merged

ovitrif merged 6 commits into
masterfrom
codex/refresh-shared-contacts

Conversation

@ben-kaufman

@ben-kaufman ben-kaufman commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

This PR imports Shop seller markers into the buyer's shared Paykit contacts and refreshes contacts on foreground entry and contact deep links.

Closes #1482. Twin: synonymdev/bitkit-ios#934. SDK: pubky/paykit-rs#194 and RC76.

Description

  • Discover historical and new seller-auto-follow markers on foreground/resume and each existing payment-request poll, with no new timer. Process up to ten markers per invocation; short pages continue and an empty page restarts the scan.
  • Retain the cursor, handled sellers and pending refresh across fresh sign-in objects with the same public key and generation; reset only for a different identity or sign-in generation.
  • Require a matching seller and positive marker timestamp, plus a storefront with schema version 1, record type shop and the same owner. Any app authorized to write the buyer's pubky.app data can create a marker; a storefront is not proof of Shop provenance.
  • Use the SDK's atomic expected-identity import to add only missing, unblocked contacts. Preserve edited labels, existing metadata and removal blocks. No observe-then-save race or automatic unblock.
  • Refresh from a read started after the import, including retry after interrupted writes/refreshes. Coalesce overlap, fence identity changes and defer import writes during payment submission. Cancelling one refresh waiter preserves the read for other callers; the last waiter or identity reset still cancels it.
  • Retain fresh foreground/contact-link reads, existing contact-change linking and explicit payment approval. Shop does not need the buyer's Paykit key or a Paykit contacts API.

Ready for review. Remaining real Shop-to-Bitkit checkout validation is still open before merge. Large histories, unavailable data and existing link safety waits can require multiple polls; no fixed checkout-latency or complete checkout claim.

Out of Scope

No Shop implementation, new UI, automatic public follows, peer unblocking, payment submission, protocol/schema change, shortened safety wait or deployment. Import is not payment-acceptance evidence.

Design

N/A - no UI changes.

Preview

Reviewer staging checkout and scan recordings.

QA Notes

Historical staging results on 3a0630fb / RC76: Cases 1, 2, 4 and 5 passed with the qualifications in the linked report; Case 3 exposed the per-poll reset. 4c1c28c2a corrects that reset with red-before/green-after tests.

Reviewer emulator retests now report Cases 3, 6 and 7 passing on a Pixel 7 / Android 15 devDebug regtest build against staging, using debugger breakpoints to inspect transitions:

  • Case 3 passed on both 4c1c28c2 and current c7accdf1: 117 markers, seven valid sellers imported once each, ten-marker page progress without per-poll reset, wraparound and discovery of markers inserted before the cursor. The first pass took 11 minutes on 4c1c28c2; this is correctness evidence, not fast checkout or a current-head latency guarantee. A forced short intermediate page remains unverified.
  • Case 6 passed on 4c1c28c2: offline discovery recovered on the next poll; a refresh interrupted after save retained its cursor and pending refresh, refreshed after reconnect and ended with one row per seller.
  • Case 7 passed on 4c1c28c2: an older contact read was followed by a fresh read, and a held pre-save import rejected wallet reset with SignInChanged and no SDK write. The new identity saw no old contacts. Restoring the first identity reset its scan once and imported the held seller. These importer and PubkyRepo sources are unchanged by the c7accdf1 master merge; this is not a claim that all payment code is unchanged.

The Shop reviewer approved c7accdf1 after these results. The subsequent non-broadcast Case 8 run passed while the payment sheet remained open, but reproduced import resuming after Back while payment submission was still running; 8c was not reached. The running-operation gate now fixes that boundary with regressions for plain/contact/request sends. Device 8b/8c and caller-level queued cancellation must be retested on the new head. The later hook-based cancellation pass cancels inside PaykitSdkService, not the parent PubkyService caller. Remaining acceptance stays open.

Repeated lookups of unreachable sellers remain a non-blocking performance follow-up. A negative cache would need bounded expiry and recovery checks so a previously unavailable seller can still be imported promptly; no cache or polling change is included here.

Journeys

  • J1 updated pubky-contact.xml: saved/unsaved/self routing, PIN protection and warm background-to-contact-link return.

Manual Tests

The shared marketplace journey fixture starts with linked contacts and does not supply marker/history seeding or controlled interruption. The reviewer used separate staging fixtures and debugger breakpoints. The checklist below retains unrun variants rather than treating every historical case label as a full current-head pass. Matching full steps are in journeys/pubky-marketplace/shop-contact-import.md on both platforms.

  • 1 Valid historical marker and storefront -> cold open, already-open poll and background/resume -> seller appears and linking begins without visiting Contacts.
  • 2 New marker and exact seller Payment Request -> existing poll imports/links -> request reaches payment review; stop before approval or payment.
  • 3 More than 100 markers, short intermediate page and insertion before cursor -> successive scans eventually import every valid seller without repeated writes.
  • 4 Edit a label, remove and block sellers before/during discovery -> poll and relaunch -> edits and removal blocks survive.
  • 5 Malformed/self/mismatched marker or invalid/missing storefront -> no import; correct failed data -> later scan retries.
  • 6 Interrupt discovery and the refresh after a committed import -> recover -> contact becomes visible without duplicate rows or lost edits.
  • 7 Hold an older contact read, import, then release it -> a newer read displays the seller; background/identity change cannot apply stale results.
  • 8 Queue import during payment submission -> import waits through software payment-sheet dismissal until the operation ends. Background/disable or identity change while queued must prevent a stale write; already-admitted writes finish safely. Repeat plain/contact/request paths.
  • 9 Foreground/contact-link callers share a held contact read -> cancel either caller -> the surviving caller receives fresh contacts. Cancel both or reset identity -> no stale contacts publish.

Automated Checks

  • added ShopContactImporterTest.kt: paging/rescan with freshly allocated equal sign-ins, marker/storefront validation, retry, coalescing, cancellation/identity changes and post-commit refresh recovery. The paging and pending-refresh cases fail before the fix; a separate table checks reset on a different key or generation.
  • updated PubkyRepoTest, ShopContactImporterTest, PaykitSdkServiceTest and AppViewModelSendFlowTest: atomic SDK forwarding, post-import fresh-read ordering and existing foreground/deep-link lifecycle coverage, plus payment deferral/reset and the existing ten-second poll.
  • removed no existing tests. Atomic label/block/identity/write-failure behavior is covered with real SDK storage tests; mobile mocks do not reimplement that algorithm.
  • Final review fixes: just compile, just test, just lint pass; 4,200 tests across 239 suites, zero failures/errors/skips. Four tests added, none removed: shared-reader cancellation, queued caller cancellation, admitted-write completion, and held software submission across dismissal. All 1,399 Detekt SARIF findings match the verified master baseline after normalizing only roots/line offsets. Compiler/format diagnostics introduce no findings. Suspend stubs follow the repository lambda convention. No dependency, safety-wait or payment-authorization change.
  • ran published-artifact verification: the resolved AAR SHA256 is 5df9b5e63df1f8b825003f07d612377d7c9e14f7d1ef74da11b5dd4673292e70, matching the independently downloaded GitHub Packages artifact and its checksum. Generated Kotlin matches the validated SDK binding, all four native ABIs are present, and no local Maven override remains. RC76 source 651695fcc07633f6df9d392ac761c71a412ed865 contains corrected directory cursor encoding/order, atomic import and SDK master through b0115c947df7c41d34f422b149cff23fbd882cd8. Dependency pins are unchanged by the polling fix. RC74 and RC75 remain untouched.
  • Current-head CI must complete after this push. Earlier successful checks or draft-only skips are not new-head evidence.
  • Bounded combined self-review covered cancellation/identity ownership, real queue admission, payment operation lifetime, direct callers, native conventions and test quality; no independent-agent review or new device acceptance run.

@ben-kaufman ben-kaufman changed the title fix: refresh shared contacts on app return fix: import Shop contacts and refresh on return Oct 14, 2026
@ben-kaufman

ben-kaufman commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor Author

@ovitrif Could you or the Shop/mobile reviewers run the remaining checkout acceptance checks on this draft? Ben has no disposable buyer/seller session available and asked that reviewers test the flow.

The importer is now on 3a0630fbff4c50f2c94bba1fe93a82e2441c7403, using the verified RC76 release from pubky/paykit-rs#194. RC76 includes the corrected encoded/Unicode directory pagination and SDK master through b0115c947df7c41d34f422b149cff23fbd882cd8. Please use the eight-case recipe: first-time unlinked seller, historical/new markers on cold open/foreground/existing poll, >100 markers, label/removal/block preservation, invalid data, interrupted refresh, stale-read/identity fencing and payment-priority handling. The recipe matches the iOS twin byte-for-byte.

Use an isolated buyer/seller fixture and record the app SHA, SDK version, device, request ID and observed transitions, without secrets. For the actual Shop request, stop at payment review: no funding, mining, approval or broadcast is requested. The payment-priority case needs a controlled non-broadcast fixture; leave it explicitly unrun if that capability is unavailable.

The forced published-artifact run passed all 4,005 unit tests; full lint and compiler diagnostics match baseline. That does not establish checkout acceptance. Please leave this PR draft until the relevant real checkout evidence is recorded, with any remaining cases clearly identified. iOS twin and matching QA request: synonymdev/bitkit-ios#934.

@ovitrif

ovitrif commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator

@ben-kaufman Android results for 3a0630f (paykit-android 0.1.0-rc76), a devDebug regtest build against staging on a Pixel 7 emulator (Android 15), with a fresh staging buyer (4ra7hd…4pwy), fresh staging sellers for the fixture cases, and the team's staging Shop seller for the real checkout. Cases 1, 2, 4 and 5 pass; Case 3 fails; Cases 6–8 are unrun. The real checkout reached payment review with request fc20414e-e4b6-49f9-8f5c-ba2f22a3a80e. Transitions come from debugger breakpoints on the SDK import and ensureLinkWithPeer; clips are sped up 1–4x.

One bug blocks the scan: ShopContactImporter.importPage resets its cursor and handled set whenever owner !== signIn, and PubkyRepo.currentSignIn() builds a new PubkySignIn on every call, so every poll starts over at the first page (a breakpoint inside the reset branch hit on every call). Sellers further down the marker list are never imported, and already imported sellers are sent to the SDK import again on every poll.

Test 1: First-time seller on cold open, open app and resume ✅

A new seller with a valid storefront and marker was imported and linked without opening Contacts on all three paths: cold open (import 60 s after a launch that waited for the debugger, link at 21:02:59 UTC), app already open (marker at 21:03:39, import on the same poll, link at 21:03:58) and resume (nothing while backgrounded, import 5 s after resume, link 25 s after resume).

a-c1-cold.mp4
a-c1-open.mp4
a-c1-resume.mp4
Test 2: Shop marker and seller Payment Request ✅

The staging Shop wrote the buyer's marker for seller 3f4khs…r84y on add to cart (21:16:16 UTC), Bitkit imported it on the next poll and started the link at 21:17:31, and the Payment Request for order 49c9f282 (request fc20414e-e4b6-49f9-8f5c-ba2f22a3a80e, ₿2 000) reached payment review at 21:20:32, about 1¾ min after checkout, where the test stopped with no approval or funding (an earlier checkout got a 503 paykit_unavailable from the Shop and was retried once).

a-c2-shop.mp4
Test 3: More than 100 markers ❌

With 117 markers (100 for keys with no homeserver, 7 valid sellers), only the valid sellers near the start of the list were imported, and the three at positions 28, 54 and 116 were still missing 23 minutes later, longer than a full pass took on iOS, because of the reset described above.

a-c3-paging.mp4
Test 4: Label, removal and block preservation ✅

An edited label and two removed sellers (removal blocks the peer; there is no separate block action) stayed as left through six polls that re-sent all of them to the SDK import and through a relaunch; the in-flight edit variant wasn't run.

a-c4-preserve.mp4
Test 5: Invalid data ✅

A malformed marker, a marker naming another seller, the buyer's own key, a key with no homeserver, and storefronts with the wrong owner or record type imported nothing over 75 s of polls, and after the wrong-owner storefront was fixed the seller was imported and linked 5 s later.

a-c5-invalid.mp4
Test 6: Interrupted discovery and refresh ⏭️

Not run: the retry path keeps its state in the cursor, handled set and pending refresh, which the reset above clears on every call, so this needs a rerun after that fix.

Test 7: Stale-read and identity fencing ⏭️

Not run for the same reason as Test 6; it needs a rerun after the reset fix.

Test 8: Payment-priority handling ⏭️

Not run: it needs a payment submission in flight, and this fixture can't submit one without funding and broadcast.

@BitcoinErrorLog BitcoinErrorLog left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed at 3a0630fb against what the Shop writes (pubky/pubky-marketplace#220, on master). One bug blocks it. Everything else covers what the Shop needs.

Blocking: the importer still restarts on every poll (Ovi's Case 3). ShopContactImporter.importPage resets cursor, handled and refreshPending when owner !== signIn. PubkyRepo.currentSignIn() returns a new PubkySignIn(publicKey, generation) on every call, so the reset runs on every poll. As a result, sellers past the first ten markers are never imported, the same sellers go to importContacts and trigger a contact reload every 10 s, and the retry state that Cases 6 and 7 depend on is wiped each time. Suggested fix: compare by value, not by reference:

if (owner?.publicKey != signIn.publicKey || owner?.generation != signIn.generation) {

ShopContactImporterTest misses this because it reuses one signIn val. Please add a test that passes two separate but equal PubkySignIn instances (the way currentSignIn() does), then asserts that the second call lists from the first page's cursor and doesn't send already handled sellers to the import again. After the fix, please ask Ovi to rerun Cases 3, 6 and 7.

Matches the Shop

  • Path and format: lists /pub/pubky.app/migrations/seller-auto-follow/v1/, accepts only <z32>.json, and requires seller to match the file name and completedAt > 0. This is exactly what the Shop writes ({"seller":"<z32>","completedAt":<ms>}, seller without a pubky prefix). ignoreUnknownKeys = true means a future extra field won't break parsing.
  • When it runs: on foreground (when polling starts, the combine emits), on reconnect (connectivity is in the combine), and on each existing payment-request poll. There's no new timer, and it stops when polling stops.
  • Idempotent and respects removals: the SDK importContacts skips existing contacts, the buyer's own key and Blocked peers in one transaction. Bitkit's removeContact calls blockPeer before removeContact, so a seller the user removed stays removed.
  • No clobbering: new contacts get label = null, and existing records, labels, tags and overrides aren't touched.

Not blocking

  • A seller is imported only when they have a shop.json. Shop #222 (open) writes a default one for every seller who takes bitcoin, so this needs no Bitkit change.
  • Unreachable sellers aren't cached, so every pass fetches them again (Ovi measured 70–80 s per page of unreachable keys on iOS). Real buyers have few markers, so this can wait. A short in-memory negative cache would make a pass cheaper.
  • Optional test: a marker with an extra unknown key still imports.

@ovitrif
ovitrif self-requested a review October 15, 2026 08:25
@ben-kaufman

Copy link
Copy Markdown
Contributor Author

@BitcoinErrorLog @ovitrif Fixed in 4c1c28c2ad0348aba8bdfc790cdb9f99ca6c15bf: the importer compares the public key and sign-in generation, not object identity. Fresh snapshots of the same sign-in now preserve the cursor, handled sellers and pending refresh; a real key/generation change still resets them.

The existing paging and interrupted-refresh tests now allocate fresh sign-ins and both fail on the preceding production code. Added one table for real identity/generation changes. Final just compile, just test and just lint passed: 4,006 tests, zero failures/errors/skips; full 1,980-finding SARIF exactly matches the verified preceding baseline. All four PR commits are signed and GitHub-verified.

Please rerun Cases 3, 6 and 7 on this head with the existing isolated staging fixture: deep-page progress and wraparound without duplicate imports; interrupted commit/refresh recovery; stale-read, background and identity fencing. Record the exact head and transitions. These emulator checks have not been claimed from the unit tests. No funding, mining or payment approval/broadcast is requested. Case 8 stays explicitly unrun without a non-broadcast fixture, and the PR stays draft.

@ovitrif ovitrif linked an issue Oct 15, 2026 that may be closed by this pull request
@ben-kaufman

Copy link
Copy Markdown
Contributor Author

Resolved the conflict with current master in c7accdf. Kept master's Bitkit Core 0.8.0-rc6 and this PR's Paykit 0.1.0-rc76; the existing Shop importer/polling fix and merged USDT changes are preserved. Normal signed merge, no history rewrite; all five PR commits are GitHub-verified.

Validation: just compile, just test and just lint passed; 4,196 tests, zero failures/errors/skips. Full Detekt reports match current master (1,399 baseline findings), and emitted compiler warnings were verified against fresh master production/test compilation. No introduced diagnostics.

Still draft: the requested current-head Cases 3/6/7 and remaining device acceptance checks are not replaced by these unit tests. No new checkout or latency claim.

@ovitrif

ovitrif commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator

Blocking: the importer still restarts on every poll (Ovi's Case 3). ShopContactImporter.importPage resets cursor, handled and refreshPending when owner !== signIn. PubkyRepo.currentSignIn() returns a new PubkySignIn(publicKey, generation) on every call, so the reset runs on every poll.

@BitcoinErrorLog @ben-kaufman Cases 3, 6 and 7 pass on Android. Case 3 ran on 4c1c28c and on the current head c7accdf. Cases 6 and 7 ran on 4c1c28c; the merge leaves ShopContactImporter and PubkyRepo unchanged. I tested a devDebug regtest build against staging on a Pixel 7 emulator (Android 15), with fresh staging buyers and sellers. Transitions come from debugger breakpoints in ShopContactImporter.importPage and the SDK importContacts. Clips are 2x and cut to the relevant parts.

Test 3: More than 100 markers ✅

On 4c1c28c, a buyer with 117 markers (110 keys with no homeserver, plus 7 sellers at positions 24 to 117) got all 7 sellers in one 11-minute pass. The cursor moved ten markers per poll, and each seller reached the SDK import exactly once. A seller added before the cursor was imported as soon as the scan restarted. The next full pass sent no handled seller to the import again, and the reset branch never ran between polls. On c7accdf, a second buyer got all 7 the same way, including two sellers whose markers landed before the cursor.

case3-2x.mp4
case3b-2x.mp4
Test 6: Interrupted discovery and refresh ✅

Discovery cut offline before its listing imported nothing, and the seller came in on the first poll after reconnecting. With the refresh cut offline right after the SDK saved a seller, the importer kept refreshPending and its cursor. After reconnecting, it refreshed first, re-sent that seller (0 new rows) and moved on. Each seller ends up with one row, and linking started for the seller imported after reconnecting.

case6-2x.mp4
Test 7: Stale-read and identity fencing ✅

A contact read held from before an import showed 11 contacts; after it was released, the next read showed all 12. An import held before its save while the wallet was reset ended in SignInChanged with no SDK write. The new identity saw none of the first identity's contacts and listed only its own markers. After the first identity was restored, the importer reset once and imported the held seller under it.

case7-2x.mp4
Test 8: Payment-priority handling ⏭️

Not run: it needs a payment submission in flight, and no non-broadcast fixture exists.

@BitcoinErrorLog BitcoinErrorLog left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-reviewed at c7accdf1. My blocker is fixed, so I'm approving.

Fixed: the importer no longer restarts on every poll. In 4c1c28c2, ShopContactImporter.importPage compares publicKey and generation instead of object identity. The fresh PubkySignIn that currentSignIn() returns on each poll now keeps the cursor, handled and refreshPending, and a real account switch or a new sign-in generation still starts a fresh scan. The master merge in c7accdf1 doesn't touch ShopContactImporter, PubkyRepo or the importer tests.

The tests cover both directions:

  • The paging test now passes a new but equal PubkySignIn on each call. It checks that the first page is listed only twice in four polls (once at the start and once on the rescan after the empty last page), and that each seller goes to importShopContacts exactly once. The old !== check fails this.
  • The interrupted-refresh test retries with a new equal instance, so a pending refresh now survives across polls, which is what Cases 6 and 7 rely on.
  • The new identity or generation change starts a fresh scan test covers both a new generation for the same key and a different key. It checks that the second scan starts again from the first page, never lists from the old cursor, and fetches the already handled seller's storefront again.

Ovi's emulator runs confirm it: Cases 3, 6 and 7 pass. Case 3 passed on 4c1c28c2 and on c7accdf1 (117 markers, all 7 sellers imported once each, including sellers inserted before the cursor, with no reset between polls). Cases 6 and 7 passed on 4c1c28c2, and the importer and PubkyRepo haven't changed since.

Not blocking: as on iOS, Case 8 (import deferred while a payment is being submitted) hasn't been run because there's no fixture for it. The negative-cache suggestion from my earlier review still applies.

This PR is still a draft. @ben-kaufman, please take it out of draft when you're ready, since we need it for RC2.

@ben-kaufman

Copy link
Copy Markdown
Contributor Author

Thanks, I've recorded the Cases 3/6/7 retest and current-head approval in the PR body. I also checked that ShopContactImporter and PubkyRepo are unchanged between 4c1c28c2 and c7accdf1, so those recovery results are still relevant. No repeat of those checks is needed just for the master merge.

Case 8 is still unrun, and I haven't waived it or counted unit tests as an emulator pass. Keeping the draft until the remaining acceptance checks are settled. The negative-cache suggestion can stay a separate performance follow-up with bounded expiry, so temporarily unavailable sellers are not skipped indefinitely.

@greptile-apps

greptile-apps Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 3/5

[Medium impact] The changes affect the Paykit contact and payment-request workflow.

Fix both cancellation bugs before merging.

Findings

  1. P1 Canceled imports still write ▶
  2. P1 Contact links silently stop ▶

Summary

Adds Shop seller discovery and refreshes shared contacts on foreground entry and contact links.

  • Bitkit adds verified Shop sellers to the buyer’s shared contacts.
  • Foreground returns and contact links use a fresh shared contact read.

Acknowledged scope from ben-kaufman: repeated unreachable-seller lookups are a non-blocking performance follow-up. Multi-poll discovery and existing safety waits are intentional. Payment-priority device acceptance remains unrun, and the PR is explicitly draft pending the remaining checks.

Diagram

sequenceDiagram
    participant App
    participant Importer
    participant Pubky
    participant SDK
    participant Contacts
    App->>Importer: Foreground or existing poll
    Importer->>Pubky: Read marker page and storefronts
    Pubky-->>Importer: Valid sellers
    Importer->>SDK: Import missing, unblocked contacts
    Note over SDK: Wait while payment submission is active
    SDK-->>Importer: Imported records
    Importer->>Contacts: Start a fresh contact read
    App->>Contacts: Refresh before contact-link routing
Loading

Reviews (1) · Last reviewed commit: "chore: merge current master into shop co..." · Reviewed by Greptile

Comment thread app/src/main/java/to/bitkit/services/PubkyService.kt Outdated
Comment thread app/src/main/java/to/bitkit/repositories/PubkyRepo.kt Outdated
@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Regtest APK

Built from 2f07d8c (run).

Download bitkit-dev-debug universal APK (expires in 30 days).

@ovitrif

ovitrif commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator

@ben-kaufman @BitcoinErrorLog Case 8 on Android at c7accdf: the import waits correctly while the payment sheet stays open (8a passes). But leaving the payment sheet ends the deferral while the payment is still being submitted (8b fails). That also blocks the identity-change branch (8c not run). Nothing was broadcast.

Bug: hideSheet() calls clearActiveContactPaymentContext() (AppViewModel.kt:6616). That sets isSubmittingPaymentRequest = false (:4271), whose setter clears setPaymentSubmissionActive (:519) while proceedWithPayment (:6829) is still running.

  • Effect: pressing Back on the payment sheet released a waiting import, and it wrote a contact while the on-chain prepare and broadcast were still pending.
  • Suggested fix: clear the flag only when the submission itself ends, in proceedWithPayment's finally, not when the sheet goes away.
  • iOS: ties the same gate to performPayment's own begin and end, and keeps the deferral after its sheet is dismissed (iOS run).

Also, from the code (not a device run): Android defers only while an incoming request is being paid (isSubmittingPaymentRequest, :6820). So plain sends and contact payments without a request don't defer the import, while iOS defers for every Send confirmation.

Setup:

  • devDebug regtest build against staging on a Pixel 7 emulator (Android 15).
  • A fresh staging buyer funded from the Blocktank regtest faucet, plus two fresh storefront-only sellers.
  • The held payment was a real Paykit Payment Request (7eaf1f10, ₿5 000) sent to this buyer from the iOS buyer's Bitkit. Android only defers while an incoming request is being paid, and staging Paykit delivery was degraded when the run started, so no Shop request was available.

Hold and abandon:

  • A jdb breakpoint with thread-only suspend on ldk-node's OnchainPayment.prepareSendToAddress entry held the ldk-queue thread before any transaction was built. Everything else kept running, including the importer's polls.
  • To abandon, I set the held amountSats (5 000) above the wallet balance. ldk-node refused to build the transaction, and Bitkit reported OnchainSendNotDispatchedError.
  • The regtest explorer shows only the faucet deposit on this wallet, and nothing to the payee address.

Each video shows the phone next to a timeline of debugger breakpoints and logcat lines, in UTC, at 2x.

Test 8a: Import waits while a Payment Request is being paid, then runs ✅

With request 7eaf1f10 held from 14:03:52 UTC, seller s1's import stopped at isPaymentSubmissionActive (14:04:52) with no write or link call for 73 s. After the abandon, the import passed the gate and wrote s1 (14:06:07), its link call followed (14:06:16), and the request was still pending and payable.

android-case8a-2x.mp4
Test 8b: Leaving the payment sheet keeps the import waiting ❌

With the same request held from 14:25:08, seller s2's import waited at the gate (14:25:23). Pressing Back on the payment sheet (14:25:30) cleared the submission flag. The import wrote s2 160 ms later, while the payment was still held before its transaction was built.

android-case8b-2x.mp4
Test 8c: Identity change during the wait ⏭️

Blocked by 8b. Every identity change I found (Delete Profile) means leaving the payment sheet, which already ends the wait. After the profile was deleted (14:26:56) and the payment released (14:27:50), no import ran for the old identity. Rerun after the 8b fix. The video is 8b's.

ovi-reviewer[bot]

This comment was marked as outdated.

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

1 finding (1 P1). Imports Shop seller markers into the buyer's Paykit contacts and refreshes contacts on foreground and contact deep links. Reviewed c7accdf1, full tier.

What I checked, and the candidates I ruled out

Read in full: ShopContactImporter.kt, PubkyRepo.kt (contact region and signOut), the AppViewModel.kt hunks with startPaykitPaymentRequestPolling, awaitPubkyDeeplinkInitialization and the scan job, PaykitSdkService.importShopContacts, PubkyService additions, ContentView.kt lifecycle observer
Call sites traced: refreshContacts (foreground collector, deep link), importShopContacts (poll loop, foreground collector), stopPaykitPaymentRequestPolling (ON_STOP, dispose)
CI: build, lint, detekt, build-local green; e2e shards still running when posted
Not built here: the branch's Gradle build is left to CI on an auto review.

Ruled out

  • Cursor advanced past an unsaved page: the cursor and handled only move when refreshPending is cleared, and a thrown SignInChanged leaves both untouched.
  • Import write during payment submission: importShopContacts re-checks isPaymentSubmissionActive inside the operation lock and loops.
  • Stale import after identity change: isStillCurrent is checked inside the lock right before the SDK call, and the SDK import is bound to expectedIdentity.
  • Refresh outliving sign-out: signOut cancels and clears contactRefresh.
  • Cold reader: it did not return within this run, so the finding is unchallenged by a second reader.

Merge confidence: 3/5, the P1 on a contact link opened while connectivity changes.

Comment thread app/src/main/java/to/bitkit/repositories/PubkyRepo.kt Outdated
@ovi-reviewer

This comment has been minimized.

@ben-kaufman

Copy link
Copy Markdown
Contributor Author

Updated in 2f07d8c. The shared-refresh and queued-import cancellation findings are fixed, including the real PubkyService wrapper. A queued cancelled caller cannot start an SDK write, while an already-admitted write still finishes safely.

The reproduced payment-sheet issue in #issuecomment-6195035283 is also fixed: the import gate follows the running software submission, not sheet visibility. Incoming requests, contact payments and plain sends keep imports paused after Back until the operation returns or is actually cancelled. A held-submit table covers all three; duplicate confirmation stays blocked. The existing USDT cancellation test also checks dismissal without releasing the gate. No payment was broadcast.

just compile, just test, just lint: 4,200 tests passed, zero failures/errors/skips. All 1,399 Detekt findings match the verified baseline; no introduced compiler/format finding. Four tests added, none removed; mirrored manual Cases 8/9 remain unchecked for the new head. Current-head CI is pending.

For the bounded device follow-up, please rerun 8b/8c and queued-import cancellation through the actual foreground/background or disable caller, then Case 9. The interim Test 8 hook cancels a Job inside PaykitSdkService; that does not by itself prove cancellation propagates through the old PubkyService wrapper. Stop before broadcasting. Existing active QA need not be duplicated.

The Test 5 report shows invalid data rejected and corrected data discovered after 21.5 s. That delay remains performance evidence to investigate, not proof that validation/retry failed. Please retain the timing separately from the functional result; this PR adds no progress UI or checkout-latency deadline. It does not claim that performance is fixed.

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

Re-review of 2f07d8c8 after c7accdf1. 1 earlier finding: 1 fixed. 0 new findings. Full tier on the diff since the last review.

  • One cancelled caller cancels the shared contact refresh: fixed. refreshContacts() now counts waiters under contactsLock against the exact Deferred, and only the last departing waiter cancels and clears it; cancelling one contact refresh does not cancel another waiter covers the foreground-plus-contact-link case.
What I checked, and 4 candidates I ruled out

Read: git diff c7accdf..2f07d8c, plus refreshContacts, signOut's refresh reset, onConfirmPay, sendPaykitUsdt, hideSheet, cancelHardwarePaymentRequestIfNeeded, onHardwarePaymentSubmissionChanged and its only caller HwSendSignScreen
Not built here: the branch's Gradle build is left to CI on this track.

Ruled out

  • Waiter count inherited by a newer refresh: a new Deferred resets the count to 0 before its own caller increments it, and an older caller's finally only decrements when contactRefresh?.second === refresh, so a stale completion cannot clear or cancel the newer read.
  • Software submission flag stuck on: it is set in onConfirmPay and sendPaykitUsdt immediately before a try whose finally clears it; the UNDISPATCHED launch closes the gap where a second PayConfirmed could slip in, and viewModelScope cancellation still runs the finally.
  • Dismissing the sheet ending the pause (Case 8b): clearActiveContactPaymentContext still sets isSubmittingPaymentRequest = false, but the setter now ORs in isSoftwarePaymentSubmissionActive, so the repo flag stays up until the payment coroutine ends.
  • Cancelled queued import still writing: importShopContacts now drops the queue's Job (queueContext.minusKey(Job), the same form as the existing read wrapper), so a caller cancelled before admission never reaches importContacts; an admitted write still completes under completeSdkCall, covered by the two new PaykitSdkServiceTest cases.

Merge confidence: 4/5, 0 open findings, but build and the e2e shards were still running when this was posted.

@ovitrif

ovitrif commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

@ben-kaufman @BitcoinErrorLog Following up on the 2f07d8c fix: Case 8 passes on Android at 2f07d8c, which is still the head. Leaving the payment sheet no longer releases a waiting Shop import. The import now waits until the payment itself ends (8b). With the sheet open, it still writes once after the payment (8a). A profile delete during the wait ends the import with no write (8c). Nothing was broadcast.

Setup:

  • devDebug regtest build of 2f07d8c against staging on a Pixel 7 emulator (Android 15).
  • A fresh staging buyer funded with ₿100 000 from the Blocktank regtest faucet.
  • Three fresh storefront-only sellers (s1, s2, s3), one per case, each with a valid shop.json. Each seller's marker was written while the payment was held.
  • The held payment was a plain ₿5 000 on-chain send, which now holds the gate too. The failing c7accdf run held an incoming Payment Request instead. That branch wasn't rerun on a device; your held-submit test covers it.

Hold and abandon:

  • As in the c7accdf run, a jdb breakpoint with thread-only suspend on ldk-node's OnchainPayment.prepareSendToAddress entry held ldk-queue before any transaction was built. Everything else kept running.
  • To abandon, I set the held amountSats above the wallet balance, and ldk-node refused with InsufficientFunds.
  • A guard breakpoint on PreparedOnchainSend was never hit. The wallet still shows ₿100 000 and one activity, the faucet deposit.

Each video shows the phone next to a timeline of steps, debugger breakpoints and logcat lines, in CEST, at 2x.

Test 8a: Import waits while a payment is held, then runs once ✅

With the payment held from 18:30:33, s1's import stopped at the payment gate (18:30:48) and made no write or link call for 109 s. After the abandon, the submission ended (18:32:37.9), and the import wrote s1 once (18:32:38.2) and linked it (18:32:43.8).

case8a-android-2x.mp4
Test 8b: Leaving the payment sheet keeps the import waiting ✅

With the payment held from 18:34:26, s2's import waited at the gate (18:34:55). The first Back stepped the sheet from Confirm to Amount, and a second Back closed the sheet (18:35:14). The import then kept waiting, with no write, link call or new poll, for 95 s. It wrote s2 only after the submission ended (18:36:49.9 end, 18:36:50.1 write, 18:36:59.6 link).

case8b-android-2x.mp4
Test 8c: Identity reset while the import waits on a payment ✅

With the payment held from 18:37:54, s3's import was waiting at the gate (18:38:15). I closed the sheet and deleted the buyer's profile (confirmed 18:38:53, contacts deleted 18:39:01, sign-out 18:39:05). The waiting import job was cancelled at 18:39:19.6 without writing s3. After the abandon (18:40:21.6), no import, SDK write or link call ran in the next 64 s.

One more thing showed up at 18:39:19.8: the sign-out's Paykit teardown (clearStateLocked) set the shared payment gate to false while the payment was still held. That code isn't in this PR's diff, and nothing was released early here: the wallet backup ran only after the payment ended (18:40:26). But during a mid-payment sign-out, it can release any other gate waiter early.

case8c-android-2x.mp4

@BitcoinErrorLog BitcoinErrorLog left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approval stands at 2f07d8c8.

  • Case 8b: fixed. The import gate now follows the running submission, not whether the sheet is visible. Pressing Back on the payment sheet keeps a waiting Shop import paused until the payment itself ends. Ovi's run on 2f07d8c8 passes 8a, 8b and 8c, and nothing was broadcast.
  • Cancellation: fixed. importShopContacts keeps the caller's Job, so an import cancelled before admission never writes. Only the last waiter cancels the shared contact refresh, so a contact link survives a foreground cancel. Each fix has a regression test.
  • CI is green on 2f07d8c8.

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

Approving at 2f07d8c on the device evidence:

@ovitrif
ovitrif merged commit 262b169 into master Oct 15, 2026
21 checks passed
@ovitrif
ovitrif deleted the codex/refresh-shared-contacts branch October 15, 2026 17:25
@ben-kaufman ben-kaufman mentioned this pull request Oct 15, 2026
2 tasks
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.

feat: save the shop seller as the buyer's contact on checkout

5 participants