Repository navigation
fix: show ordinary Lightning results without Paykit waits - #1684
ben-kaufman wants to merge 8 commits into
Conversation
|
Triage: 3.1.0. It fixes the shipped Lightning payment result screen, and 3.1.0 is due 28 Oct. |
[Medium impact] The PR appears safe to merge; ordinary results skip unrelated work without changing request-specific proof handling. SummaryOrdinary Lightning failures and pending results now skip unrelated Paykit proof work. Payments tied to an incoming request keep their existing checks and proof handling.
Reviews (1) · Last reviewed commit: "fix: show ordinary Lightning results wit..." · Reviewed by Greptile |
talosmachina
left a comment
There was a problem hiding this comment.
No findings. For a Lightning send that has no incoming Paykit request, the result screen no longer waits on proof cleanup or reconciliation: a failure skips failLightningPayment, and a pending result skips refreshIncomingPaykitPaymentRequests(). Reviewed 3b95acd, light tier (7 changed production lines), stacked on #1681 and reviewed as the delta against fix/usdt-recovery-parity.
What I checked, and 4 candidates I ruled out
Read in full: proceedWithLightningPayment, clearFailedLightningPayment, associateLightningPaymentProof, preparePaymentProof, refreshIncomingPaykitPaymentRequests, handlePaymentFailed, handlePaymentSuccessful in AppViewModel.kt; failLightningPayment (both overloads), associateLightningPayment and reconcile in PaykitPaymentProofRepo.kt; the two new tests in AppViewModelSendFlowTest.kt.
Call sites traced: every failLightningPayment( and associateLightningPayment( caller in app/src/main, and every refreshIncomingPaykitPaymentRequests() call.
CI when read: build-local and lint green; build and the local e2e shards were still running. Not built here: on this track the branch's Gradle code would run with a write token in its environment, so CI is the gate.
Ruled out
- An ordinary failure leaves a stale Lightning proof:
associateLightningPaymentProofreturns before writing anything whenrequest == null, andpreparePaymentProofdoes too, so this flow never creates a proof keyed to that hash. The node'sPaymentFailedevent still runsfailLightningPayment(paymentHash)off the send path inhandlePaymentFailed. - An allowance payment on the same hash loses its cleanup:
PaykitAllowanceExecutorassociates its proof and fails it itself, throughpayer.failLightningPayment(paymentHash, it), so it does not depend on this branch. - A pending ordinary payment no longer brings up the next request: the refresh dropped from this path also runs after
PaymentSuccessfulandPaymentFailedand on the polling loop, and nothing on the ordinary pending path changes request state.trackandpreserveContactPaymentContextstill run first, so the pending hash and the contact context are kept. - Reordering the
whenchanges how request payments are handled: whenisPaymentRequestis true, the guard is skipped andLightningPaymentFailedErrorand the two-argumentfailLightningPaymentrun as before. The guard now comes beforeLightningPaymentFailedError, but that arm and the old!isPaymentRequestarm both returnedtrue, so the only difference is the dropped cleanup call.
Merge confidence: 4/5, build and e2e were still running when this was read and the branch was not built here; the change itself is covered by the two new AppViewModelSendFlowTest.kt cases.
Reviewer model: claude-opus-5-5
Reasoning effort: Not exposed (the runtime does not report it to the reviewer)
Regtest APKDownload bitkit-dev-debug universal APK (expires in 30 days). |
jvsena42
left a comment
There was a problem hiding this comment.
Code scan: no findings.
Checked and clean:
- "Ordinary" is
contactPaymentContext?.incomingPaymentRequest == null, the same value that gates proof preparation and association. So a Paykit payment can't be classified as ordinary, and an ordinary send never has a proof to clean up. - An ordinary Lightning failure no longer awaits
failLightningPayment(AppViewModel.kt:6264), and an ordinary pending result no longer awaitsrefreshIncomingPaykitPaymentRequests(AppViewModel.kt:6231). Paykit payments keep both. - Receive and startup were already independent of publication on Android (the debounced address observer,
AppViewModel.kt:836-850). - Twin synonymdev/bitkit-ios#1120 covers receive publication and startup cleanup instead. iOS still awaits
failLightningPaymenton an ordinary Lightning failure, which this PR's body acknowledges.
Performance-relevant (removes a user-visible wait). The gate times Pay → error/pending screen with the homeserver unreachable, head vs master, and counts Failed to clear a pending Paykit payment proof during an ordinary failure (expected 0).
Device gate (partial): 3b95acd — not run yet; pending that measurement and wallet/ordinary-during-paykit-outage.xml. Device results will follow in a separate review.
jvsena42
left a comment
There was a problem hiding this comment.
Approving at 3b95acd. My code scan stands with no findings, and the device gate found none.
Device gate: 3b95acd (base dc5a47d) — Pixel_Tablet with a 60,000-sat spending channel, staging. journeys: wallet/ordinary-during-paykit-outage.xml step 4 passed: an ordinary Lightning payment that fails with a real PaymentFailed(RouteNotFound) reaches the error screen once per swipe, with no automatic second payment. perf: Pay → "Payment Failed" 2.8 s (4 runs), base 2.8 s; 0 Failed to clear a pending Paykit payment proof on both. Figma: no design; l10n: no UI text changed.
-dns-server) and a filtering CONNECT proxy (-http-proxy); both carried only system traffic, and the app's traffic bypassed them. The emulator image has no root and no hosts file, and airplane mode would also cut the LSP and Electrum. Step 5 needs a hold invoice, which the staging LSP doesn't expose.
Skipped — covered by CI at 3b95acd: unit tests, lint, detekt, e2e (all green).
There was a problem hiding this comment.
Verdict:
Findings: None. I reviewed the full diff and reached callers at 3b95acd; ordinary sends create no request proof, and genuine requests retain their authorization, claim and proof paths.
QA for 3b95acd: Built with the committed dependencies. Device cases used Android 15 on a Pixel 7 emulator, disposable local regtest funds and an authenticated local Pubky identity. The payment-result cases used the debug-only lock/observation hook below; no SDK result was fabricated.
Test 1: J1.4 — ordinary failure with proof maintenance held ✅ A real recipient-rejected invoice reached Payment Failed while the proof lock remained held, with one submission.
The 101-sat canceled invoice produced RECIPIENT_REJECTED. Device logs recorded the send result 0.97 seconds after submission with the gate still held, and no automatic second submission. The recording shows confirmation through the error screen.
failure2x.2x.mp4
Ordinary failed payment at 2×.
Test 2: J1.5 — ordinary pending result with proof maintenance held ✅ A real hold invoice reached Payment Pending with its original hash and 200 sats before releasing the proof lock.
Paykit was enabled and the wallet was authenticated. LND reported one accepted 200,000-msat HTLC; device logs recorded one submission and the same hash at pending navigation after the existing 10-second node-event timeout. The recording shows the pending screen. Hash, submission count and the held gate were checked in logs and LND state.
pending2x.2x.mp4
Real hold-invoice pending result at 2×.
Test 3: J1.1–2 — receive during public publication delay ✅ Receive returned a current local QR while an authenticated public endpoint PUT remained held, and releasing the fault reconciled the current public invoice.
An actual synthetic payment triggered public-invoice replacement. In the full edit run, the displayed QR decoded to amount=0.00002 and message=Held-publication; the proxy confirmed an outstanding endpoint PUT while that QR was usable. Close/reopen was checked through the UI. After release, an independent public read matched the wallet's current public invoice, differed from the paid invoice and the cleanup flag cleared. Identity sign-out during reconciliation was not driven. The clip shows a repeat QR presentation under the same publication fault; QR payload and reconciliation claims were checked separately from the video.
receive2x.2x.mp4
Receive QR presentation during a held publication PUT at 2×.
Test 4: J1.3 — recovery startup during disabled-Paykit cleanup ⏭️ The pending order/swap plus retained endpoint-cleanup scenario was not established.
Direct authenticated Pubky and regtest Lightning routing worked, but the fixture does not seed that combined recovery state. Startup was inspected in code; this is not a device pass.
Test 5: J1.6 — retained proof restart and genuine requests ⏭️ Public endpoint recovery passed above; the complete retained-proof restart and genuine-request fault scenario remains unrun.
The repository's compatibility check found controlled peers on an older Paykit revision and no Paykit Server revision pinned to this head's released Paykit 0.1.1-rc1. Direct Pubky signup/publication provided the receive subset, but not the matching request/proof peer. The proof-lock cases above do not establish retained-proof service recovery. Request-specific authorization and proof regressions passed in the focused component suite.
I ran assembleDevDebug and the focused AppViewModelSendFlowTest suite successfully at the exact head, including both new held-proof regressions. The existing same-head device report already covered an ordinary routing failure without an isolated Paykit outage; I relied on that result rather than repeating it.
Debug test hook and deciding device logs
Not pushed. The hook holds the real proof-operation mutex until explicitly released, logs submission/navigation, and connects the real regtest peer for setup. It does not change the result decision, authorization or SDK outcome.
diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt
index 4a5b66f36..c21f20a36 100644
--- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt
@@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import kotlinx.coroutines.isActive // test hook
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@@ -135,6 +136,20 @@ class PaykitPaymentProofRepo @Inject constructor(
private val persistenceMutex = Mutex()
private val deliveryScope = appScope(ioDispatcher, TAG)
+ init { // test hook: explicit file-controlled contention on the real proof-operation lock
+ if (to.bitkit.BuildConfig.DEBUG) deliveryScope.launch { // test hook
+ val gate = java.io.File("/data/user/0/${to.bitkit.BuildConfig.APPLICATION_ID}/files/v4-proof-hold") // test hook
+ while (kotlinx.coroutines.currentCoroutineContext().isActive) { // test hook
+ if (gate.exists()) operationMutex.withLock { // test hook
+ android.util.Log.i("V41684", "PROOF_LOCK_HELD") // test hook
+ while (gate.exists()) kotlinx.coroutines.delay(100) // test hook
+ android.util.Log.i("V41684", "PROOF_LOCK_RELEASED") // test hook
+ } // test hook
+ kotlinx.coroutines.delay(100) // test hook
+ } // test hook
+ } // test hook
+ } // test hook
+
// Process-local activity handoffs only; restored proofs must reconstruct their activity.
private val observedHardwareProofs = mutableSetOf<PendingPaykitPaymentProof>()
diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
index 94648b955..ed2f9c393 100644
--- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
+++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
@@ -71,6 +71,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.isActive // test hook
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -86,6 +87,7 @@ import org.lightningdevkit.ldknode.PaymentId
import org.lightningdevkit.ldknode.SpendableUtxo
import org.lightningdevkit.ldknode.SyncType
import org.lightningdevkit.ldknode.Txid
+import to.bitkit.ext.of // test hook
import to.bitkit.BuildConfig
import to.bitkit.R
import to.bitkit.data.CacheStore
@@ -6229,6 +6231,7 @@ class AppViewModel @Inject constructor(
pendingPaymentRepo.track(pendingHash)
preserveContactPaymentContext(pendingHash)
if (incomingPaymentRequest != null) refreshIncomingPaykitPaymentRequests()
+ if (to.bitkit.BuildConfig.DEBUG) android.util.Log.i("V41684", "SHOW_PENDING hash=$pendingHash amount=$displayAmountSats gate=" + java.io.File(context.filesDir, "v4-proof-hold").exists()) // test hook
setSendEffect(SendEffect.NavigateToPending(pendingHash, displayAmountSats.toLong()))
lnurlComment?.let { activityRepo.setLightningMessageIfEmpty(paymentHash, it) }
return@onFailure
@@ -6243,6 +6246,7 @@ class AppViewModel @Inject constructor(
is LightningPaymentFailedError -> error.reason.toSendFailureDetails(context, error.paymentRequest)
else -> error.toSendFailureDetails(context, _sendUiState.value.currentLightningPaymentRequest())
}
+ if (to.bitkit.BuildConfig.DEBUG) android.util.Log.i("V41684", "SHOW_ERROR hash=$paymentHash gate=" + java.io.File(context.filesDir, "v4-proof-hold").exists()) // test hook
setSendEffect(SendEffect.NavigateToError(failure))
}
}
@@ -6616,12 +6620,33 @@ class AppViewModel @Inject constructor(
onBroadcast = onBroadcast,
)
+ init { // test hook: fixture setup only; payment execution is still the real production path
+ if (to.bitkit.BuildConfig.DEBUG) viewModelScope.launch(bgDispatcher) { // test hook
+ val command = java.io.File(context.filesDir, "v4-command") // test hook
+ while (kotlinx.coroutines.currentCoroutineContext().isActive) { // test hook
+ if (command.exists()) { // test hook
+ val action = command.readText().trim() // test hook
+ command.delete() // test hook
+ runSuspendCatching { // test hook
+ if (action == "node") android.util.Log.i("V41684", "NODE_ID=" + lightningRepo.lightningState.value.nodeId) // test hook
+ if (action.startsWith("peer ")) { // test hook
+ lightningRepo.connectPeer(org.lightningdevkit.ldknode.PeerDetails.of(action.removePrefix("peer "))).getOrThrow() // test hook
+ android.util.Log.i("V41684", "PEER_CONNECTED") // test hook
+ } // test hook
+ }.onFailure { android.util.Log.e("V41684", "SETUP_FAILED", it) } // test hook
+ } // test hook
+ kotlinx.coroutines.delay(200) // test hook
+ } // test hook
+ } // test hook
+ } // test hook
+
private suspend fun sendLightning(
bolt11: String,
amount: ULong? = null,
paymentDeadlineAt: Instant? = null,
onBeforeSend: suspend () -> Boolean,
): Result<PaymentId> {
+ if (to.bitkit.BuildConfig.DEBUG) android.util.Log.i("V41684", "SUBMIT_INVOICE") // test hook
return lightningRepo.payInvoice(
bolt11 = bolt11,
sats = amount,
Failure:
--------- beginning of main
10-26 23:15:45.903 9876 9926 I V41684 : PROOF_LOCK_HELD
10-26 23:15:57.527 9876 9876 I V41684 : SUBMIT_INVOICE
10-26 23:15:58.496 9876 9876 I V41684 : SHOW_ERROR hash=ccaa3e8a12d4c673329e862c273b1f17e2240d462fcc5d7d381df121ad63efc9 gate=true
Pending:
--------- beginning of main
10-26 23:14:51.658 9876 9925 I V41684 : PROOF_LOCK_HELD
10-26 23:14:57.744 9876 9876 I V41684 : SUBMIT_INVOICE
10-26 23:15:07.982 9876 9876 I V41684 : SHOW_PENDING hash=8556a73c5df1149f70307ea83764bc09feff634a5ad6eceba401e5472ad83677 amount=200 gate=true
Full review: dc5a47d..3b95acd, including payment preparation, result handling, node events, private-contact and allowance siblings. The full startup/restart/genuine-request service journey and a base-versus-head latency comparison were not driven.
Twin: bitkit-ios#1120 fix: decouple ordinary receive and startup from Paykit publication at fd3ce01 matches the shared intent and journey. It removes different receive/startup waits; Android already prepares receive locally, while this patch removes ordinary Lightning result waits. This is a source comparison, not a cross-platform device parity claim.
Reviewer model: gpt-6.1-sol
Reasoning effort: high
Reviewed by gpt-6.1-sol-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Full review of the whole PR diff against its merge base dc5a47d, at 3b95acd. No earlier QA baseline was available.
No new actionable code findings.
No new actionable code findings. Ordinary Lightning sends are those with contactPaymentContext?.incomingPaymentRequest == null. The same value controls preparePaymentProof, associateLightningPaymentProof and beginManualAllowancePayment. Lightning proofs are only created for a Paykit request, so an ordinary send has no proof for its payment hash. Dropping failLightningPayment(paymentHash) from the ordinary failure path therefore skips no cleanup. It also no longer deletes a request-owned proof that happens to share the hash. Proof cleanup after node events still runs in handlePaymentFailed, and it runs in the background. For an ordinary pending result, the order is unchanged: the original hash is tracked, the contact context is preserved, and the pending screen opens. Only the full request refresh is skipped; that refresh would also wait on paykitPaymentProofRepo.reconcile(). Paykit request payments keep their authorization check, the definite-failure proof cleanup and the request refresh.
Dependency #1681 has moved ahead of this PR's pinned base, to 416c1e3. Those commits don't change proceedWithLightningPayment, clearFailedLightningPayment or PaykitPaymentProofRepo. This PR's diff applies cleanly onto that head (checked with a local git apply --check). Parity with bitkit-ios#1120 at df5b074: on iOS, an ordinary Lightning failure still waits for PaykitPaymentProofService.failLightningPayment in SendConfirmationView. In the Lightning proof completion path I inspected, iOS delivers the proof outside mutationLock, so no matching remote wait was established. I did not audit every block that takes the lock. The PR body acknowledges the iOS difference.
Validation: I did not build or run tests in this review. The matching CI run at 3b95acd passed assembleDevDebug and testDevDebugUnitTest, which includes the two new held-proof regression tests. I only read those tests: they check that an ordinary failure never calls failLightningPayment and that a pending result navigates while reconcile() is held.
Device testing: not performed in this review. J1 (ordinary-during-paykit-outage.xml) has steps that need a Pubky-only service fault fixture, which the PR says is not provisioned.
Suggested additional test cases
- Android, Paykit request paid over Lightning, pending: Pay an incoming Paykit payment request with a hold invoice so the result stays pending. Expected: the pending screen shows the original hash and amount; after the refresh, the request no longer shows as payable and its proof stays retained. No unit test explicitly asserts the request refresh on this request-pending branch, which the changed condition still relies on.
- Android, Paykit request paid over Lightning, failure while proof maintenance is held: Hold unrelated proof maintenance and pay a Paykit request whose Lightning send fails definitively, for example no route. Expected: the error screen appears after cleanup, the request's own proof is removed, and the request can be paid again.
Reviewer model: claude-opus-5-5
Reasoning effort: high
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Full review of synonymdev/bitkit-android#1684 at 3b95acd, from merge base 78798d2. No saved baseline was available, so coverage is the complete three-dot diff and nothing was inherited.
No new actionable code findings.
An ordinary Lightning send, one with no incoming payment request, now shows the node failure or pending result without waiting on proof removal or a full incoming-request refresh. The pending hash is still tracked and the contact context is still preserved. A send that has an incoming request still associates its Lightning proof; a definite failure still awaits failLightningPayment before the error screen; a pending request still awaits refreshIncomingPaykitPaymentRequests (and therefore reconcile()) before navigation. associateLightningPaymentProof returns before writing when the request is absent. handlePaymentFailed still removes a matching Lightning proof in the background. Allowance attempts clean up through PaykitAllowanceExecutor.
The same diff carries the stacked USDT recovery journal: immutable acknowledged VSS records, a restore gate that blocks wallet replacement, legacy-wallet data merged before the journal, a fail-closed binding conflict, and a React Native migration path that resumes only the USDT gate. Retry stays on the recorded pending transfer.
Compared with bitkit-ios#1120 at 7f4d029, an ordinary pending timeout navigates before proof cleanup. An ordinary definite failure still calls local failLightningPayment. That reconcile pass does not hold mutationLock across its remote work, so the remote wait removed on Android was not established there. bitkit-android#1681 at 6fc2267 still has the pre-fix result calls; its later AppViewModel edits leave clearFailedLightningPayment and the pending refresh unchanged. That was a contract check, not a review of either comparison PR.
Validation: Gradle was not run here. On this revision, CI passed assembleDevDebug and testDevDebugUnitTest, and lint passed. The new held-proof cases in AppViewModelSendFlowTest, and the existing request failure and pending cases, were read only.
Device testing: not performed in this review. J1 (ordinary-during-paykit-outage.xml) still needs the unprovisioned proof-fault fixture, so that journey was not treated as run.
Suggested additional test cases
- Android, incoming Paykit request, hold invoice, proof reconciliation held: Confirm the Lightning payment while
reconcile()stays blocked. Expected: the pending screen appears only after the request refresh returns, shows the original hash and amount, does not callfailLightningPayment, and keeps the request proof. The new tests hold that gate only for an ordinary send.
Reviewer model: grok-4.7
Reasoning effort: high
|
Retargeted this PR to master before merging its base, #1681, so deleting that branch would not close it. The base change dismissed the earlier approval, so it needs a re-approval. |
|
Updated this branch with master so its approval sticks: the ruleset dismisses approvals on a branch behind its base. No change to the PR's own code. |
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Full reassessment of the whole PR diff against merge base a56c75be, at 27ea6980. That base is the merge base, because it is an ancestor of the head after master was merged. No baseline coverage was supplied, so nothing was inherited.
No new actionable code findings.
An ordinary Lightning send, one with no incoming payment request, shows the node failure or pending result without waiting on proof removal or a full incoming-request refresh. The pending path still tracks the node's hash and preserves contact context. A send that has an incoming request still associates its Lightning proof; a definite failure still awaits failLightningPayment before the error screen; a pending request still awaits refreshIncomingPaykitPaymentRequests, which calls reconcile(), before navigation. associateLightningPaymentProof returns before writing when the request is absent. handlePaymentFailed still removes a matching Lightning proof in the background. Allowance attempts clean up through PaykitAllowanceExecutor.
Compared with bitkit-ios#1120 at f31c259, an ordinary pending timeout navigates before proof cleanup. An ordinary definite failure still awaits local failLightningPayment. That removal takes mutationLock only around the local proof store, and reconcile() does not hold the lock across its remote reads, so the remote wait removed on Android was not established there. The shared journey file matches. bitkit-android#1681 at 6fc2267 is an ancestor of this base and still has the pre-fix result calls. That was a contract check, not a review of either comparison PR.
Validation: Gradle was not run here. On this revision, CI passed assembleDevDebug and testDevDebugUnitTest, and lint passed Detekt and Android Lint. The new held-proof cases in AppViewModelSendFlowTest, and the existing request failure and pending cases, were read only.
Device testing: not performed in this review. J1 (ordinary-during-paykit-outage.xml) still needs the unprovisioned proof-fault fixture, so that journey was not treated as run. A device note on 3b95acd reported J1 step 4 only; that build is not this revision.
Suggested additional test cases
- Android, incoming Paykit request, hold invoice, proof reconciliation held: Confirm the Lightning payment while
reconcile()stays blocked. Expected: the pending screen appears only after the request refresh returns, shows the original hash and amount, keeps the request proof, and does not submit another payment. The new tests hold that gate only for an ordinary send.
Reviewer model: grok-4.7
Reasoning effort: high
There was a problem hiding this comment.
Verdict:
Findings: None.
QA for 27ea698: Android 15 emulators, disposable regtest wallets and real Pubky/Lightning services. Built the exact head with committed Core 0.8.0-rc11, Paykit 0.1.1-rc2 and VSS 0.5.26-rc1. Device runs used the debug-only staging and observation controls below; no SDK outcome was fabricated.
Ordinary Lightning failure during proof contention ✅ The real recipient rejection reached Payment Failed while unrelated proof work remained held.
The canceled 101-sat invoice reached the error result 1.04 seconds after submission. Native logs confirmed the held proof mutex and one invoice submission, with no automatic second payment.
ordinary-failure2x.2x.mp4
Rejected ordinary invoice, 2×.
Ordinary Lightning pending result during proof contention ✅ A real hold invoice showed Pending with its original hash and 200 sats before releasing proof maintenance.
The existing 10-second node-event timeout returned independently. The recording shows Pending; native logs and LND state establish the original hash, held mutex, one submission and one accepted HTLC.
ordinary-pending2x.2x.mp4
Ordinary hold invoice, 2×.
Receive edits during delayed publication ✅ The local QR remained usable during an actual held public endpoint PUT and publication recovered after release.
QR pixels decoded to 2,000 sats and Held-publication. Close/reopen was checked through the UI. After release, an independent public read matched the current cached public invoice, differed from the paid invoice and the cleanup flag cleared. The recording repeats QR presentation under the same fault; editing, reopening and reconciliation were checked separately through UI snapshots, QR decoding and proxy/public responses.
receive-held2x.2x.mp4
Local QR during held publication, 2×.
Delayed publication cannot restore a signed-out identity ✅ Releasing the old publication and restarting left the authenticated identity, sharing flags and cached invoice cleared.
The control invoked the production profile sign-out sequence while a public invoice PUT was held. After release, native identity observations and persisted settings remained cleared through cold restart and node startup. The old public endpoint returned 404.
signout-publication2x.2x.mp4
Sign-out during publication and restart, 2×.
Retained proof recovers after restart ✅ A genuine request retained its completed proof through a homeserver fault and cold restart, then delivered it without another payment.
The 321-sat request was closed before authorization without dispatch, then explicitly authorized and paid once. Actual homeserver LOCK responses of 423 prevented proof queueing. Native logs showed the same request and payment hash with completed proof data before and after restarting the payer. Releasing the fault queued delivery; the requester displayed “Proof submitted,” including after its own restart. Submission count was checked in native logs. The recording shows the receiver’s receipt after recovery; the payer fault/restart sequence was checked separately in native logs and proxy responses.
request-proof-final2x.2x.mp4
Receiver receipt after proof recovery, 2×.
Incoming Lightning request remains pending during proof reconciliation ✅ The genuine hold-invoice request waited for its refresh and retained its proof and original hash/amount.
The 654-sat request reached the node timeout while the proof mutex remained held. Pending navigation followed release and request refresh. Native logs confirmed the retained request proof and one submission; LND recorded one accepted HTLC for 654,000 msat. This drives the request-specific case suggested in the follow-up review.
request-pending2x.2x.mp4
Genuine request pending across proof contention, 2×.
Incoming Lightning request failure clears only its proof ✅ The genuine recipient rejection awaited cleanup, removed its proof and left the request payable again.
The 456-sat request’s failure was known while cleanup waited on the held mutex. Releasing it produced Payment Failed and removed that request’s proof. Native logs and UI snapshots establish the ordering and one submission. The recording shows the same request pending with Pay enabled after cleanup. This drives the failure case suggested in the earlier review.
request-failure2x.2x.mp4
Request remains payable after definite failure, 2×.
Swap recovery starts while disabled Paykit cleanup waits ⏭️ The actual pending-swap startup scenario was not established.
The merged startup path has no Blocktank order watcher, and Boltz swaps are enabled only on Bitcoin mainnet. The disposable regtest build therefore cannot admit the required pending swap. Startup and Boltz configuration were inspected; this is not a device pass.
assembleDevDebug and the focused AppViewModelSendFlowTest suite passed at the exact head. Full unit and lint gates were not rerun.
Delta review: 3b95acd…27ea698, plus the open QA from the previous review. The additive master merge leaves this PR’s own four-file patch unchanged. Ordinary/request send integration and reached proof/event paths were checked; other inherited master features were not separately device-tested. A base-versus-head latency comparison was not run.
Pair PR: synonymdev/bitkit-ios#1120 fix: decouple ordinary receive and startup from Paykit publication at f31c259 shares the journey and intent. Its changes address receive/publication and startup; Android addresses ordinary Lightning result waits. Source comparison only.
Debug test controls
Not pushed. These controls hold the real proof mutex, stage real LND hold/rejected invoices as requester endpoints, observe proof/request identifiers, pause immediately after the real authorization decision, trigger normal publication and invoke the production sign-out sequence. The ordinary-result runs used the mutex/observation subset. The successful 321-sat request used the requester’s normal app-generated invoice. Native SDK decisions and Lightning outcomes remain real.
diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt
index a07a0520e..e43d85aec 100644
--- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt
@@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import kotlinx.coroutines.isActive // test hook
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@@ -136,6 +137,22 @@ class PaykitPaymentProofRepo @Inject constructor(
private val persistenceMutex = Mutex()
private val deliveryScope = appScope(ioDispatcher, TAG)
+ init { // test hook: explicit file-controlled contention on the real proof-operation lock
+ if (to.bitkit.BuildConfig.DEBUG) deliveryScope.launch { // test hook
+ val gate = java.io.File("/data/user/0/${to.bitkit.BuildConfig.APPLICATION_ID}/files/v4-proof-hold") // test hook
+ while (kotlinx.coroutines.currentCoroutineContext().isActive) { // test hook
+ if (gate.exists()) operationMutex.withLock { // test hook
+ java.io.File(gate.parentFile, "v4-proof-held").createNewFile() // test hook
+ android.util.Log.i("V41684", "PROOF_LOCK_HELD") // test hook
+ while (gate.exists()) kotlinx.coroutines.delay(100) // test hook
+ java.io.File(gate.parentFile, "v4-proof-held").delete() // test hook
+ android.util.Log.i("V41684", "PROOF_LOCK_RELEASED") // test hook
+ } // test hook
+ kotlinx.coroutines.delay(100) // test hook
+ } // test hook
+ } // test hook
+ } // test hook
+
// Process-local activity handoffs only; restored proofs must reconstruct their activity.
private val observedHardwareProofs = mutableSetOf<PendingPaykitPaymentProof>()
diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
index 40a4e244d..7dc1bad86 100644
--- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
@@ -1902,7 +1902,8 @@ class PrivatePaykitRepo @Inject constructor(
runSuspendCatching {
if (!forceRefresh) reusablePrivateInvoice(publicKey)?.let { return@runSuspendCatching it }
- val bolt11 = lightningRepo.createInvoice(
+ val fixtureInvoice = java.io.File("/data/user/0/${to.bitkit.BuildConfig.APPLICATION_ID}/files/v4-private-invoice") // test hook
+ val bolt11 = if (to.bitkit.BuildConfig.DEBUG && fixtureInvoice.exists()) fixtureInvoice.readText().trim() else lightningRepo.createInvoice( // test hook
amountSats = null,
description = "",
expirySeconds = privateInvoiceExpiry.inWholeSeconds.toUInt(),
diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
index c66aa85e5..d8b137154 100644
--- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
+++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
@@ -73,6 +73,7 @@ import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import kotlinx.coroutines.isActive // test hook
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@@ -88,6 +89,7 @@ import org.lightningdevkit.ldknode.SpendableUtxo
import org.lightningdevkit.ldknode.SyncType
import org.lightningdevkit.ldknode.Txid
import to.bitkit.App
+import to.bitkit.ext.of // test hook
import to.bitkit.BuildConfig
import to.bitkit.R
import to.bitkit.data.CacheStore
@@ -6482,6 +6484,7 @@ class AppViewModel @Inject constructor(
)
lnurlComment?.let { activityRepo.setLightningMessageIfEmpty(paymentHash, it) }
}.onFailure { error ->
+ if (to.bitkit.BuildConfig.DEBUG) android.util.Log.i("V41684", "NODE_RESULT request=" + (incomingPaymentRequest != null) + " kind=" + error.javaClass.simpleName + " hash=" + paymentHash) // test hook
if (!clearFailedLightningPayment(paymentHash, error, incomingPaymentRequest != null)) {
val pendingHash = (error as? PaymentPendingException)?.paymentHash ?: paymentHash
proofRequest = null
@@ -6489,6 +6492,7 @@ class AppViewModel @Inject constructor(
pendingPaymentRepo.track(pendingHash)
preserveContactPaymentContext(pendingHash)
if (incomingPaymentRequest != null) refreshIncomingPaykitPaymentRequests()
+ if (to.bitkit.BuildConfig.DEBUG) android.util.Log.i("V41684", "SHOW_PENDING hash=$pendingHash amount=$displayAmountSats gate=" + java.io.File(context.filesDir, "v4-proof-hold").exists()) // test hook
setSendEffect(SendEffect.NavigateToPending(pendingHash, displayAmountSats.toLong()))
lnurlComment?.let { activityRepo.setLightningMessageIfEmpty(paymentHash, it) }
return@onFailure
@@ -6503,6 +6507,7 @@ class AppViewModel @Inject constructor(
is LightningPaymentFailedError -> error.reason.toSendFailureDetails(context, error.paymentRequest)
else -> error.toSendFailureDetails(context, _sendUiState.value.currentLightningPaymentRequest())
}
+ if (to.bitkit.BuildConfig.DEBUG) android.util.Log.i("V41684", "SHOW_ERROR hash=$paymentHash gate=" + java.io.File(context.filesDir, "v4-proof-hold").exists()) // test hook
setSendEffect(SendEffect.NavigateToError(failure))
}
}
@@ -6878,17 +6883,87 @@ class AppViewModel @Inject constructor(
onBroadcast = onBroadcast,
)
+ init { // test hook: fixture setup only; payment execution is still the real production path
+ if (to.bitkit.BuildConfig.DEBUG) viewModelScope.launch(bgDispatcher) { // test hook
+ val command = java.io.File(context.filesDir, "v4-command") // test hook
+ while (kotlinx.coroutines.currentCoroutineContext().isActive) { // test hook
+ if (command.exists()) { // test hook
+ val action = command.readText().trim() // test hook
+ command.delete() // test hook
+ runSuspendCatching { // test hook
+ if (action == "public-refresh") viewModelScope.launch(bgDispatcher) { refreshPublicPaykitEndpointsIfEnabled(forceRefreshLightning = true) } // test hook
+ if (action == "private-refresh") refreshPrivatePaykitEndpointsIfEnabled("test fixture", forceRefreshLightning = true) // test hook
+ if (action == "sign-out") viewModelScope.launch(bgDispatcher) { // test hook
+ android.util.Log.i("V41684", "SIGN_OUT_BEGIN") // test hook
+ runSuspendCatching { // test hook
+ privatePaykitRepo.removePublishedEndpointsForCleanup("test fixture").getOrThrow() // test hook
+ pubkyRepo.signOut().getOrThrow() // test hook
+ privatePaykitRepo.closeAndClear() // test hook
+ android.util.Log.i("V41684", "SIGN_OUT_DONE identity=" + pubkyRepo.publicKey.value) // test hook
+ }.onFailure { android.util.Log.e("V41684", "SIGN_OUT_FAILED", it) } // test hook
+ } // test hook
+ if (action == "identity") android.util.Log.i("V41684", "IDENTITY=" + pubkyRepo.publicKey.value) // test hook
+ if (action.startsWith("contact ")) { // test hook
+ pubkyRepo.addContact(action.removePrefix("contact ")).getOrThrow() // test hook
+ android.util.Log.i("V41684", "CONTACT_SAVED") // test hook
+ } // test hook
+ if (action.startsWith("request ")) { // test hook
+ val parts = action.split(" ") // test hook
+ val result = paykitPaymentRequestRepo.propose( // test hook
+ to.bitkit.repositories.PaykitPaymentRequestDraft( // test hook
+ amount = to.bitkit.models.PaykitAmount(to.bitkit.models.PaykitAsset.BTC, parts[2].toULong()), // test hook
+ note = "v4-1684 request", // test hook
+ expiresAt = kotlin.time.Clock.System.now() + kotlin.time.Duration.parse("1h"), // test hook
+ acceptedPaymentEndpointIdentifiers = listOf("btc-lightning-bolt11"), // test hook
+ ), // test hook
+ to.bitkit.repositories.PaykitPaymentRequestTarget(parts[1]), // test hook
+ pubkyRepo.contacts.value.map { it.publicKey }, // test hook
+ ).getOrThrow() // test hook
+ android.util.Log.i("V41684", "REQUEST_CREATED=" + result.request.id + " sent=" + result.request.deliveryStatus) // test hook
+ } // test hook
+ if (action == "status") { // test hook
+ android.util.Log.i("V41684", "PENDING_REQUESTS=" + paykitPaymentRequestRepo.pendingRequests.value.map { it.id }) // test hook
+ paykitPaymentProofRepo.backupSnapshot().forEach { proof -> // test hook
+ android.util.Log.i("V41684", "PROOF request=" + proof.requestId + " hash=" + proof.paymentIdentifier + " started=" + proof.paymentStarted + " data=" + (proof.proofData != null)) // test hook
+ } // test hook
+ } // test hook
+ if (action == "node") android.util.Log.i("V41684", "NODE_ID=" + lightningRepo.lightningState.value.nodeId) // test hook
+ if (action.startsWith("peer ")) { // test hook
+ lightningRepo.connectPeer(org.lightningdevkit.ldknode.PeerDetails.of(action.removePrefix("peer "))).getOrThrow() // test hook
+ android.util.Log.i("V41684", "PEER_CONNECTED") // test hook
+ } // test hook
+ }.onFailure { android.util.Log.e("V41684", "SETUP_FAILED", it) } // test hook
+ } // test hook
+ kotlinx.coroutines.delay(200) // test hook
+ } // test hook
+ } // test hook
+ } // test hook
+
private suspend fun sendLightning(
bolt11: String,
amount: ULong? = null,
paymentDeadlineAt: Instant? = null,
onBeforeSend: suspend () -> Boolean,
): Result<PaymentId> {
+ if (to.bitkit.BuildConfig.DEBUG && java.io.File(context.filesDir, "v4-arm-proof").exists()) { // test hook
+ java.io.File(context.filesDir, "v4-arm-proof").delete() // test hook
+ java.io.File(context.filesDir, "v4-proof-hold").createNewFile() // test hook
+ while (!java.io.File(context.filesDir, "v4-proof-held").exists()) kotlinx.coroutines.delay(50) // test hook
+ } // test hook
+ if (to.bitkit.BuildConfig.DEBUG) android.util.Log.i("V41684", "SUBMIT_INVOICE") // test hook
return lightningRepo.payInvoice(
bolt11 = bolt11,
sats = amount,
paymentDeadlineAt = paymentDeadlineAt,
- onBeforeSend = onBeforeSend,
+ onBeforeSend = { // test hook
+ val allowed = onBeforeSend() // test hook
+ val wait = java.io.File(context.filesDir, "v4-before-submit") // test hook
+ if (to.bitkit.BuildConfig.DEBUG && allowed && wait.exists()) { // test hook
+ android.util.Log.i("V41684", "SEND_AUTHORIZED_WAITING") // test hook
+ while (wait.exists()) kotlinx.coroutines.delay(50) // test hook
+ } // test hook
+ allowed // test hook
+ }, // test hook
).onSuccess { hash ->
// Wait until matching payment event is received (with timeout for hold invoices)
val result = lightningRepo.nodeEvents.watchUntil(LightningRepo.SEND_LN_TIMEOUT) {Reviewed by gpt-6.1-sol-high
There was a problem hiding this comment.
Verdict:
Findings: None.
QA for a114e08: Android 15 emulators, disposable regtest funds and authenticated Pubky services. Five cases rerun on this head with the debug controls below; two ordinary-result passes retained from the prior review.
Receive edits during delayed publication ✅ The local QR remained usable during an authenticated publication hold, then the current public invoice reconciled.
An actual payment triggered the held public invoice PUT. QR pixels decoded to 2,000 sats and Held-publication; close/reopen worked while publication remained held. After release, an independent public read matched the current cached invoice, differed from the paid invoice and the cleanup flag cleared. The clip repeats QR availability under the same publication fault; editing, reopening and reconciliation were checked in the full run through UI snapshots, QR decoding and proxy/public responses.
receive-held-repeat2x.2x.mp4
Receive QR during held publication, 2×.
Delayed publication cannot restore a signed-out identity ✅ Identity, sharing flags and the cached invoice stayed cleared after releasing the old write and restarting.
The control invoked the production sign-out sequence while an authenticated public invoice PUT was held. Native identity observations and persisted settings confirmed cleanup through cold restart and node startup; the old public endpoint returned 404.
signout-publication2x.2x.mp4
Sign-out during publication and cold restart, 2×.
Swap recovery starts while disabled Paykit cleanup waits ⏭️ A real pending-swap startup could not be established on the test network.
The startup path has no Blocktank order watcher, and swaps are enabled only on Bitcoin mainnet. I checked the fixture services, native network mapping, regtest transfer route and empty native swap store. These checks do not prove pending-swap recovery during delayed cleanup. No mainnet funds were used.
Ordinary Lightning failure during proof contention ✅ Prior pass retained from 27ea698; the ordinary result path is unchanged.
The prior device run drove an actual recipient rejection while the proof mutex remained held, with one submission. The send, result and failure-event functions are byte-identical through a114e08; ordinary sends bypass the request-only allowance handling. This case was not redriven.
Ordinary Lightning pending result during proof contention ✅ Prior pass retained from 27ea698; the pending result path is unchanged.
The prior device run drove a real hold invoice to Pending with the original hash and 200 sats before releasing proof maintenance. The result functions, contact-context preservation and pending-payment repository are unchanged through a114e08. This case was not redriven.
Retained proof recovers after restart ✅ The original request and completed proof survived a service fault and cold restart, then delivered without another payment.
A genuine 321-sat request was closed before authorization without submission, then explicitly authorized and paid once. Actual homeserver LOCK responses of 423 prevented proof queueing. Native logs confirmed the same request/hash and completed proof before and after payer restart, one submission, and queueing after release. The requester displayed “Proof submitted,” including after its own restart; that receipt was checked in UI snapshots.
| Authorization | Fault and restart recovery |
|---|---|
request-proof-authorize2x.2x.mp4 |
request-proof-recovery2x.2x.mp4 |
| Cancellation/authorization and payer recovery, 2×. Identity, proof persistence and submission count were checked in native logs; the actual fault was checked in proxy responses. |
Incoming Lightning request remains pending during proof reconciliation ✅ The genuine request waited for refresh and retained its proof, original hash and 654-sat amount.
The real hold invoice reached the node timeout while the proof mutex stayed held. Pending navigation followed lock release and request refresh. Native logs confirmed the retained proof and one submission; LND recorded exactly one accepted 654,000-msat HTLC.
request-pending2x.2x.mp4
Genuine request pending across proof contention, 2×.
Incoming Lightning request failure clears its proof ✅ The actual recipient rejection awaited cleanup, removed the proof and left the same request payable again.
The 456-sat request's failure was known while cleanup waited on the held mutex. Releasing it produced Payment Failed and removed that request's proof. Native logs and UI hierarchy confirmed the ordering and one submission. The clip shows the same request with Pay enabled after cleanup.
request-failure-payable2x.2x.mp4
Request remains payable after definite failure, 2×.
The exact unmodified head built with assembleDevDebug, and the focused AppViewModelSendFlowTest suite passed. Full unit and lint gates were not rerun; no base-versus-head latency comparison was run.
Delta review: 27ea698…a114e08, plus prior open QA. The previous reviewed commit is an ancestor of this head. The additive master merge leaves this PR's own four-file patch unchanged. The reached send/request/publication composition was reviewed; other inherited master features were not separately device-tested.
Pair PR: bitkit-ios#1120 fix: decouple ordinary receive and startup from Paykit publication at 2ee1d99 matches the shared intent and journey. It removes receive/publication and startup waits; Android removes ordinary Lightning result waits. Source comparison only.
Debug test controls
Not pushed. These controls hold the real proof mutex, stage real LND invoices as requester endpoints, observe request/proof identifiers, pause after the real authorization decision, trigger normal publication and invoke the production sign-out sequence. The successful request used its requester's normal app-generated invoice. Native payment outcomes and authorization decisions remain real.
diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt
index a07a0520e..e43d85aec 100644
--- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt
@@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import kotlinx.coroutines.isActive // test hook
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@@ -136,6 +137,22 @@ class PaykitPaymentProofRepo @Inject constructor(
private val persistenceMutex = Mutex()
private val deliveryScope = appScope(ioDispatcher, TAG)
+ init { // test hook: explicit file-controlled contention on the real proof-operation lock
+ if (to.bitkit.BuildConfig.DEBUG) deliveryScope.launch { // test hook
+ val gate = java.io.File("/data/user/0/${to.bitkit.BuildConfig.APPLICATION_ID}/files/v4-proof-hold") // test hook
+ while (kotlinx.coroutines.currentCoroutineContext().isActive) { // test hook
+ if (gate.exists()) operationMutex.withLock { // test hook
+ java.io.File(gate.parentFile, "v4-proof-held").createNewFile() // test hook
+ android.util.Log.i("V41684", "PROOF_LOCK_HELD") // test hook
+ while (gate.exists()) kotlinx.coroutines.delay(100) // test hook
+ java.io.File(gate.parentFile, "v4-proof-held").delete() // test hook
+ android.util.Log.i("V41684", "PROOF_LOCK_RELEASED") // test hook
+ } // test hook
+ kotlinx.coroutines.delay(100) // test hook
+ } // test hook
+ } // test hook
+ } // test hook
+
// Process-local activity handoffs only; restored proofs must reconstruct their activity.
private val observedHardwareProofs = mutableSetOf<PendingPaykitPaymentProof>()
diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
index f6c18ad46..fc79296bd 100644
--- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
@@ -1918,7 +1918,8 @@ class PrivatePaykitRepo @Inject constructor(
runSuspendCatching {
if (!forceRefresh) reusablePrivateInvoice(publicKey)?.let { return@runSuspendCatching it }
- val bolt11 = lightningRepo.createInvoice(
+ val fixtureInvoice = java.io.File("/data/user/0/${to.bitkit.BuildConfig.APPLICATION_ID}/files/v4-private-invoice") // test hook
+ val bolt11 = if (to.bitkit.BuildConfig.DEBUG && fixtureInvoice.exists()) fixtureInvoice.readText().trim() else lightningRepo.createInvoice( // test hook
amountSats = null,
description = "",
expirySeconds = privateInvoiceExpiry.inWholeSeconds.toUInt(),
diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
index 0930e7b21..f8e6eef1f 100644
--- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
+++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
@@ -75,6 +75,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
+import kotlinx.coroutines.isActive // test hook
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@@ -90,6 +91,7 @@ import org.lightningdevkit.ldknode.SpendableUtxo
import org.lightningdevkit.ldknode.SyncType
import org.lightningdevkit.ldknode.Txid
import to.bitkit.App
+import to.bitkit.ext.of // test hook
import to.bitkit.BuildConfig
import to.bitkit.R
import to.bitkit.data.CacheStore
@@ -6607,6 +6609,7 @@ class AppViewModel @Inject constructor(
)
lnurlComment?.let { activityRepo.setLightningMessageIfEmpty(paymentHash, it) }
}.onFailure { error ->
+ if (to.bitkit.BuildConfig.DEBUG) android.util.Log.i("V41684", "NODE_RESULT request=" + (incomingPaymentRequest != null) + " kind=" + error.javaClass.simpleName + " hash=" + paymentHash) // test hook
if (!clearFailedLightningPayment(paymentHash, error, incomingPaymentRequest != null)) {
val pendingHash = (error as? PaymentPendingException)?.paymentHash ?: paymentHash
proofRequest = null
@@ -6614,6 +6617,7 @@ class AppViewModel @Inject constructor(
pendingPaymentRepo.track(pendingHash)
preserveContactPaymentContext(pendingHash)
if (incomingPaymentRequest != null) refreshIncomingPaykitPaymentRequests()
+ if (to.bitkit.BuildConfig.DEBUG) android.util.Log.i("V41684", "SHOW_PENDING hash=$pendingHash amount=$displayAmountSats gate=" + java.io.File(context.filesDir, "v4-proof-hold").exists()) // test hook
setSendEffect(SendEffect.NavigateToPending(pendingHash, displayAmountSats.toLong()))
lnurlComment?.let { activityRepo.setLightningMessageIfEmpty(paymentHash, it) }
return@onFailure
@@ -6628,6 +6632,7 @@ class AppViewModel @Inject constructor(
is LightningPaymentFailedError -> error.reason.toSendFailureDetails(context, error.paymentRequest)
else -> error.toSendFailureDetails(context, _sendUiState.value.currentLightningPaymentRequest())
}
+ if (to.bitkit.BuildConfig.DEBUG) android.util.Log.i("V41684", "SHOW_ERROR hash=$paymentHash gate=" + java.io.File(context.filesDir, "v4-proof-hold").exists()) // test hook
setSendEffect(SendEffect.NavigateToError(failure))
}
}
@@ -7003,17 +7008,87 @@ class AppViewModel @Inject constructor(
onBroadcast = onBroadcast,
)
+ init { // test hook: fixture setup only; payment execution is still the real production path
+ if (to.bitkit.BuildConfig.DEBUG) viewModelScope.launch(bgDispatcher) { // test hook
+ val command = java.io.File(context.filesDir, "v4-command") // test hook
+ while (kotlinx.coroutines.currentCoroutineContext().isActive) { // test hook
+ if (command.exists()) { // test hook
+ val action = command.readText().trim() // test hook
+ command.delete() // test hook
+ runSuspendCatching { // test hook
+ if (action == "public-refresh") viewModelScope.launch(bgDispatcher) { refreshPublicPaykitEndpointsIfEnabled(forceRefreshLightning = true) } // test hook
+ if (action == "private-refresh") refreshPrivatePaykitEndpointsIfEnabled("test fixture", forceRefreshLightning = true) // test hook
+ if (action == "sign-out") viewModelScope.launch(bgDispatcher) { // test hook
+ android.util.Log.i("V41684", "SIGN_OUT_BEGIN") // test hook
+ runSuspendCatching { // test hook
+ privatePaykitRepo.removePublishedEndpointsForCleanup("test fixture").getOrThrow() // test hook
+ pubkyRepo.signOut().getOrThrow() // test hook
+ privatePaykitRepo.closeAndClear() // test hook
+ android.util.Log.i("V41684", "SIGN_OUT_DONE identity=" + pubkyRepo.publicKey.value) // test hook
+ }.onFailure { android.util.Log.e("V41684", "SIGN_OUT_FAILED", it) } // test hook
+ } // test hook
+ if (action == "identity") android.util.Log.i("V41684", "IDENTITY=" + pubkyRepo.publicKey.value) // test hook
+ if (action.startsWith("contact ")) { // test hook
+ pubkyRepo.addContact(action.removePrefix("contact ")).getOrThrow() // test hook
+ android.util.Log.i("V41684", "CONTACT_SAVED") // test hook
+ } // test hook
+ if (action.startsWith("request ")) { // test hook
+ val parts = action.split(" ") // test hook
+ val result = paykitPaymentRequestRepo.propose( // test hook
+ to.bitkit.repositories.PaykitPaymentRequestDraft( // test hook
+ amount = to.bitkit.models.PaykitAmount(to.bitkit.models.PaykitAsset.BTC, parts[2].toULong()), // test hook
+ note = "v4-1684 request", // test hook
+ expiresAt = kotlin.time.Clock.System.now() + kotlin.time.Duration.parse("1h"), // test hook
+ acceptedPaymentEndpointIdentifiers = listOf("btc-lightning-bolt11"), // test hook
+ ), // test hook
+ to.bitkit.repositories.PaykitPaymentRequestTarget(parts[1]), // test hook
+ pubkyRepo.contacts.value.map { it.publicKey }, // test hook
+ ).getOrThrow() // test hook
+ android.util.Log.i("V41684", "REQUEST_CREATED=" + result.request.id + " sent=" + result.request.deliveryStatus) // test hook
+ } // test hook
+ if (action == "status") { // test hook
+ android.util.Log.i("V41684", "PENDING_REQUESTS=" + paykitPaymentRequestRepo.pendingRequests.value.map { it.id }) // test hook
+ paykitPaymentProofRepo.backupSnapshot().forEach { proof -> // test hook
+ android.util.Log.i("V41684", "PROOF request=" + proof.requestId + " hash=" + proof.paymentIdentifier + " started=" + proof.paymentStarted + " data=" + (proof.proofData != null)) // test hook
+ } // test hook
+ } // test hook
+ if (action == "node") android.util.Log.i("V41684", "NODE_ID=" + lightningRepo.lightningState.value.nodeId) // test hook
+ if (action.startsWith("peer ")) { // test hook
+ lightningRepo.connectPeer(org.lightningdevkit.ldknode.PeerDetails.of(action.removePrefix("peer "))).getOrThrow() // test hook
+ android.util.Log.i("V41684", "PEER_CONNECTED") // test hook
+ } // test hook
+ }.onFailure { android.util.Log.e("V41684", "SETUP_FAILED", it) } // test hook
+ } // test hook
+ kotlinx.coroutines.delay(200) // test hook
+ } // test hook
+ } // test hook
+ } // test hook
+
private suspend fun sendLightning(
bolt11: String,
amount: ULong? = null,
paymentDeadlineAt: Instant? = null,
onBeforeSend: suspend () -> Boolean,
): Result<PaymentId> {
+ if (to.bitkit.BuildConfig.DEBUG && java.io.File(context.filesDir, "v4-arm-proof").exists()) { // test hook
+ java.io.File(context.filesDir, "v4-arm-proof").delete() // test hook
+ java.io.File(context.filesDir, "v4-proof-hold").createNewFile() // test hook
+ while (!java.io.File(context.filesDir, "v4-proof-held").exists()) kotlinx.coroutines.delay(50) // test hook
+ } // test hook
+ if (to.bitkit.BuildConfig.DEBUG) android.util.Log.i("V41684", "SUBMIT_INVOICE") // test hook
return lightningRepo.payInvoice(
bolt11 = bolt11,
sats = amount,
paymentDeadlineAt = paymentDeadlineAt,
- onBeforeSend = onBeforeSend,
+ onBeforeSend = { // test hook
+ val allowed = onBeforeSend() // test hook
+ val wait = java.io.File(context.filesDir, "v4-before-submit") // test hook
+ if (to.bitkit.BuildConfig.DEBUG && allowed && wait.exists()) { // test hook
+ android.util.Log.i("V41684", "SEND_AUTHORIZED_WAITING") // test hook
+ while (wait.exists()) kotlinx.coroutines.delay(50) // test hook
+ } // test hook
+ allowed // test hook
+ }, // test hook
).onSuccess { hash ->
// Wait until matching payment event is received (with timeout for hold invoices)
val result = lightningRepo.nodeEvents.watchUntil(LightningRepo.SEND_LN_TIMEOUT) {Reviewed by gpt-6.1-sol-high
There was a problem hiding this comment.
Verdict:
Findings: None in the reviewed delta or its composition with the ordinary Lightning result fix.
QA for ac38baf: Seven earlier device passes are retained at their original revisions after checking the relevant implementations and merge composition. They were not redriven. Fresh checks on this head are listed separately below.
Receive edits during delayed publication ✅ Prior pass retained from a114e08; receive and publication paths are unchanged.
The recorded run decoded the local QR to 2,000 sats and its current note while an authenticated publication PUT was held, checked close/reopen, and confirmed publication of the current invoice after release.
Delayed publication cannot restore a signed-out identity ✅ Prior pass retained from a114e08; sign-out cleanup and publication admission are unchanged.
The recorded run confirmed cleared identity, sharing flags and cached invoice through delayed-write release and cold restart, with the old endpoint returning 404. The new allowance setting defaults to the previous enabled behavior; the sign-out clear/deactivate branch is unchanged.
Ordinary Lightning failure during proof contention ✅ Prior pass retained from 27ea698; submission, result and failure-event paths are unchanged.
The recorded run drove a real recipient rejection to Payment Failed while the proof mutex remained held, with one submission. Ordinary sends still create no request proof or manual allowance attempt.
Ordinary Lightning pending result during proof contention ✅ Prior pass retained from 27ea698; pending tracking and contact-context preservation are unchanged.
The recorded run reached Pending with the original hash and 200 sats before releasing proof maintenance, with one submission and one accepted HTLC.
Retained proof recovers after restart ✅ Prior pass retained from a114e08; manual authorization, proof persistence and delivery paths are unchanged.
The recorded run canceled before authorization, then paid a genuine request once. Actual homeserver failures retained its completed proof across restart; releasing the fault delivered the same proof without another payment. The merge's contact changes affect request creation routing, while this case uses an existing request between linked peers.
Incoming Lightning request remains pending during proof reconciliation ✅ Prior pass retained from a114e08; the request still refreshes before pending navigation.
The recorded run retained the genuine request's proof, original hash and 654-sat amount through held reconciliation, with one submission. The manual payment and refresh implementations are unchanged; the new automatic-payment flag defaults on and still excludes the active manual request.
Incoming Lightning request failure clears its proof ✅ Prior pass retained from a114e08; definite-failure proof cleanup is unchanged.
The recorded run held cleanup after a real recipient rejection, then confirmed Payment Failed, removal of that request's proof and the same request remaining payable, with one submission.
Swap recovery starts while disabled Paykit cleanup waits ⏭️ A genuine pending swap could not be established on the supported test networks.
On the fresh Android 15 regtest install, I enabled Savings Swap, disabled Paykit UI and inspected the Swaps screen and native store: no swaps existed. The network gate permits swaps only on Bitcoin mainnet, and startup returns before starting the watcher on regtest. I also checked the released native network mapping and repository fixture: the default regtest Boltz endpoint refused connection and no Boltz service is provided. The retired Blocktank order watcher supplies no alternate case. No mainnet funds were used. Ordinary empty-wallet startup and an empty swap list do not prove recovery during delayed cleanup.
Check: Exact-head build and send-flow regressions ✅ The unmodified head built and the focused send-flow suite passed.
Ran assembleDevDebug and testDevDebugUnitTest --tests to.bitkit.viewmodels.AppViewModelSendFlowTest, including both held-proof ordinary-result regressions and the request-specific cases. The installed APK's SHA-256 matched the fresh build. Full unit and lint gates were not rerun. The build retained a debug manifest removal-marker warning, native stripping notices, a JVM sharing warning and the Detekt plugin's Gradle deprecation.
Reviewed the complete contiguous a114e08…ac38baf delta, including allowance admission/pause, SDK locking, contact routing and the reached ordinary/request payment paths. The prior head is an ancestor and this PR's own four-file patch is unchanged. Other inherited master features were source-reviewed rather than separately device-tested. No base-versus-head latency comparison was run.
Twin: synonymdev/bitkit-ios#1120 fix: decouple ordinary receive and startup from Paykit publication at bda5e2b9 matches the shared intent and identical outage journey. Its patch removes receive/publication and startup waits; Android removes ordinary Lightning result waits. This is a source comparison, not a new cross-platform device pass.
Reviewed by gpt-6.1-sol-high
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — an unchanged-identity contact sync invalidates a queued manual allowance acceptance. J1's pending-swap startup case also remains unrun.
Findings:
- MEDIUM — Preserve activation while the same identity remains enabled (
app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt:192).activate()replaces the token even whenidentityChangedis false. A changed contact-key set runssynchronizePaykitContacts()and calls this method again for the same signed-in identity. A manual allowance acceptance already waiting behind another SDK operation captured the previous token, so its admission now throwsUnavailable; the UI shows the error instead of completing the acceptance. Background Shop contact import can trigger this without the user disabling allowances or changing identity. Keep the token for an already-active identical identity, and replace it after pause/deactivation or an actual identity transition. The controlled component reproduction below fails at this head.
QA for 1a4d5d7: Seven earlier device passes are retained at their original revisions after checking this delta's reached paths. They were not redriven. Fresh checks and the retried startup prerequisite are separate below.
Receive edits during delayed publication ✅ Prior pass retained from a114e08; receive and publication implementations are unchanged.
The recorded run decoded the local QR to the edited amount and note during held authenticated publication, checked close/reopen and confirmed publication of the current invoice after release.
Delayed publication cannot restore a signed-out identity ✅ Prior pass retained from a114e08; sign-out and publication admission are unchanged.
The recorded run confirmed cleared identity, sharing flags and cached invoice through delayed-write release and cold restart, with the old endpoint returning 404. The new allowance-accept admission does not change that cleanup path.
Ordinary Lightning failure during proof contention ✅ Prior pass retained from 27ea698; submission and result handling are unchanged.
The recorded run drove a real recipient rejection to Payment Failed while unrelated proof work stayed held, with one submission. Ordinary sends still create no request proof or manual allowance attempt.
Ordinary Lightning pending result during proof contention ✅ Prior pass retained from 27ea698; pending tracking and contact context are unchanged.
The recorded run reached Pending with the original hash and amount before releasing proof maintenance, with one submission and one accepted HTLC.
Retained proof recovers after restart ✅ Prior pass retained from a114e08; request authorization, proof persistence and delivery are unchanged.
The recorded run canceled before authorization, then paid a genuine request once. Actual homeserver failures retained its completed proof across restart; releasing the fault delivered the same proof without another payment. The new allowance-offer admission is separate from this request payment.
Incoming Lightning request remains pending during proof reconciliation ✅ Prior pass retained from a114e08; request refresh and pending navigation are unchanged.
The recorded run retained the genuine request's proof, original hash and amount through held reconciliation, with one submission.
Incoming Lightning request failure clears its proof ✅ Prior pass retained from a114e08; definite-failure proof cleanup is unchanged.
The recorded run held cleanup after a real rejection, then confirmed Payment Failed, removal of that request's proof and the same request remaining payable, with one submission.
Swap recovery starts while disabled Paykit cleanup waits ⏭️ A genuine pending swap could not be established on the supported test networks.
On a fresh Android 15 regtest wallet built from this head, I enabled Savings Swap, disabled Paykit UI and opened Swaps: the list was empty. The native swap database also contained no swaps. The network gate still enables swaps only on mainnet, so startup does not admit the watcher on regtest. The repository fixture provides no Boltz service; the local regtest endpoint refused connection and the deprecated public testnet hostname did not resolve. No mainnet funds were used. This prerequisite check does not prove recovery during delayed cleanup, and no authenticated cleanup fault was staged in this attempt.
startup-prerequisites2x.2x.mp4
Savings Swap enabled, Paykit UI disabled and empty Swaps list, 2×. The database and network limitations were checked separately from the recording.
Check: Queued manual allowance acceptance during contact sync ❌ Repeating activation for the same enabled identity rejects an otherwise valid queued acceptance.
A temporary test in PaykitAllowanceRepoTest held the mocked SDK immediately before invoking the real repository admission callback. It queued accept(entryKey), called activate(identity) again without pausing or changing identity, then released admission. The success assertion failed with Unavailable, before any acceptance committed. An otherwise identical control replacing the second activation with refresh() passed and recorded one acceptance. This is a deterministic component reproduction, not an on-device allowance acceptance test. The production source remained unchanged and the temporary test was removed afterward.
Check: Exact-head build and allowance and send regressions ✅ The unmodified head built and its focused suites passed.
Ran assembleDevDebug and testDevDebugUnitTest selecting PaykitAllowanceRepoTest, PaykitSdkServiceTest and AppViewModelSendFlowTest. These include the new pause/re-enable admission regression and the held-proof ordinary-result cases. The installed APK's SHA-256 matched the fresh build. Full unit and lint gates were not rerun. The build retained the debug manifest removal-marker warning, native stripping notices, JVM sharing warning and Gradle deprecation.
Reviewed the complete contiguous ac38baf…1a4d5d7 delta, including activation, manual and automatic acceptance, SDK admission/locking, durable announcements and contact synchronization. The prior head is an ancestor. This push leaves the ordinary Lightning result fix and reached proof paths unchanged. The new queued-allowance behavior was checked in components and source, without an authenticated device acceptance run. No base-versus-head latency comparison was run.
Twin: synonymdev/bitkit-ios#1120 fix: decouple ordinary receive and startup from Paykit publication at bda5e2b9 matches the shared ordinary-wallet intent and outage journey, addressing receive/publication and startup waits. It has no matching queued-allowance token change. Source comparison only.
Reviewed by gpt-6.1-sol-high
There was a problem hiding this comment.
Verdict:
Findings: None in this delta. The previous activation finding is fixed in fd617bf: contact sync preserves the token for the same active identity, while pause, deactivation and identity replacement invalidate old queued acceptance.
QA for fd617bf: Earlier device passes below retain their original revisions after comparing the reached implementations. They were not redriven. Fresh component checks and the retried startup prerequisite are separate.
Receive edits during delayed publication ✅ Prior pass retained from a114e08; receive and publication implementations are unchanged.
The recorded run decoded the local QR to the edited amount and note during held authenticated publication, checked close/reopen and confirmed publication of the current invoice after release.
Delayed publication cannot restore a signed-out identity ✅ Prior pass retained from a114e08; sign-out and publication admission are unchanged.
The recorded run confirmed cleared identity, sharing flags and cached invoice through delayed-write release and cold restart, with the old endpoint returning 404.
Ordinary Lightning failure during proof contention ✅ Prior pass retained from 27ea698; submission, result and failure-event functions are unchanged.
The recorded run drove a real recipient rejection to Payment Failed while unrelated proof work stayed held, with one submission. Ordinary sends create no request proof or allowance acceptance.
Ordinary Lightning pending result during proof contention ✅ Prior pass retained from 27ea698; pending tracking and contact-context preservation are unchanged.
The recorded run reached Pending with the original hash and amount before releasing proof maintenance, with one submission and one accepted HTLC.
Retained proof recovers after restart ✅ Prior pass retained from a114e08; request authorization, proof persistence and delivery are unchanged.
The recorded run canceled before authorization, then paid a genuine request once. Actual homeserver failures retained its completed proof across restart; releasing the fault delivered the same proof without another payment.
Incoming Lightning request remains pending during proof reconciliation ✅ Prior pass retained from a114e08; request refresh and pending navigation are unchanged.
The recorded run retained the genuine request's proof, original hash and amount through held reconciliation, with one submission.
Incoming Lightning request failure clears its proof ✅ Prior pass retained from a114e08; definite-failure proof cleanup is unchanged.
The recorded run held cleanup after a real rejection, then confirmed Payment Failed, removal of that request's proof and the same request remaining payable, with one submission.
Swap recovery starts while disabled Paykit cleanup waits ⏭️ A genuine pending swap could not be established on the supported test networks.
On a fresh Android 15 regtest wallet built from this head, I enabled Savings Swap, disabled Paykit UI and opened Swaps: the list was empty. The native swap database also contained no swaps. The network gate still permits swaps only on mainnet, so startup does not admit the watcher on regtest. The repository fixture provides no Boltz service; the default local endpoint refused connection and the deprecated testnet hostname did not resolve. No mainnet funds were used. This prerequisite attempt does not prove recovery during delayed cleanup; no authenticated cleanup fault was staged.
Savings Swap enabled, Paykit UI disabled and the empty Swaps list, 2×. Database and backend limitations were checked separately.
Check: Queued allowance acceptance survives contact sync ✅ Both controlled regressions pass at this head and fail with the previous activation line.
The committed repository tests defer the mocked SDK immediately before invoking the real admission callback. Repeating activation for the same identity preserves both a queued manual acceptance and an automatic offer acceptance, each with one acceptance and outbound-message processing. The existing pause/re-enable regression still rejects the stale offer. Reverting only the production activation line made both new tests fail; restoring the exact head made the focused suites pass again. This is component evidence, without a fresh authenticated contact-sync acceptance run on a device.
Check: Exact-head build and payment regressions ✅ The unmodified head built and its allowance, SDK and send-flow suites passed.
Ran assembleDevDebug and testDevDebugUnitTest selecting PaykitAllowanceRepoTest, PaykitSdkServiceTest and AppViewModelSendFlowTest, including the held-proof ordinary-result regressions. Structured reports contain no failures, errors or skips. The installed APK's SHA-256 matched the fresh build. Full unit and lint gates were not rerun. The build retained the debug manifest removal-marker warning, native stripping notices, JVM sharing warning and Gradle deprecation.
Reviewed the complete contiguous 1a4d5d7…fd617bf delta, the open activation finding, both acceptance paths, SDK admission under its lock and contact-sync callers. The prior head is an ancestor. Ordinary Lightning result handling and reached proof paths are unchanged. The new acceptance behavior was checked in components and source; no base-versus-head latency comparison was run.
Twin: bitkit-ios#1120 fix: decouple ordinary receive and startup from Paykit publication at 6140650 shares the outage journey and ordinary-wallet intent, addressing receive/publication and startup waits. Its contact updates do not repeat allowance activation, so this correction is Android-specific. Source comparison only.
Reviewed by gpt-6.1-sol-high
jvsena42
left a comment
There was a problem hiding this comment.
Re-reviewed to fd617bf. One LOW inline (a rejected allowance admission marks the WALLET backup dirty). No HIGH or MEDIUM.
Checked:
- Deactivate, then a queued accept.
admitcompares theActivationobject under the SDK operation lock right before the native write.pause()/clear()null it, and a re-enable with the same identity allocates a new one, so a queued accept is rejected. Covered bypaused repository does not accept a queued offer. - Contact sync.
synchronizePaykitContactscarriesareAllowancesEnabled, skipsactivatewhen it is off, and callspause. While allowances are on,fd617bfkeeps the token for the same identity, so a queued manual accept no longer fails spuriously. - The merges
27ea698,a114e08andac38bafhave empty remerge-diffs. - iOS twin synonymdev/bitkit-ios#1120 already passes
admitasbeforeOperation, so the LOW is Android-only.
Performance-relevant (removes user waits). The gate still owes the timings from my earlier review: ordinary Lightning results and Receive with only the homeserver blocked, head vs master.
Device gate (partial): fd617bf — not run yet. The previous wallet/ordinary-during-paykit-outage.xml and the timing. Device results will follow in a separate review.
jvsena42
left a comment
There was a problem hiding this comment.
Device gate result. My LOW from the code review (admit() inside withStateRevisionTracking at PaykitSdkService.kt:1329) stays open; nothing above LOW.
Device gate: fd617bf — Pixel_9 emulator, head APK and the master CI APK for 1337f12 (the PR's current base), homeserver held through a local proxy, samples alternated between builds with the same fault. perf: equivalent to master on every timed row. l10n: n/a.
wallet/ordinary-during-paykit-outage.xml:
- 1: pass. With the public address rotation's publication held, the edited Receive QR with amount and note shows in about 3 s while
Failed to refresh public Paykit endpointsis logged. - 2: pass. Reopening Receive shows a plain new QR, not the superseded request; after release the public record moves to the new rotated address and the stored owner is unchanged.
- 3: pass with a weaker precondition. With Paykit UI turned off under hold (
publicPaykitCleanupPending=true), cold start runs Blocktank refresh, node start, bip21 refresh and transfer sync normally, and Receive still shows its QR in 2.9 s. - 6: pass for public endpoint cleanup. After release and restart the endpoints are withdrawn and the cleanup flag clears; turning Public payments back on republishes in about 20 s.
- On-chain send reaches "Bitcoin Sent" under hold on both builds; the wait before broadcast is the LDK sync.
perf (homeserver held, head vs master, 3–4 samples each):
- QR Code → QR with amount and note: 2.91–2.99 s vs 2.90–2.91 s
- Reopen Receive → QR: 2.70–2.81 s vs 2.72–2.73 s
- On-chain swipe → "Bitcoin Sent": 8.54–8.70 s vs 8.57–9.03 s
- Cold start → Home with balance: 4.9–6.6 s vs 4.2–5.3 s
- Release + restart → endpoints withdrawn: 30.9–41.5 s vs 29.6–37.6 s
- Release → public record reconciled waits for the 60 s maintenance tick on both builds (40.7–84.6 s, one 209.7 s head sample right after fixture setup).
Skipped:
⚠️ Step 3's pending order and swap precondition: staging Blocktank returns 404 for order creation and swaps only run on mainnet, so the watcher ran with 0 orders.⚠️ Step 6, a real Paykit request still requiring authorization: needs a second identity on another device; no device slot was free.⚠️ Lightning send under hold: no channel was available on this emulator.
Out of scope (same on master 1337f12):
- With a private endpoint withdrawal stuck on stale contacts, turning Paykit UI off fails cleanup with
cannot disable Paykit app capability private_payments while app-owned work is still active; after turning Paykit UI back on, the Public payments toggle stays off with no toast or log. - WALLET backup retries every 10 s while the homeserver is down (
get_paykit_app_registry: fetch Paykit App Registry).
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Re-reviewed the full PR at fd617bf against merge base 1337f12, because the comparison base moved from a56c75be and master changed AppViewModel.kt and AppViewModelSendFlowTest.kt. This was a full reassessment. No earlier coverage was inherited.
No new actionable code findings.
An ordinary Lightning send has no incoming payment request. Its definite failure now goes straight to the error screen, skipping failLightningPayment. Its pending result still tracks the node's hash and keeps the contact context. Only the incoming-request refresh is skipped, because that refresh waits for proof reconciliation. preparePaymentProof and associateLightningPaymentProof return early when there is no request, so an ordinary send never creates a proof for its hash. No cleanup is lost. The change also stops an ordinary retry of the same invoice from deleting a proof that belongs to a request or allowance payment. Request payments still run authorization, definite-failure proof cleanup and the pending refresh.
Allowance acceptance now carries an Activation token. The token is checked inside the SDK operation lock, just before acceptAllowance commits. This applies to manual accept and to automatic acceptOffersFromAllowers, which also checks canContinue. Activating the same normalized identity keeps the token, so a contact sync no longer invalidates a queued accept. pause, deactivate, a new identity, or turning allowances off and on again (clear() sets the token to null, then a new one is created) all reject stale queued work. This matches the earlier bot MEDIUM (r4433814124), and I confirmed in source that it is fixed. The only lifecycle callers are the sequential contact-sync collector and the settings pause collector in AppViewModel.
Existing concern, still open at this head: jvsena42's LOW. admit() runs inside the tracked block in PaykitSdkService.acceptAllowance. When admission is rejected, withPaykitBackupStateTracking drops the cached snapshot and bumps backupStateVersion, and BackupRepo turns that into a WALLET backup. Nothing was written. I confirmed the mechanism in source. It is not repeated here as a new finding.
Parity: bitkit-ios#1120 at 6140650 adds the same admission callback with an activation and canContinue check. iOS increments its activation on every activate, but startAutomaticPaykitSession skips activation when the identity is unchanged. No equivalent contact-sync reactivation path was found, so the Android-only correction is consistent. This was a contract comparison only, not a review of the iOS PR.
Validation: I did not run Gradle here. CI, Lint and E2E passed on this exact head. I read the new tests but did not run them: the held-proof ordinary failure and pending cases, same-identity queued manual and offer acceptance, and paused or re-enabled rejection. Allowances are behind the dev-mode feature flag.
Device testing: not performed in this review. J1 (ordinary-during-paykit-outage.xml) and manual test 1 need fixtures that are not provisioned: proof-fault holds and an SDK-admission hold. Device results from other reviewers belong to their own runs.
Suggested additional test cases
- Android, ordinary retry of an invoice already in a request or allowance payment: Start a Paykit request or automatic allowance payment for a hold invoice so its Lightning proof is stored. Then scan and pay the same bolt11 as an ordinary send, which the node rejects. Expected: the error screen appears, no second payment goes out, and the original proof stays until the original payment settles or fails.
Reviewer model: claude-opus-5-5
Reasoning effort: high
There was a problem hiding this comment.
Verdict:
Findings: None in this delta. The rejected-admission backup finding is fixed in 698e0d8: admission runs before mutation tracking. The Paykit SDK-service regression verifies that rejection makes no native acceptance call, preserves the cached backup and version, and lets the next operation reuse the cache.
QA for 698e0d8: Fresh device cases below used Android 15, authenticated Pubky identities and disposable regtest funds. Earlier passes retain their original revisions after comparing the reached implementations; they were not redriven.
Queued allowance acceptance survives contact sync ✅ A genuine manual acceptance committed once after contact sync; the Active row was checked separately in the UI.
With two linked identities, I tapped Accept on a real proposal and held the call before the Paykit SDK operation lock. Adding a separate authenticated contact ran normal contact synchronization while the same identity stayed enabled. Releasing the hold ran the original admission check and native acceptance. Logs contain one admission and one completed acceptance for that proposal; the list subsequently showed Active.
Disabled allowances reject the old queued acceptance ✅ Disable/re-enable rejected the old attempt without a native acceptance call.
I queued acceptance of a second genuine proposal, disabled allowances, verified that the repository entries cleared, then re-enabled the same identity before releasing the hold. The old attempt reported Unavailable. Logs contain its queued call but no admitted or completed native acceptance; the proposal remained unanswered. Setting changes used the production settings store, and identity and admission ordering were checked in logs.
allowance-disable-reenable2x.2x.mp4
Stale acceptance rejected after disable/re-enable, 2×.
Delayed contact activation stays stopped after disabling allowances ✅ The switch stayed off and repository entries stayed empty after releasing an enabled contact callback.
A real contact-key change reached the hold after payment-request activation, with allowances still enabled. I switched Allowances off in Dev Settings, then released the older callback. Native logs establish that ordering, the unchanged identity and empty allowance entries after completion; the recording shows the switch remaining off.
stale-contact-activation2x.2x.mp4
Disable during delayed contact activation, 2×.
Ordinary retry preserves a request-owned Lightning proof ✅ Duplicate-payment errors left the original proof and the single accepted HTLC intact, verified in native and LND logs.
I authorized a genuine 654-sat request using a real routed LND hold invoice. It reached Pending with its request-owned proof. Scanning that same invoice as an ordinary send reached Payment Failed with DuplicatePayment. A repeated ordinary attempt was also rejected. After both intentional retries, the proof still named the original request and hash with paymentStarted=true, and LND still held exactly one accepted 654,000-msat HTLC. This covers the additional case in the follow-up review.
request-proof-retry-rejection2x.2x.mp4
Repeated ordinary rejection, 2×. Initial authorization, Pending, proof ownership and HTLC count were checked separately in the recorded run and native/LND logs.
Receive edits during delayed publication ✅ Prior pass retained from a114e08; receive and publication implementations are unchanged.
The recorded run decoded the edited amount and note during held authenticated publication, checked close/reopen and confirmed publication of the current invoice after release.
Delayed publication cannot restore a signed-out identity ✅ Prior pass retained from a114e08; publication admission and sign-out cleanup are unchanged.
The recorded run confirmed cleared identity, sharing flags and cached invoice through release and cold restart, with the old endpoint returning 404. This delta additionally refuses stale contact activation.
Ordinary Lightning failure during proof contention ✅ Prior pass retained from 27ea698; submission, result and failure-event functions are unchanged.
The recorded run drove a real recipient rejection to Payment Failed while unrelated proof work stayed held, with one submission.
Ordinary Lightning pending result during proof contention ✅ Prior pass retained from 27ea698; pending tracking and contact-context preservation are unchanged.
The recorded run reached Pending with the original hash and amount before releasing proof maintenance, with one submission and one accepted HTLC.
Retained proof recovers after restart ✅ Prior pass retained from a114e08; request authorization, proof persistence and delivery are unchanged.
The recorded run canceled before authorization, then paid a genuine request once. Actual homeserver failures retained its completed proof across restart; release delivered it without another payment.
Incoming Lightning request remains pending during proof reconciliation ✅ Prior pass retained from a114e08; request refresh and pending navigation are unchanged.
The recorded run retained the genuine request's proof, original hash and amount through held reconciliation, with one submission.
Incoming Lightning request failure clears its proof ✅ Prior pass retained from a114e08; definite-failure proof cleanup is unchanged.
The recorded run held cleanup after a real rejection, then confirmed Payment Failed, removal of that request's proof and the same request remaining payable.
Swap recovery starts while disabled Paykit cleanup waits ⏭️ A genuine pending swap could not be established on the supported test networks.
On this head's regtest wallet, I enabled Savings Swap, disabled Paykit UI and opened Swaps: the list was empty. The network gate permits swaps only on mainnet, so startup does not admit the watcher on regtest. The repository fixture has no Boltz service; its local endpoint refused connection and the deprecated testnet hostname did not resolve. No mainnet funds were used. This prerequisite attempt does not prove recovery during delayed cleanup; no authenticated cleanup fault was staged for it.
swap-prerequisite2x.2x.mp4
Savings Swap enabled, Paykit UI disabled and empty Swaps list, 2×. Network and backend limits were checked separately.
Check: Stale activation and backup admission regressions ✅ The unmodified head built and its focused suites passed.
Ran assembleDevDebug and testDevDebugUnitTest selecting PaykitAllowanceRepoTest, PaykitSdkServiceTest and AppViewModelSendFlowTest. Structured reports contain no failures, errors or skips. Coverage includes activation queued on the dispatcher, suspended caller admission, profile deletion after the settings read, same-identity acceptance, pause/re-enable rejection, cached-backup preservation and the ordinary-result regressions. Full unit and lint gates were not rerun. The build retained the debug manifest marker warning, native stripping notices, JVM sharing warning and Gradle deprecation.
Device runs used local DEBUG scheduling and observation controls: acceptance waited before acquiring the original Paykit SDK lock, and contact sync could wait after real request activation. Fixture commands invoked production identity/contact/settings repositories; the payment case staged a real LND invoice as the requester's private endpoint. Original admission, authorization, proof storage and native payment decisions remained in place. The controls were not pushed.
Reviewed the complete contiguous fd617bf…698e0d8 delta, the existing backup finding, both allowance acceptance paths, activation/pause/deactivation, Paykit SDK locking and contact-sync/deletion callers. The prior head is an ancestor. Dispatcher and settings-read interleavings were checked in components; profile deletion was not driven on a device. No new base-versus-head Lightning latency comparison was run. The earlier receive/startup timing report retains its stated weaker startup precondition.
Twin: synonymdev/bitkit-ios#1120 fix: decouple ordinary receive and startup from Paykit publication at 66a0320 shares the outage journey and ordinary-wallet intent. Its allowance acceptance uses the same admission-before-tracking boundary; saved-contact updates do not repeat allowance activation, so this contact-sync correction is Android-specific. Source comparison only.
Reviewed by gpt-6.1-sol-high
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Follow-up. Re-reviewed the changes since fd617bf, including the code paths they affect and the earlier findings, at 698e0d8. The merge base 1337f12 is unchanged, and the previous head is an ancestor of this one. Coverage of the ordinary Lightning send result path (failure, pending, proof cleanup and skipped request refresh) and the outage journey is inherited from the previous QA review. This pass did not change that code.
No new actionable code findings.
Allowance activation. activate now reads a revision before it is dispatched to IO. It runs the caller's admission check, then compares the revision under activationLock and only then reaches executor.activate. pause and deactivate replace the revision under the same lock before calling stopAdmitting or clear. Two cases are now rejected: an activation still queued on the dispatcher, and one whose admission check is still suspended. Either can no longer reopen admission after a pause. No suspension point or reverse lock order sits inside the lock: publish only takes publishLock, and executor.activate is a plain field write. Contact sync in synchronizePaykitContacts now stops early if the sync is no longer current. It also re-reads the allowance setting before activating and passes areAllowancesEnabled() && isStillCurrent() as the admission check, so a pending profile deletion also blocks activation. A sync that stops early is re-run by the next distinct contact-sync state, as before.
Backup tracking on a rejected acceptance. acceptAllowance now passes admit as beforeOperation, which runs outside the try in withPaykitBackupStateTracking. A rejected admission therefore no longer clears cachedBackupState or calls notifyBackupStateChanged. Both earlier threads are marked resolved, and I confirmed their fixes in the source at this head:
- the same-identity activation token thread, fixed in
fd617bf; - the rejected-admission backup thread, fixed in this delta.
Validation: I read the new tests in PaykitAllowanceRepoTest, PaykitSdkServiceTest and AppViewModelSendFlowTest but did not run them. The CI run at 698e0d8, which includes testDevDebugUnitTest, passed. The local QA contract helper was not available in this workspace.
iOS parity: compared by reading the source only, not tested. bitkit-ios#1120 at 689ec4e already checks admit before the SDK accept. Its PaykitAllowanceManager.activate and the AppScene activation callers are unchanged, though. They have no equivalent of the revision check: an activate started in a Task from onChange(of: areAllowancesEnabled) can still run after a later synchronous pause. That is existing iOS behaviour, not a defect in this PR.
Device testing: not performed in this review.
Suggested additional test cases
- Android, signed in with Paykit UI and allowances on, and an incoming allowance offer waiting. Turn allowances off in Dev Settings and then back on several times in quick succession, ending with off, while contact sync runs (for example, just after adding a contact). Expected: the allowances list stays empty, the offer is not accepted, and no automatic payment starts while the setting is off.
- Android, same setup with allowances on. Start removing the profile while contact sync is activating allowances. Expected: allowances do not come back on during the removal, and after it completes no allowance entries remain.
Reviewer model: claude-opus-5-5
Reasoning effort: high
jvsena42
left a comment
There was a problem hiding this comment.
Re-approving at 698e0d8. The delta is one commit, "reject stale allowance activation after pause".
Checked and clean:
PaykitAllowanceRepo.activatecapturesactivationRevisionunderactivationLock, then re-checks it aftercanActivate()before settingactivationor callingexecutor.activate.pause()anddeactivate()replace the revision under the same lock, so an activation still queued or suspended when a pause lands never turns on admission.- An activation that finished first is stopped by
stopAdmitting(). Auto-pay admission (admits()) is re-checked under the SDK lock, so no payment starts after the pause. - No suspension happens inside either lock, and the lock order is always activationLock → publishLock.
- My LOW is fixed:
withStateRevisionTracking(beforeOperation = admit)atPaykitSdkService.kt:1328runs the admission before the tracked block, so a rejected admission leaves the backup state and version unchanged. The new test covers it.
Device gate: no device check needed for this delta. The change only narrows a timing window that the new unit tests cover, and it adds no journey and no user-visible behaviour. perf: not relevant (two non-suspending synchronized blocks and one settings read per contact sync). CI is green at 698e0d8.
Note: this PR and #1686 carry separate copies of the same PaykitAllowanceRepo / PaykitSdkService / AppViewModel change, and only #1686 has the off/on revision resync (181d664). Whichever merges second needs that reconciled.
piotr-iohk
left a comment
There was a problem hiding this comment.
Approving at 698e0d871f07d26efa0cad12d9036fa28eef2d1e, based on the current-head QA review, deterministic regression coverage and passing hosted checks. No new device run was performed for this approval.
The reviewed fixes preserve legitimate same-identity allowance acceptance, reject stale activation/acceptance after pause, disable/re-enable or profile removal, and avoid changing backup state when admission is rejected. The earlier code findings are verified fixed in the linked review.
Validation: hosted unit tests and static checks pass. E2E completed successfully with 39 distinct passing cases, but settings_10 needed the workflow's automatic second attempt and settings_08 remains disabled. This is not a clean-first-attempt or complete device-coverage claim.
Remaining device coverage is accepted for this approval and stays explicitly open:
- Fresh authenticated app execution with acceptance held immediately before SDK admission, contact sync while the same identity remains enabled, and disable/re-enable before releasing the old attempt.
- Disable allowances or start profile removal while contact-sync activation is pending, verifying that allowances stay off and no offer acceptance or automatic payment starts afterward.
- Full
ordinary-during-paykit-outage.xmlacceptance, including the controlled proof-fault/restart and genuine pending-swap scenarios. Existing runtime evidence retains its recorded revision and scope.
The controlled repository/native tests cover the latest timing changes; an ordinary smoke run would not establish those precise races. No additional device pass is inferred from CI.
Integration note: #1686 carries overlapping allowance/SDK changes. Reconcile that overlap when the second PR merges, preserving its off/on revision resynchronization.

Hosted validation update — November 1, 22:16 UTC
Exact-head hosted checks are complete: 4,814 unit tests pass, six static reports have zero findings, and 39 distinct end-to-end cases eventually pass.
settings_08remains an explicit disabled skip.settings_10failed its first execution waiting forElectrumErrorToastand passed the workflow’s automatic second attempt; all logs and checksum/CRC-verified failure media are retained. The video shows an invalid-port toast and appended port input, but the expected-tag/timing failure’s cause is unproven. No manual CI rerun or clean-first-attempt claim is made. This hosted suite does not replace the existing pending-swap/held-contact acceptance gaps below.Stale contact-sync activation follow-up — November 1
Signed
698e0d871f07d26efa0cad12d9036fa28eef2d1erejects allowance activation after allowances are disabled or profile removal starts, including contact callbacks held in a settings read and activation queued on the IO dispatcher. Pause/deactivate revoke earlier admissions; same-identity active synchronization still preserves queued legitimate acceptance. A rejected SDK admission now runs before backup mutation tracking, so it does not dirty the cached complete backup or increment its version.Controlled regressions failed before repair and pass afterward, including the real SDK-service cache/version assertion. Final required
just compile,just test,just lintand formatting passed: 4,814 unit tests, zero failures/skips, six static reports with zero findings. All 1,778 frozen inputs match; two independent concurrency reviews and the affected recheck are clean. Complete diagnostics retain four structured CloseGuard warnings and earlier failed controls/formatter corrections. All eight actual-live-base commits verify locally and on GitHub. New-head hosted CI is pending. These are component/native results; no new authenticated stale-callback/device or J1 pending-swap pass is claimed. Existing finite runtime evidence below remains revision-specific.Contact-sync acceptance correction — November 1
Signed
fd617bffda110abe7af049ccfdd92e92e062e102fixes review 4433814124: ordinary contact sync now preserves the activation while the same identity remains enabled. A queued manual acceptance or automatic offer therefore stays valid. Pause, deactivation and identity changes still invalidate old work, including disable/re-enable of the same identity.Both controlled regressions fail against the preceding production code and pass after the one-line lifecycle correction. The affected suite passes 47 tests. Final format, compile, full tests and lint pass: 4,808 tests / 260 suites, no failures/errors/skips/retries, zero fresh Detekt/Android Lint findings. All 1,778 frozen inputs match; complete diagnostics were audited and the owner verified 1,105 final artifacts. Three JVM sharing warnings and four cursor-close notices remain; the latter match the retained prior run after normalizing generated IDs, with unchanged source/test origins. Two independent affected concurrency reviews are clean.
All seven commits against live
master/1337f121cd2694718fb58e1c4f022d73104cccc1verify locally and on GitHub (true/valid). New-head hosted checks await complete inspection. Ready status remains unchanged. This is controlled component/native evidence; no fresh authenticated contact-sync app run is claimed. The iOS caller trace found no equivalent contact-sync reactivation path, so this fix is Android-specific.The preceding
1a4d5d78hosted CI, lint and E2E are fully inspected on an exact matching tree: 4,806 units / zero static findings / 39 eventual E2E passes, with one disabled Settings case. Three first-attempt failures—boost activity text, WidgetSave visibility and PriceWidget visibility—passed on internal retry; all failures/media are preserved and their causes remain unproven. Complete logs, nested diagnostics and nine digest/CRC-verified archives were inspected; 519 evidence files verified. These earlier results do not certify the new head.Evidence:
work/validation/allowance-stable-activation-20261101/andwork/validation/android1684-stable-activation-final-a/inspection/structured-report.json.Earlier queued allowance acceptance follow-up — November 1
When an allowance offer was already queued, disabling the feature could still allow acceptance after the SDK lock became available. Commit
1a4d5d7806de9ba36e0abb483ca0fd500b25a8a7captures the original activation and rechecks it inside the SDK operation lock immediately before mutation. Disabling and re-enabling the same identity cannot revive the old queued acceptance. Ordinary acceptance and existing profile-removal admission remain intact.The controlled original code failed the queued-acceptance regression; the fixed repository suite passes all 45 tests. Final format, compile, full unit tests and lint passed: 4,806 tests across 260 suites, zero failures/errors/skips/retries, and zero fresh Detekt/Android Lint findings. All 1,778 frozen inputs match. Complete logs and structured/nested reports were audited, and the owner verified all 1,116 sealed final evidence files. Three JVM sharing notices and four cursor-close observations remain disclosed; their implicated files are byte-identical to actual master, without a claim of equal runtime incidence. Two independent affected concurrency reviews are clean after correcting a background-test fixture to change activity only after its simulated commit.
The live base remains
master/1337f121cd2694718fb58e1c4f022d73104cccc1; all six commits in the complete range verify locally and on GitHub (verified=true,reason=valid). New-head hosted checks are pending inspection; the older hosted results below belong only to their stated revisions. These automated component/native gates do not establish a new authenticated outage journey, and J1's existing fixture gaps remain explicit. Ready status is retained.Evidence:
work/validation/android1684-offer-admission-final-a/REPORT.mdandwork/validation/allowance-offer-admission-20261101/. This follow-up was scoped and implemented withgpt-6-astra/xhigh, and reviewed independently withgpt-6.1-sol/highandgpt-6-astra/xhigh.Validated master composition — October 31
Signed additive merge
ac38baf3a65887a4f44cb0b9b4c960de0bdeadd1incorporates actual master1337f121cd2694718fb58e1c4f022d73104cccc1; final tree6e565183469f73acb08e85793c1d1644e84b2508. This PR’s original four-file ordinary Lightning delta is unchanged. The affected review traced the new allowance/SDK admission and contact-routing paths with ordinary and genuine-request callers; no new composition finding was found.Required format, compile, full unit tests and lint passed. There are 4,805 fresh unit passes across 260 suites, zero failures/errors/skips/retries, including the two delayed-proof ordinary-send controls. Complete fresh detekt and Android Lint reports have zero findings. All 1,778 frozen inputs match. Actual Gradle daemon execution used JetBrains JDK 21/Gradle 9.5.0; the runner’s JDK 17 setting is only its launcher. All five live-base commits verify locally and on GitHub (
verified=true,reason=valid).Complete logs, every JUnit case/nested output, SARIF/XML/HTML and report freshness were inspected; the owner verified 1,634 sealed evidence files. Three JVM instrumentation warnings and four cursor-close warnings remain disclosed; the cursor source/test files are byte-identical to actual master. An older copied Gradle deprecation report is explicitly stale, not a fresh result. No warning-free runtime or base-runtime rerun is claimed.
Current-head hosted CI passed 4,805 tests with zero skips, and lint has zero findings. E2E reached 39 unique eventual passes and one explicitly disabled settings case. Its first LNURL/transfer attempt had two failures (
150andTransferexpected inActivityShort-0); automatic attempt 2 passed. Both failed screenshots show the expected content in a later row. The failure logs/media and exit 1 are preserved; the cause is unproven and this is not an all-attempts-clean result. Complete logs, 264 HTML reports, structured lint and all diagnostic archives were inspected and integrity-checked. The synthetic merge tree exactly equals the published head tree. Six migration jobs were skipped. Optional APK transfers are outside this diagnostic seal and add no acceptance gate. Existing authenticated/external journey evidence below retains its original revision and limits; these local gates do not supply a new full-app outage or pending-swap acceptance pass. Ready status remainsdraft=false. Evidence:work/validation/android1684-master1337-final-a/REPORT.md,android1684-master1337-root-integrity.jsonandandroid1684-master-20261031/publication/.October 30 external acceptance update
Review 5717831999 reports exact-head authenticated receive/publication, sign-out/restart, retained-proof recovery, genuine request pending and definite-failure cases at
a114e084. Ordinary failure/pending evidence is explicitly retained from27ea698after the reviewer's relevant-path comparison, not newly executed. The complete report and disclosed DEBUG controls were read; underlying proxy/native logs, snapshots, QR decodes and media have not been independently inspected by the owner. These are attributed external results, not new author-verified acceptance or human approval. No new source finding was reported. J1 remains partial because no genuine pending swap was available through the mainnet-only supported gate; no live funds or new fixture authority are inferred.Companion: synonymdev/bitkit-ios#1120
Description
An ordinary Lightning payment could already be failed or pending in the node while its result screen waited for unrelated Paykit proof maintenance. Return the node-derived result without that unrelated work when there is no incoming payment request. Pending payments retain their original hash and contact context; actual Paykit requests keep their authorization, claim and proof handling.
Out of Scope
No payment is retried by this change. Full-wallet backup separation, pending-restore admission and the separate iOS receive/startup waits are follow-up work. No historical contact incident attribution.
Design
N/A — no design available. Existing result screens and copy.
Preview
Existing error and pending screens appear when the node result is known.
QA Notes
Setup
Use the committed Debug dependencies (Core 0.8.0-rc11, Paykit 0.1.1-rc2, VSS 0.5.26-rc1). The deterministic repository tests inject an unrelated held proof cleanup/reconciliation and controlled ordinary node failure/pending results. No funds or services are needed for those tests. J1 requires disposable test-network wallets with synthetic funds, a usable Lightning channel, a controlled failing/hold invoice and a service fixture that delays only an unrelated retained proof. This full service fixture is not provisioned; never use live funds.
Journeys
ordinary-during-paykit-outage.xml— ordinary receive/startup/payment-result behavior while unrelated Paykit services wait. Native component tests cover the two changed Android payment-result steps; the complete journey remains unrun.Manual Tests
Automated Checks
AppViewModelSendFlowTest; unchanged production fails both. Candidate focused suite: 504 passed.just compile,just test,just lint: passed. Complete unit suite: 4,606 tests across 254 reports, zero failures/errors/skips.Evidence
Controlled failure and pending results appear before the unrelated proof gate is released, with one invoice submission. Existing request-specific tests continue to pass. Two independent reviews inspected request ownership, proof cleanup, pending tracking, call paths and parity. iOS ordinary pending presentation already returns independently; iOS local proof mutation has a different locking boundary and no matching remote wait was confirmed.
Gaps
No full-app proof-fault/restart journey was run. These component tests mock the node/proof repository; they are not deployed Lightning or Pubky verification. Compatible released Paykit/Pubky authentication/lease fixes remain separate dependencies of the broader recovery work.
Models used
gpt-6-astra(reasoning:xhigh)gpt-6-astra(reasoning:xhigh)gpt-6-astra(reasoning:xhigh)October 30 live-master composition —
a114e084Signed additive merge
a114e08413e7f8db785efcc6b9f35b353220cb9cincorporates livemaster6d99ad38a44e7f294583fa350212ac4f5d56c4e6. The ordinary Lightning result handling remains independent of unrelated Paykit work, while upstream restore/spending admission, payment-request authorization and separate public-address reservations are preserved. The owned delta remains four files; no unrelated incoming feature is claimed as this PR's work.The combined tree
255aa771fa1c02dbb4dd97851096b6d7f1583945passed just compile, all 4,798 unit tests in 260 suites, just lint and unchanged just format. There were zero test failures/errors/skips/retries and zero fresh Detekt/Android Lint findings. Complete logs, XML, SARIF, HTML and Gradle problem reports were inspected, including stale copied reports and failure-policy configuration. All 1,775 current tracked inputs match the frozen tree; the runner's before/after HEAD, status and full-diff identities match. One independent affected-composition review is clean, preserving earlier feature review coverage.Four Robolectric cursor-close observations, three JVM notices, a debug manifest marker warning and the Detekt/Gradle deprecation remain disclosed. Their implicated source/configuration is byte-identical to both actual parents; this does not prove equal historical runtime incidence. Validation used the branch's released Paykit RC2/Core RC11 dependencies, with Gradle's configured JetBrains 21 daemon. No APK install/device or authenticated outage/pending-swap journey is inferred from JVM tests. J1's real pending-swap case remains unrun because the supported path requires its unavailable mainnet prerequisite; no live funds were used. Prior external evidence remains limited to its recorded revisions.
All four commits in the current live-base range verify locally and on GitHub (
true/valid). Current-head CI, Lint and E2E pass on synthetic merge7c8677fe, whose tree exactly equals this head: 4,798 unit passes, zero fresh lint findings, and 39 distinct E2E passes across seven shards, all on first attempt. Six migration jobs skipped; they are not passes.Complete 18,252-line job output, all 263 unit HTML reports, six lint reports, 32 runtime files (288,846 lines), annotations and all artifact members were inspected. The owner verified the 1,239-row evidence manifest; all 14 ZIPs and both APKs pass integrity checks. The external E2E suite is
611509ee2d6dc44094276b567ac51949d63760f9. Runtime networking/backup/session/emulator warnings and 76 external npm advisories remain retained; a green general E2E suite is not evidence for the unrun controlled proof-fault, restart or pending-swap J1. No unit XML, runner Gradle problem HTML, screenshots or video were uploaded, so those unavailable artifacts are not claimed inspected.