Skip to content

fix: prevent false on-chain send success - #1384

Open
ovitrif wants to merge 119 commits into
masterfrom
codex/1211-explicit-broadcast-outcome
Open

ovitrif wants to merge 119 commits into
masterfrom
codex/1211-explicit-broadcast-outcome

Conversation

@ovitrif

@ovitrif ovitrif commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #1211
Twin: synonymdev/bitkit-ios#844
Refs:

Description

  • Recognizes the actual Electrum refusal envelopes while preserving the original signed hardware payment and wallet guard; refusal navigation remains dismissible after expiry or reopen.
  • Retries a retained hardware request with its original signed bytes when displayed fee estimates change; a different request reports the existing-payment guard.
  • Retains and broadcasts confirmed initial preset fees across the positive UInt32 range, including rates above 999 sat/vB, for software sends, send-all and Fast transfer funding; original-payment recovery retains its 999 sat/vB limit. Send-all Success shows the actual signed recipient amount.
  • Reports failures proven to precede the durable dispatch marker as unsent, without displaying a Pending transaction that never left the device.
  • Retains exact winning-fee provenance across local restart so a later activity retry does not depend on fetching the same backend details again.
  • Bounds initial Max funding by the original approved total, including the actual signed mining fee from native rc71; missing, excessive or overflowing fees fail before candidate retention or broadcast.
  • Holds unconfirmed transaction observations while competing recovery candidates exist, preventing a stale original event from overriding the accepted replacement.
  • Restarts accepted-funding recovery on reconnect even when the preceding order request is still suspended, using the original funding transaction without another send.
  • Validates every restored proof before installing a payment guard or replacing payment state; malformed unrelated proofs cannot leave a partially restored blocking attempt.
  • Rejects restored transfer totals outside the signed database range before installing their payment guard.
  • Releases abandoned ordinary-send and transfer preparation after restart only when its durable dispatch marker proves it was never submitted. Live preparations, restored guards and any submitted or uncertain payment remain protected.

Hardware Shop payments persist the signed receipt and original private boundary before endpoint consumption. Interrupted software preparations proven never dispatched release the saved boundary before removing their proof.

  • Preserves the prior consumed private payment-list boundary with the original receipt and backup, so cancelling an unsent version cannot reopen older payment details.
  • Preserves the original consumed private payment-list version in proof backups and releases it durably before removing a definitely unsent hardware Shop proof; failed cleanup remains retryable after restart.
  • Restores whether the original hardware Shop transaction ever reached dispatch; interrupted authorization or expiry before dispatch clears only its exact unsent proof, while attempted payments stay guarded.
  • Keeps a failed hardware Shop broadcast guarded through Back/cancel so an explicit retry reuses the original signed transaction, payer and request.
  • Defers wallet backups while any local on-chain attempt lacks a signed receipt, preventing restoration of an unrecoverable unsigned guard.
  • Keeps the exact signed hardware payment guarded when candidate persistence fails, allowing a save retry without another signature.
  • Keeps an accepted retry Pending when its acceptance cannot be saved, unless the exact operation already has durable positive evidence.
  • Revalidates the original funding address, client balance and service fee before confirmation resumes an accepted transfer.
  • Preserves same-operation progress when retrying an interrupted backup restore without replacing its original payment context.
  • Keeps manual contact detachment protected when a contact assignment fails to persist.
  • Integrates shared-state Paykit 0.1.0-rc72 with LDK 0.7.0-rc.71, retaining original payment guards, captured proof app IDs and cross-platform backup state.
  • Checks payment deadlines at native preparation and dispatch; expiry after a prior broadcast retains the original guarded payment.
  • Original-payment retry preserves the original request deadline through native preparation and dispatch, and rejects a changed deadline after authentication. Expiry before dispatch retains the original guarded payment; the inclusive deadline remains valid.
  • Saves the Shop payment guard before consuming request details. Preparation proven never dispatched can be retried after restart only after durable cleanup of its original proof; live preparations, restored guards and submitted or uncertain candidates stay protected.
  • Fixes unsent private Shop request retries by releasing consumed details on proven pre-dispatch failures; subscription proof errors retain their retry screen.

Required for Bitkit 2.6.0 Shop support. The identified recovery fixes are published and this PR is open for human review. LDK rc71 is merged and published; the original signed transaction ID, actual inputs and recipient amount are durable before submission. Human approval, current CI and the remaining wallet/merchant acceptance checks are still required before merging.

  • Uses explicit LDK broadcast outcomes for normal sends, send-all, transfers and Shop payments so a transaction ID alone never produces success or payment proof.
  • Persists one active payment guard and its signed receipts before submission. Pending offers an explicit authenticated retry of the original recipient amount using only the original inputs; all candidate IDs remain guarded across restart and wallet backup, and an accepted or observed original winner cannot be overwritten by a later failure.
  • Preserves accepted transaction IDs after local storage/activity/proof failures and resumes local follow-up without creating another payment. Reconciliation requires independent observation of the exact transaction ID.
  • Preserves original transfer amount and balance context, keeps a new unsent payment separate from an earlier accepted attempt, and saves verified acceptance with Shop proofs so delivery can resume after the guard is replaced.
  • Requires fresh observation of the exact outgoing hardware transaction in the original wallet before Shop proof, Sent activity or Success. Missing observation keeps the original request pending; its saved identity, wallet and transaction cannot be replaced by current screen context.
  • Retains a started hardware Shop guard after a candidate-save failure or a Core exception with no returned transaction ID, so dismissing and reopening cannot authorize a replacement payment.
  • Resumes accepted or exactly observed funding at startup and node events from the saved original order and balance context. Transfer and paid-order persistence are idempotent; guard completion requires durable local activity and never broadcasts again.
  • Preserves paid-success navigation after the original transfer and paid order are durably saved, even if subsequent local activity completion fails. Existing resumption repairs that follow-up without funding a second order; failed funding persistence still retains the original attempt.
  • Routes proven pre-admission failures to existing error handling while retaining unresolved protection after dispatch starts.
  • Shows candidate transaction IDs and refusal reasons in Pending, with Details for an exact original-wallet local activity. That local record never proves acceptance or releases the guard.
  • Uses LDK 0.7.0-rc.71 prepared-send bindings with the actual signed mining fee. Current Android compilation resolves the hosted rc71 Maven artifact with local repositories excluded; the built APK native libraries match the published artifact. Earlier rc70 remote resolution and native-byte checks are historical coverage.
  • Includes the active payment guard in the shared wallet backup, validates its original wallet/network and proof association before restore, and resets local acknowledgement so restoration cannot silently unlock another send. Restored contact attribution is saved before acknowledgement, without overwriting a later contact edit. Missing or invalid follow-up remains guarded.
  • Updates Pending from the exact durable original payment after positive evidence and local completion. Transfer recovery opens the original funded order only after its saved follow-up completes; a late uncertain retry result cannot replace the winner.
  • Keeps recovery errors scrollable while Retry, Details and Close remain visible and separated.

Why

Required for Shop support in Bitkit 2.6.0: buyers must be able to tell whether an on-chain payment was accepted and recover an uncertain checkout without paying twice.

  • Prevent false success: Bitkit must not show “sent” or deliver payment proof when the backend rejected the transaction or acceptance is still unknown; the Shop order may remain unpaid.
  • Prevent accidental double payment: reopening or retrying an uncertain checkout must retain the original payment instead of starting a separate payment.
  • Preserve the merchant amount on retry: use only the original inputs and recipient amount; a Max payment without enough fee headroom must fail safely rather than reduce what the merchant receives.
  • Resolve stale Pending state: once the exact original payment succeeds and its local follow-up is saved, the app must reflect that result.
  • Preserve protection after wallet restore: restoring a backup must retain the unresolved payment guard so it cannot silently authorize another send.

These are release acceptance requirements. The app PRs include the selected Paykit updates for 2.6.0. Final validation must pay a Shop order with these app builds and verify that the merchant receives the payment proof and the order becomes paid on the existing Shop server.

  • Defers the complete wallet backup while a Shop preparation has no signed receipt, keeping private payment-list consumption and payment guards together. Upload completion acknowledges its captured snapshot; newer changes remain queued.
  • Clears the exact hardware candidate after a definite first queued dispatch expiry before releasing its preparation; prior uncertain submissions and accepted proofs remain guarded.
  • Rejects mixed private-consumption/SDK wallet snapshots and resumes positively evidenced Shop proof follow-up after complete restore, without waiting for another node event.
  • Consumes a matching completed hardware result after asynchronous reconciliation so future sends do not replay the previous payment.

Out of Scope

  • Legacy private Paykit cache/backup receiver-path migration: bug: opted-in private payment state is lost on upgrade #1431; raised in fix: prevent false on-chain send success #1384 (comment).
  • Transaction recovery: historical journals, raw transaction storage, automatic retries, replacement input selection, abandonment and general RBF recovery.
  • Chain handling: reorg and event-delivery redesign.
  • Unresolved sends: no timeout/reset escape. Missing original provenance or insufficient fee headroom keeps the payment guarded. A higher-fee retry cannot reduce the merchant amount or add other inputs; backend acceptance does not guarantee confirmation.
  • Legacy software proofs: queued-era transaction IDs without positive acceptance and original wallet provenance are not promoted or delivered. Opted-in released users can have these records; migration is excluded from this PR, with the documented limitation accepted in review.

Design

N/A — no design available for the new unresolved-send state.

Preview

Prepared-send Pending preview before the backup follow-up: actual published rc69 native preparation in a funded regtest wallet, with Unknown injected before broadcasting. This shows the retained 1,000-sat original receipt and explicit retry action; it does not reproduce backend refusal or response loss.

Original payment retained (rc69 fixture)

Historical rc68 candidate before the current feedback batch: fixed 1,000-sat and Max 98,749-sat regtest sends using the actual published and resolved LDK package. Both exact transaction IDs matched native Accepted logs and independent backend observation.

Earlier Pending preview at 0ef4613: synthetic component UI only, with dummy transaction IDs/refusal text and mocked fiat value. It shows refusal copy, a selectable candidate ID and local Details without success; it does not reproduce backend refusal or persistent reopening.

Fixed: Sent (historical) Fixed: Details (historical) Max: Sent (historical) Max: Details (historical) Pending (component) Details (component)

Hardware submission boundary

  • Exact observed completion cancels and invalidates its original signing attempt, preventing late responses from reopening Pending.
  • Signed receipt retention leaves the original payment undispatched until the native broadcast queue reaches submission.
  • The submission boundary verifies the original payer and exact retained receipt, then rechecks the request deadline before broadcasting.
  • Focused regressions cover queued expiry, changed receipts and uncertain responses; funded physical hardware acceptance remains unrun.

QA Notes

Merchant acceptance (10 October)

  • Android aa33095 passed a funded Locks merchant purchase using Paykit rc72, native rc71, a Ring seller, Paykit Server rc11 and Locks rc10, with staging identities and Bitcoin regtest. The buyer paid the original 1,000-sat request once; Paykit reported amount_matched: true and one confirmation. Locks completed verification, issued an access credential, and returned the exact protected content through an authenticated read. This Android result is separate from the iOS result below and does not establish Shop order completion.
  • The same request, seller, buyer and amount were preserved. Transaction f33ff0363066237f61ce8e6e1db6bd845f226cafc9d36774058284712cf9a013 has one original funding input, a 1,000-sat seller output and a 141-sat fee. Confirmed transaction bytes and native signed-input evidence were retained; this does not claim a pre-broadcast snapshot or an interrupted-send retry test.
  • The isolated test required correcting its credential lifetime limit from 900 to 3,600 seconds to match the existing Lock. Credential issuance was then retried on the same completed bundle, without another payment. A replaced tunnel preserved the same backend identity and invoice.
  • Remote Shop staging remains incomplete. Ring signup, Shop marketplace authorization, Locks connection and same-identity Paykit setup succeeded after removing only the unsupported initial creator parameter. Shop then called the fork-only /v0/accounts/pubky<seller> route and received HTTP 404; listing creation and Accept Bitcoin remained gated.
  • A separate remote staging Lock was created, but invoice admission returned HTTP 502 paykit_invoice_creation_failed, including on one authorized new-bundle retry. The buyer's App Registry and identity-signed Noise authorization were present and verified. The exact remote deployment/configuration cause remains unknown; the successful isolated purchase does not identify it or prove the same Shop order becomes paid.
  • Pubky Ring and Bitkit are equally valid sign-in options. The first Bitkit-seller attempt could not complete Shop's marketplace-session step; the Ring-seller flow above is a supported acceptance path.
  • Hardware coverage: the existing Trezor emulator validation is accepted hardware-signing coverage; a separate physical-device run is not an acceptance requirement. Earlier physical-device caveats below describe historical runs.
  • iOS bb02e835 passed a funded Locks merchant purchase on the simulator with Paykit rc72 and native rc71, using the same Paykit Server rc11 / Locks rc10 mixed local-staging setup. A fresh buyer paid the original 1,000-sat request once, with a 143-sat fee. Invoice ec0d05e9-9238-4c7d-a156-69a9be3c7908 became confirmed with amount_matched: true; request a7e5d0aa-7099-4af5-939a-5838f78e1ef3 reached proof_submitted. Transaction: 283f408b3435eaf04258b5f929eed73a846f78ad21ff1990c2dcd203397b9a97.
  • Locks completed bundle 0KGP41FQ0W0F27EWPA2YBGJD6R, issued an access credential and served the exact protected content through an independently repeated authenticated read (73 bytes; SHA-256 66126e151321afc148b133e059dd634f6f7beb1bf1b8b64c36097d021da21724). This proves iOS Locks merchant acceptance; it does not prove a Shop order-paid transition or wallet restore.
  • This iOS run used the disposable input and diagnostic hooks below, without changing payment decisions. A prior bundle failed before invoice admission because setup regeneration dropped the Locks signer from server trust. After additive trust repair, the successful bundle above was created separately and retained throughout payment. A temporary SharedStateBusy session error cleared after stopping the payer app for 82 seconds and relaunching normally; no remote lock was edited. The simulator and app/log helpers were stopped after evidence capture; the backend was subsequently stopped after both platforms completed acceptance.
  • Android ordinary wallet restore passed on aa33095: after one 1,000-sat payment, a disposable request-scoped hook held external proof enqueue while ordinary wallet/metadata backups completed. A fresh emulator restored the same mnemonic through normal onboarding, downloaded the exact WALLET snapshot (c51f229cac163ea802a5495dd3c2bbdcbdee113382b7948dc254f65897722d1c), recovered the original payer and request e470dd63-92e8-4b13-8584-fcab299e5f27, and queued its original proof at 03:28:08 UTC. The server independently changed that request from accepted to proof_submitted; transaction 34d03ad637596414c1f8bdb0f3c9a54c6d4ff820986a31f2c80ae8934852377e was paid once. No application data or hold marker was copied. This covers ordinary pending-proof restore, not an active-guard crash window or a Shop order-paid transition. The Android PR includes the temporary hook and repeatable journey.
  • iOS ordinary wallet restore passed on bb02e835: a fresh simulator with separate Keychain and app data restored the same mnemonic and downloaded the exact source WALLET snapshot (380b66e9b7d9a85c02432ad6a71b7cf175800c5c89aa839c94cae1c0990e29f9). The retained proof kept request 7c73247a-47aa-47c2-8486-3c5fce98ddd6, the original payer, 1,000-sat amount and transaction a6e55ae0b09afa69f216d3753d3fd040af5302d5c56ccd23ab7d4ee7a28bfcab. Normal reconciliation submitted the original proof; an independent server read verified proof_submitted and amount_matched: true. No new transaction was created or broadcast by the restored wallet. The disposable hook only deferred source proof delivery and logged ordinary backup/restore data; its marker was absent on the fresh simulator. Both owned simulators and helpers were stopped. Temporary XML, exact diff and reproduction steps are in the iOS PR.
  • Android full local Shop order-paid acceptance passed on aa33095 (10 October): the normal devDebug build paid the original 1,000-sat request once. The same order 89ca8c49-65d5-423f-b2f8-3fd877601dd1 became paid; payment 4320c5ed-b2a1-4c05-a22b-fa3cf86042be became confirmed through the service’s independent Locks worker. Paykit invoice 26c3a793-e0f3-49d9-a49b-3a40c869293b reported amount_matched: true and request 8b2aaf89-6c93-4b08-a1e1-37b78440af45 reached proof_submitted. Locks completed the original bundle JPEHRR8WAH01B7Y6VKBZ3PTW3W. Transaction ad00c6f43d748ff0cff0161ec2d1eb1cb00b387aa735def1a502e3e0e972ed5e used one original input, paid 1,000 sats with a 141-sat fee, and was not replaced.
  • Tested setup: local Shop feat: run the shop against upstream paykit and locks behind a switch pubky/pubky-marketplace#154 at fbe3babba0c3c05990571221b5d4dc0c31788e68, service fix: accept upstream locks lock paths pubky/pubky-marketplace-service#98 at 005b0707e6047b388ce032f4b51a2e0ed9e3a752, Paykit Server rc11 and Locks rc10, staging identities and Bitcoin regtest. Shop CI was pending during the run and subsequently verified green. This establishes local mixed-stack acceptance, not remote staging deployment acceptance.
  • Shop revision note: tested at fbe3babb; feat: run the shop against upstream paykit and locks behind a switch pubky/pubky-marketplace#154 later moved to 84934516 for review changes to the seller-readiness source and buyer key-record gate; the Locks prepare/register payment path is unchanged. The tested setup remains pinned to fbe3babb.
  • Seller provisioning used a disposable, exact-seller/listing-guarded development page calling the normal schema validator and commitUpsertListing; it was removed before checkout. No readiness or payment-success override remained. The studio cannot author Locks listings. The service currently represents this Locks-backed listing as shipping and asks for an address; ordinary digital-delivery and physical-goods payment routes are not covered by this result.
  • Repeat the buyer path on the pinned setup: sign in with a funded regtest Bitkit buyer, check out a registered Locks listing, then press Request payment in your wallet on the order page. The production flow prepares the payment, submits a bundle, and registers it. Confirm the original request’s seller and amount, pay once, then verify Paykit’s amount match, Locks completion and that the same service order becomes paid. If registration needs retry, retain the same browser profile and stored bundle correlation; never create a replacement invoice to resolve an uncertain payment. The reproduction recipe is published in bitkit-docker#24, stacked on Basic Send Onchain Flows #23: shop-order instructions at 5972825. The passing app runs used a separately assembled local mixed stack with the same component revisions. The packaged setup has a recorded startup proof: the preserved build/start log is from 9b4e208 with the pinned component revisions; the follow-up proof at 5972825 records strict readiness for all four services, public tunnel responses, and the driver, Locks and service signing keys surviving a driver restart. The 5972825 checks are recorded in the proof receipt rather than a separate raw log. Its stack was stopped with data retained. Seller setup, ./shop-order lock, provisioning publication and buyer payment were not driven through this packaged profile; the completed Android and iOS order-paid acceptance used the separately assembled runtime on the same component revisions. Tunnel checks came from the public internet, not a device. Commit 5972825 also isolates the Compose project and restores owned fetched sources to their exact pins.
  • iOS full local Shop order-paid acceptance passed on bb02e835 (10 October): using only the disclosed DEBUG scanner-input hook, the original order 0bd26696-7c4a-4275-bf6a-56208d6b018d became paid after one 1,000-sat payment. Payment c3d2633a-9cd8-4e4c-8d87-58e770d3ceff, bundle CF0YST4VWN7RS9BX098GGEBKNW, invoice cff3c171-e138-4e01-80cb-7cde2c54e4bb and request 7732ba03-e404-4d40-9b6d-5e23c9b6ceca retain the same buyer, seller and amount. Independent persisted-database reads confirmed the same order paid, Paykit confirmed with amount_matched: true, and the original request proof_submitted. Transaction 14a1d4700f2d9dd4eb2b24f48061bd6dcf498afe369f520e4adee07bc967fd52 paid a 143-sat fee; its exact 222 signed bytes have SHA-256 9ee854da632e366be2cd5ea7fdfa7ca6a99f9eedb02e4b92246c059e803c6e88. Locks completed and authenticated access returned the expected 66-byte content. No payment decision was modified.
  • Reproduction setup correction: after publishing a fresh Locks listing, verify the service’s seller-authored Lock snapshot, revision and available stock before checkout. Initial listing.register alone omitted that snapshot; a normal seller-signed revision-2 publication through commitUpsertListing emitted listing.sync and populated it. The first iOS setup order was refused before any bundle/invoice/payment and cancelled through the normal command; the successful order above used the corrected snapshot. Disposable provisioning code was removed before checkout. Both platforms’ test runtimes are stopped, with wallet state and database volumes preserved.
  • Validation status: both platforms’ full local Shop order-paid, separate Locks merchant, and ordinary pending-proof wallet restore cases are verified. The reviewer recipe is published in feat: run a paid shop order on our own paykit server and locks with the staging homeserver bitkit-docker#24; its packaged setup startup is proven, with the unrun packaged seller/provisioning/payment steps distinguished above. Remote staging deployment acceptance is not claimed; current-head human approval is still required.

Local-only fallback documentation (aa33095)

  • Adds matching fallback journey and setup recipe; remote Shop staging remains the default. The fallback requires a recorded staging pairing failure and successful staging signup prerequisite.
  • Documents server rc11 with the rc72 Paykit revision, staging identities, the matching regtest Electrum endpoint and an HTTPS tunnel. Preserves the original request/payment on uncertainty and distinguishes server observation from Shop order completion.
  • Documentation only: XML and source/configuration checks completed; no device, server or funded purchase was run for this change. Prior runtime evidence remains attached to its recorded commits. Current-head checks and dev approval remain required.

Current master integration (feb7b16)

  • Merged master b2d116d, inheriting its reviewed Paykit 0.1.0-rc72 update while keeping LDK 0.7.0-rc.71. The contact-sync test conflict preserves incoming sign-in/membership checks and retained-payment regressions.
  • Local verification: affected private-contact/link/reservation, original-payment proof/restore, hardware retry, migration and wallet-start regressions on the merged source, with the published native rc71 artifact and Paykit rc72. Static checks introduced no conflict-resolution finding; the changed wallet-test size warning is inherited from master.
  • Current-head hosted CI and human review remain required. Earlier approvals apply to their reviewed heads. No new full-suite or device run; funded Shop checkout, fresh-device wallet/VSS restore and physical hardware acceptance remain unrun.

Current verification and acceptance

  • Local verification: affected refusal-envelope, proof retention/reopen, expiry, pre-dispatch failure, confirmed preset fee and actual send-all success regressions; Kotlin compilation and static checks. No new full-suite or device acceptance run.
  • Hardware refusal: focused regressions cover explicit known backend refusals and invalid transaction errors permitting dismissal while retaining the original signed payment and durable wallet guard. Unknown and connectivity errors stay guarded; retry signs once and reuses the same bytes. Physical hardware acceptance remains unrun.
  • Earlier base integration: master 0b6c30c preserved confirmation-reset and recovery PIN navigation; its historical send-flow/build evidence remains recorded below.
  • Local verification: debug build and affected store/coordinator, transfer recovery and send-flow regressions cover unsent retry cleanup, authorization failure, unavailable USD rates and node restart during a suspended order fetch. Prior full-suite coverage is retained for unchanged paths.
  • Earlier SDK restore fixture: Paykit rc71 with a fresh grant and SDK/storage instance retrieved the exact shared request without overwriting remote state and delivered a synthetic proof to the original counterparty. This does not exercise wallet/VSS restore or a funded Shop checkout.
  • Unrun acceptance:
    • Fresh-device wallet/VSS restore with the selected app and Paykit versions.
    • Funded Shop order: merchant receives the original payment proof and the order becomes paid.
    • Physical hardware wallet payment and recovery.
  • Preview: the captures below demonstrate earlier builds. The new exact-fee approval dialog has not been recorded; those captures do not validate the current fee interaction.

Earlier master integration

  • Merged master 983e322, including its Paykit rc71 update; preserved original-wallet hardware completion checks and the new submission-state regression.
  • Ran the app build, unit checks and detekt. The merged test fixture initially omitted the submission-state setter mock; its affected send-flow tests were rerun after correction.
  • Funded merchant acceptance remains unrun for this change.

Earlier master integration

  • updated shared-state Paykit to 0.1.0-rc69 from the merged Paykit PR; LDK remains 0.7.0-rc.70.
  • ran local verification of the merged payment recovery, hardware coordinator, backup and Paykit scheduling changes.
  • Fresh-wallet Shop request/proof VSS restore and final funded merchant order/proof checkout on these heads.
  • Physical hardware payment path and current Android expiry device journey.

Private hardware cleanup (b71aff7)

  • Encoded proof backup preserves the original consumed private payment-list version alongside the signed receipt and dispatch marker.
  • Definite pre-dispatch cleanup releases that exact version before deleting the proof. Failed private-state storage or proof removal retains the original proof for safe, idempotent retry after reopening.
  • Local verification: affected proof, hardware-send, app send-flow, wallet-backup and wire-format checks and Android build completed. The regression failed before correction; physical hardware, fresh-wallet Shop VSS and funded merchant checkout remain unrun.

Hardware dispatch-state recovery (91351de)

  • The original signed receipt starts with a durable false dispatch marker. The marker is saved before native submission and restored together with its original payer/request/wallet context; missing dispatch evidence remains guarded.
  • Definite authorization denial or expiry before the first dispatch removes only the matching unsent signed proof. Attempted and uncertain payments retain their original receipt and require explicit authorized recovery.
  • Local verification: affected hardware/proof checks and application build completed. Encoded backup/reopen covers both unattempted cleanup and attempted preservation; restored authorization cannot sign another transaction. Physical hardware, fresh-wallet Shop VSS and funded merchant checkout remain unrun.

Hardware authorization recovery (3925ef2)

  • The original signed hardware receipt, derived transaction ID and fee metadata are persisted in the same write that starts the Shop proof, before authorization can suspend.
  • Preparation receives the exact signed object; a Shop request without it cannot start. Restart or backup during authorization retains the original receipt for authenticated retry without another signature.
  • Updated PaykitPaymentProofRepoTest, HwSendViewModelTest and AppViewModelSendFlowTest; the authorization-boundary backup regression reproduced the missing receipt before correction. Local verification: affected checks and Android build completed; physical hardware and fresh-wallet Shop VSS acceptance remain unrun.

Current review corrections at 59df4f8 / 5843da2: direct-send fallback validates original attempt and wallet; hardware Shop Pending retains its result until proof completion. Missing proof app IDs remain readable and retained, and cannot be submitted with invented provenance. Focused repository, hardware-send and send-flow checks and the app build completed. Physical hardware proof-save failure, remote restore and final Shop order/proof checkout remain unvalidated.

Journeys

  • temporary wallet-restore-unfinished-payment.xml — retained original proof restored from normal VSS backup without another payment (passed on disposable debug build).
Temporary restore journey and debug hook

Not pushed. Base aa330959dd34ab51fb46025a021b4155980676b5. One request-scoped gate defers only SDK proof enqueue on the source device. Production local guard completion remains unchanged; this does not claim active-guard crash-window coverage.

In a detached worktree at the base above, save the diff below and run git apply --check restore.patch, then git apply restore.patch. Build ./gradlew assembleDevDebug and install that APK. After testing, run git apply -R restore.patch and verify a clean worktree; do not commit the hook.

Manually arm files/restore-proof-hold through debug run-as with exactly the new original request UUID. After one actual payment, require ordinary WALLET and METADATA VSS upload/readback hashes to match and the retained proof to be present. Force-stop the source app and shut down only its emulator; preserve its state. Create a fresh emulator, install the same APK without the marker, and restore the original mnemonic through the normal UI. Never copy application data. Compare the downloaded snapshot hash, original proof fields and actual merchant acceptance. No second payment or replacement request is allowed.

<journey name="wallet restore unfinished payment">
  <description>
    Restore one retained original Paykit proof through normal VSS backup and a fresh wallet installation.
    Use the exact-head disposable debug patch. Hold only external proof enqueue for one recorded on-chain
    request; allow production guard completion. Requires a ready merchant backend, staging regtest funds,
    and a new request distinct from any previously paid request. This does not prove an active-guard crash window.
  </description>
  <actions>
    <action>Record the original request id, payer, seller, endpoint, app, amount, deadline and merchant reference</action>
    <action>Arm the source device's private restore-proof-hold file with exactly the original request id</action>
    <action>Open the original payment request and verify its seller and amount on the payment review screen</action>
    <action>Swipe to send once (testTag "GRAB")</action>
    <action>Verify Bitcoin Sent (testTag "SendSuccess") and retain accepted transaction id, inputs and serialized transaction hash</action>
    <action>Verify RestoreProofTest held logs identify the exact original request, payer, amount and transaction</action>
    <action>Open Settings and the backup status screen; wait for ordinary WALLET and METADATA backups to finish</action>
    <action>Verify normal VSS WALLET put and readback hashes match and its snapshot contains the retained original proof and accepted request id</action>
    <action>Record the actual guard state; do not require or fabricate an active guard after normal local completion</action>
    <action>Verify the normal METADATA backup put and readback hashes match before stopping the source wallet</action>
    <action>Force-stop the source app and shut down its emulator while preserving its state as evidence</action>
    <action>Create a fresh emulator and install the same instrumented APK without copying app data or the hold marker</action>
    <action>Tap Restore Wallet (testTag "RestoreWallet") and enter the original recovery phrase through normal onboarding</action>
    <action>Tap Restore (testTag "RestoreButton") and wait for normal backup restoration and node synchronization</action>
    <action>Verify the downloaded WALLET hash equals the durable source snapshot hash and the original root identity is restored</action>
    <action>Verify restored proof fields match the original request, payer, seller, endpoint, app, amount and transaction id</action>
    <action>Verify production reconciliation queues that original proof without another payment or replacement request</action>
    <action>Verify the merchant backend reports the same original invoice amount matched and its original merchant acceptance or entitlement completes</action>
    <action>Capture deciding logs, backend receipts and screenshots; verify only one payment exists and stop owned runtimes</action>
  </actions>
</journey>
diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
index 4512fdda2..527e3fa62 100644
--- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
@@ -1,5 +1,8 @@
 package to.bitkit.repositories
 
+import to.bitkit.BuildConfig // test hook
+import java.security.MessageDigest // test hook
+import to.bitkit.ext.toHex // test hook
 import android.content.Context
 import dagger.hilt.android.qualifiers.ApplicationContext
 import kotlinx.coroutines.CoroutineDispatcher
@@ -535,6 +538,12 @@ class BackupRepo @Inject constructor(
                         runningBackups -= category
                         failedBackupRequired -= category
                     }
+                    if (BuildConfig.DEBUG && category in setOf(BackupCategory.WALLET, BackupCategory.METADATA)) runSuspendCatching { // test hook
+                        val remote = vssBackupClient.getObject(category.name).getOrThrow()?.value // test hook
+                        Logger.info("RestoreProofTest VSS category=$category putSha=${testHash(data)} " + // test hook
+                            "readSha=${remote?.let(::testHash)} equal=${remote?.contentEquals(data)}", context = TAG) // test hook
+                        if (category == BackupCategory.WALLET) testWalletSnapshot("put", data) // test hook
+                    }.onFailure { Logger.warn("RestoreProofTest readback unavailable", context = TAG) } // test hook
                     Logger.info("Backup succeeded for: '$category'", context = TAG)
                 }
                 .onFailure { markBackupFailed(category, backupRequired, it) }
@@ -831,9 +840,24 @@ class BackupRepo @Inject constructor(
         return RestoredCoreBackup(createdAt = parsed.createdAt, needsRewrite = migration.changed && persisted)
     }
 
+    private fun testHash(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256").digest(bytes).toHex() // test hook
+    private fun testWalletSnapshot(phase: String, bytes: ByteArray) { // test hook
+        if (!BuildConfig.DEBUG) return // test hook
+        val state = json.decodeFromString<WalletBackupV1>(String(bytes)).paykitPaymentState // test hook
+        Logger.info("RestoreProofTest $phase sha=${testHash(bytes)} guard=${state?.activeOnchainAttempt?.status} " + // test hook
+            "guardRequest=${state?.activeOnchainAttempt?.requestId} accepted=${state?.acceptedOneTimeRequests}", context = TAG) // test hook
+        state?.pendingProofs?.filter { it.kind == PaykitPaymentProofKind.Onchain.type }?.forEach { proof -> // test hook
+            Logger.info("RestoreProofTest $phase request=${proof.requestId} payer=${proof.identity} " + // test hook
+                "txid=${proof.proofData} amount=${proof.onchainAmountSats} app=${proof.paymentAppId} " + // test hook
+                "endpoint=${proof.paymentEndpointIdentifier} verified=${proof.onchainAcceptanceVerified}", context = TAG) // test hook
+        } // test hook
+    } // test hook
+
     private suspend fun restoreWalletBackup(dataBytes: ByteArray): Long {
         keychain.upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, String(dataBytes))
         val parsed = json.decodeFromString<WalletBackupV1>(String(dataBytes))
+        if (BuildConfig.DEBUG) runCatching { testWalletSnapshot("restore", dataBytes) } // test hook
+            .onFailure { Logger.warn("RestoreProofTest snapshot observation failed", context = TAG) } // test hook
         var restoredAttempt: OnchainSendAttempt? = null
         parsed.paykitPaymentState?.let {
             // Validate every proof before writing a guard or replacing existing payment state.
diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt
index 2d6983e55..eb195a46d 100644
--- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt
@@ -1,5 +1,7 @@
 package to.bitkit.repositories
 
+import to.bitkit.BuildConfig // test hook
+import java.io.File // test hook
 import dagger.Lazy
 import com.synonym.paykit.BillingPeriod
 import com.synonym.paykit.PubkyIdentityCapability
@@ -1186,6 +1188,16 @@ class PaykitPaymentProofRepo @Inject constructor(
                 it.proof.exportText().proofValues() == proofJson.proofValues()
         }
         if (!alreadyQueued) {
+            if (BuildConfig.DEBUG && proof.kind == PaykitPaymentProofKind.Onchain && runCatching { // test hook
+                    File("/data/user/0/${BuildConfig.APPLICATION_ID}/files/restore-proof-hold") // test hook
+                        .takeIf { it.isFile }?.readText()?.trim() == proof.requestId.paymentRequestId // test hook
+                }.getOrDefault(false) // test hook
+            ) { // test hook
+                Logger.info("RestoreProofTest held request=${proof.requestId} payer=${proof.identity} " + // test hook
+                    "txid=${proofData} amount=${proof.onchainAmountSats} app=${proof.paymentAppId} " + // test hook
+                    "endpoint=${proof.paymentEndpointIdentifier}", context = TAG) // test hook
+                return false // test hook
+            } // test hook
             paykitSdkService.submitPaymentProof(
                 counterparty = proof.requestId.counterparty,
                 paymentRequestId = proof.requestId.paymentRequestId,
@@ -1194,6 +1206,8 @@ class PaykitPaymentProofRepo @Inject constructor(
                 proofJson = proofJson,
                 billingPeriod = proof.billingPeriod,
             )
+            if (BuildConfig.DEBUG && proof.kind == PaykitPaymentProofKind.Onchain) Logger.info("RestoreProofTest queued request=${proof.requestId} " + // test hook
+                "payer=${proof.identity} txid=${proofData}", context = TAG) // test hook
             Logger.info("Queued a Paykit payment proof for private delivery", context = TAG)
             runSuspendCatching { paykitSdkService.processPendingPrivateMessages() }
                 .onFailure {
10-10 05:22:42.293  3123  3192 I APP     : 2026-10-10 03:22:42.293 INFO    [PaykitPaymentProofRepo.kt:1196]     RestoreProofTest held request=PaykitPaymentRequestId(paymentRequestId=e470dd63-92e8-4b13-8584-fcab299e5f27, counterparty=pubkyggowx1o7x56y8betg6yaotd3amk5e48b5zcispqykc6oiciesjqo, billingPeriodStartsAt=null) payer=pubkyx8jadxyyuhasndhk6g3kt1jfysptphnhpanw8pu5auzqdi88otqo txid=34d03ad637596414c1f8bdb0f3c9a54c6d4ff820986a31f2c80ae8934852377e amount=1000 app=paykit-server endpoint=btc-regtest-p2wpkh - PaykitPaymentProofRepo
10-10 05:22:49.617  3123  3193 I APP     : 2026-10-10 03:22:49.617 INFO    [BackupRepo.kt:543]                  RestoreProofTest VSS category=WALLET putSha=c51f229cac163ea802a5495dd3c2bbdcbdee113382b7948dc254f65897722d1c readSha=c51f229cac163ea802a5495dd3c2bbdcbdee113382b7948dc254f65897722d1c equal=true - BackupRepo
10-10 05:22:49.618  3123  3193 I APP     : 2026-10-10 03:22:49.618 INFO    [BackupRepo.kt:847]                  RestoreProofTest put sha=c51f229cac163ea802a5495dd3c2bbdcbdee113382b7948dc254f65897722d1c guard=null guardRequest=null accepted={pubkyx8jadxyyuhasndhk6g3kt1jfysptphnhpanw8pu5auzqdi88otqo=[PaykitPaymentRequestId(paymentRequestId=e470dd63-92e8-4b13-8584-fcab299e5f27, counterparty=pubkyggowx1o7x56y8betg6yaotd3amk5e48b5zcispqykc6oiciesjqo, billingPeriodStartsAt=null)]} - BackupRepo
10-10 05:22:49.619  3123  3193 I APP     : 2026-10-10 03:22:49.619 INFO    [BackupRepo.kt:850]                  RestoreProofTest put request=PaykitPaymentRequestId(paymentRequestId=e470dd63-92e8-4b13-8584-fcab299e5f27, counterparty=pubkyggowx1o7x56y8betg6yaotd3amk5e48b5zcispqykc6oiciesjqo, billingPeriodStartsAt=null) payer=pubkyx8jadxyyuhasndhk6g3kt1jfysptphnhpanw8pu5auzqdi88otqo txid=34d03ad637596414c1f8bdb0f3c9a54c6d4ff820986a31f2c80ae8934852377e amount=1000 app=paykit-server endpoint=btc-regtest-p2wpkh verified=true - BackupRepo
10-10 05:27:58.620  4973  6540 I APP     : 2026-10-10 03:27:58.620 INFO    [BackupRepo.kt:847]                  RestoreProofTest restore sha=c51f229cac163ea802a5495dd3c2bbdcbdee113382b7948dc254f65897722d1c guard=null guardRequest=null accepted={pubkyx8jadxyyuhasndhk6g3kt1jfysptphnhpanw8pu5auzqdi88otqo=[PaykitPaymentRequestId(paymentRequestId=e470dd63-92e8-4b13-8584-fcab299e5f27, counterparty=pubkyggowx1o7x56y8betg6yaotd3amk5e48b5zcispqykc6oiciesjqo, billingPeriodStartsAt=null)]} - BackupRepo
10-10 05:27:58.620  4973  6540 I APP     : 2026-10-10 03:27:58.620 INFO    [BackupRepo.kt:850]                  RestoreProofTest restore request=PaykitPaymentRequestId(paymentRequestId=e470dd63-92e8-4b13-8584-fcab299e5f27, counterparty=pubkyggowx1o7x56y8betg6yaotd3amk5e48b5zcispqykc6oiciesjqo, billingPeriodStartsAt=null) payer=pubkyx8jadxyyuhasndhk6g3kt1jfysptphnhpanw8pu5auzqdi88otqo txid=34d03ad637596414c1f8bdb0f3c9a54c6d4ff820986a31f2c80ae8934852377e amount=1000 app=paykit-server endpoint=btc-regtest-p2wpkh verified=true - BackupRepo
10-10 05:28:03.212  4973  6539 I APP     : 2026-10-10 03:28:03.212 INFO    [BackupRepo.kt:775]                  Full restore success - BackupRepo
10-10 05:28:08.929  4973  6026 I APP     : 2026-10-10 03:28:08.929 INFO    [PaykitPaymentProofRepo.kt:1209]     RestoreProofTest queued request=PaykitPaymentRequestId(paymentRequestId=e470dd63-92e8-4b13-8584-fcab299e5f27, counterparty=pubkyggowx1o7x56y8betg6yaotd3amk5e48b5zcispqykc6oiciesjqo, billingPeriodStartsAt=null) payer=pubkyx8jadxyyuhasndhk6g3kt1jfysptphnhpanw8pu5auzqdi88otqo txid=34d03ad637596414c1f8bdb0f3c9a54c6d4ff820986a31f2c80ae8934852377e - PaykitPaymentProofRepo

An independent server read confirmed the same original server request changed from accepted to proof_submitted. Profile QR matches the original payer. Locks completion preceded restore and is not used as restored-proof evidence. Both owned Android runtimes stopped; no second payment.

  • new local-paykit-fallback.xml — local-only rc11 pairing/payment checks after a remote staging pairing failure; setup in local-paykit-fallback.md. Unrun; standalone payment does not establish Shop order completion.

  • new onchain-original-payment-retry.xml — Pending → approve fee and normal payment authentication → retry the same recipient amount and input set; persist both candidates, keep uncertainty guarded and finish only the accepted/observed winner. Spec parsed; current funded retry journey remains unrun.

  • new shop-onchain-proof.xml — linked issuer and funded Bridge hardware wallet: exact original transaction and delivered proof, with no new signing/payment while observation is pending. Spec parsed; native hardware journey unrun.

  • new onchain-accepted-result.xml — accepted fixed-amount and send-all finish local activity and expose distinct exact transaction IDs in Details.

Manual Tests

  • Controlled non-final refusal → Send Confirm → Pending → no success/proof/paid order and no fresh send after reopening — controlled native broadcast-refusal fixture not in Capabilities.
  • Lost response or process death after dispatch → reopen the same Shop request and switch payment methods → no second payment — native response-loss/dispatch-synchronization fixture not in Capabilities.
  • regression: fail local storage/activity/proof/transfer follow-up after Accepted → reopen and finish the original local operation → same transaction ID without another broadcast — storage-fault injection at the send boundary not in Capabilities.
  • regression: fail local metadata write/readback after accepted funding and durable paid-order save → Transfer → Setting Up without a payment-failure toast or another confirm swipe → original activity resumes without a second funded order — deterministic SQLite fault injection not in Capabilities; unrun.
  • regression: physical Trezor Send → approve transaction → original hardware payment and proof survive transport changes — physical USB permissions/enumeration and BLE transport not in Capabilities.

Automated Checks

  • Review correction at 6b000fe: the Android instrumentation callback supplies all three parameters; the observation fixture seeds unresolved state through the test-only storage seam and restores the exact prior state. Android test APK compilation, focused attempt-store JVM checks and detekt passed. Device execution of this corrected instrumentation test remains unrun. Production code is unchanged, so the recorded aa33095 app acceptance remains applicable.

  • updated BroadcastExceptionExtTest.kt and HwSendViewModelTest.kt — known backend refusal permits dismissal without clearing the receipt; unknown/connectivity errors stay guarded and retry retains original signed bytes.

  • updated OnchainSendAttemptStoreTest.kt and OnchainSendCoordinatorTest.kt — prepared send-all funding rejects underfunding and amounts beyond approved terms before candidate retention; rejected receipts never reach broadcast.

  • updated ActiveOnchainAttemptBackupTest.kt — send-all funding restore rejects recipient amounts above the retained transaction total in all outcome states while preserving valid surplus.

  • updated ActiveOnchainAttemptBackupTest.kt — send-all funding backups retain the actual recipient amount and original order terms through restore; underfunding and fixed-amount mismatch remain rejected.

  • updated TransferViewModelTest.kt — fresh and resumed accepted funding pass complete original order terms to durable follow-up without another payment.

  • updated ActiveOnchainAttemptBackupTest.kt — restored original candidate fees must agree with the retained original fee rate; omission preserves the original fallback.

  • updated ActiveOnchainAttemptBackupTest.kt — restored funding amounts must equal their original order fee before installing the payment guard.

  • updated ActiveOnchainAttemptBackupTest.kt — every retained replacement has a valid fee rate before restore installs a payment guard; original transaction fee fallback is preserved.

  • updated ActiveOnchainAttemptBackupTest.kt — malformed restored contacts are rejected before installing the payment guard; absent and valid contacts remain accepted.

  • updated TransferRepoTest.kt — accepted original funding completes after connectivity recovery without another send.

  • updated OnchainSendAttemptStoreTest.kt — the captured contact is saved before dispatch and survives reopening the active payment guard.

  • updated AppViewModelSendFlowTest.kt — recovery follows the original Shop request and consumes an existing acceptance without returning to Pending.

  • updated AppViewModelSendFlowTest.kt — recovering an older blocked payment never assigns the new recipient’s contact to it.

  • updated BackupRepoTest.kt — an older unresolved backup resumes retained local acceptance after original context is restored, without requiring another transaction event.

  • updated ActiveOnchainAttemptBackupTest.kt and BackupRepoTest.kt — missing original follow-up context rejects restore before installing a blocking payment guard.

  • updated ActiveOnchainAttemptBackupTest.kt, BackupRepoTest.kt and OnchainSendAttemptStoreTest.kt — accepted replacement backups require the winning candidate’s fee rate; missing/partial maps fail restore, valid replacement rates and original-candidate fallback remain supported.

  • updated HwSendViewModelTest.kt — exact-wallet completion after a hardware Shop broadcast timeout clears the retained send guard and preserves the original amount/request/payer; unrelated observations cannot clear it. Build, unit and lint checks completed; the funded hardware journey remains unrun.

  • updated LightningRepoTest.kt — a retained accepted result after a broadcast failure stays Pending while durable repair fails, including after reopening; no second native send is prepared.

  • updated ActivityServiceTest.kt — accepted-payment restoration preserves a deliberately removed contact and allows local follow-up to finish. The previous behavior reattached the original contact in the regression. Local verification: Debug build, unit tests and lint. Device and merchant acceptance remain outstanding.

  • updated ActiveOnchainAttemptBackupTest.kt and LightningRepoTest.kt — unsigned backup attempts are rejected; retry preserves Pending when the accepted winner is not durable, without authorizing or dispatching another payment. Both defects reproduced before correction. Local verification: Debug build, unit tests and lint. Fresh-wallet Shop restoration and funded merchant acceptance remain outstanding.

  • ran PaykitPaymentProofRepoTest.kt and HwSendViewModelTest.kt regressions: definite software preparation failure retains the proof until its original private boundary is released; a restored signed hardware receipt blocks ordinary and other-request signing from the same wallet. Required local build, unit tests and lint passed at bcc22b1.

  • ran LightningRepoTest.kt and ActivityRepoTest.kt regressions: failed accepted-outcome persistence and failed repair stay Pending across restart; contact-assignment retries finish marker cleanup and replacement propagation. Required local build, unit tests and lint passed at 4306d22.

  • updated HwSendViewModelTest.kt and PaykitPaymentProofRepoTest.kt — signed hardware Shop receipts survive encoded backup/repository restoration; explicit authorized retry broadcasts the original bytes without another signature. Wrong payer/wallet/address/amount cannot load the receipt. Restoration itself does not broadcast or prove acceptance.

  • Hardware Shop connectivity failure retains its signed payment across cancellation and explicit retry, with one signature and the same original payer/request. The regression failed before correction; affected hardware-send/proof tests, app build and changed-line static analysis completed. Physical hardware remains unrun.

  • Non-connectivity hardware Shop broadcast errors also retain the original signed payment through cancellation and retry. The invalid-transaction regression failed before correction; affected hardware-send/proof checks, app build and changed-line static analysis completed. Physical hardware remains unrun.

  • added BackupRepoTest.kt — unsigned ordinary sends and transfers defer the entire wallet snapshot before any remote write.

  • added HwSendViewModelTest.kt — save denial and storage exceptions preserve the signed payment across cancellation and retry it without signing again.

  • added OnchainSendCoordinatorTest.kt — accepted retry storage failure stays Pending with the original candidate family after restart.

  • added TransferViewModelTest.kt — changed funding address, client balance or service fee cannot complete the retained transfer or trigger another send.

  • updated OnchainSendAttemptStoreTest.kt — repeated restore retains an imported operation’s progressed fee and completion; changed payer or recipient is rejected, and a first import without follow-up context remains guarded.

  • updated ActivityRepoTest.kt — a failed Core contact write leaves the durable manual-detachment marker intact. Both new regressions failed before correction.

  • updated OnchainBackupRestoreDeviceTest.kt — verifies the remote wallet payload contains the accepted attempt, deletes its local attempt record and checks it is absent, then restores the original wallet, transaction, amount, exact inputs, candidate IDs and completed follow-up from VSS. Corrected device replay passed. The earlier completed-attempt replay was a false positive because backups excluded its completed receipt. This remains an ordinary-payment test on the same wallet, not fresh-wallet Shop request/proof recovery.

  • ran repaired shared-state Paykit device fixtures at 5f800c2: app/test APK compilation and drawer/Pending component checks passed on Android 16, with exact installed app APK bytes verified. Removed duplicate profile arguments and obsolete settings dependencies. These are component checks, not funded PIN/recovery or merchant checkout.

  • updated PaymentDeadlineSubmissionTest.kt, AppViewModelSendFlowTest.kt — queue-time expiry, expiry after immutable preparation, and exact original hardware cancellation with no release after dispatch.

  • ran local JVM suite and detekt against Paykit rc65 and hosted LDK rc70; current funded device, hardware and Shop checkout journeys remain unrun. Detekt retains its existing ignoreFailures=true configuration.

  • ran native rc70 consumer compilation and broadcast-outcome/recovery checks against the hosted Maven artifact with local Maven repositories excluded.

  • updated OnchainSendAttemptStoreTest.kt, PaykitPaymentProofRepoTest.kt — durable admission precedes request consumption; failed writes preserve request details; restart cleanup preserves live, legacy and signed attempts, and proof-removal failure retains the guard.

  • added ActiveOnchainAttemptBackupTest.kt, updated PaykitPaymentStateBackupTest.kt, BackupRepoTest.kt — shared golden wire, wallet/network/proof rejection and guard-before-proof restore.

  • added OnchainSendCoordinatorTest.kt — exact original amount/input retries, original winner races, payer change and UInt32 fee bounds.

  • added OnchainSendAttemptStoreTest.kt — serialized admission, durable guards, outcome persistence and exact transaction observation.

  • added SendPendingScreenTest.kt — refusal/candidate visibility and enabled local Details callback while remaining Pending; component checks do not reload durable state.

  • updated LightningRepoTest.kt, LightningServiceTest.kt — explicit accepted/rejected/unknown mapping and pre-dispatch boundaries.

  • updated AppViewModelSendFlowTest.kt, PaykitPaymentProofRepoTest.kt, TransferViewModelTest.kt — proven pre-admission errors, accepted follow-up failures, request protection and original hardware proof identity.

  • updated AppViewModelSendFlowTest.kt — permission denial, captured request/identity across asynchronous hardware authorization, and started-proof preservation after retry denial/cancellation.

  • updated TransferViewModelTest.kt — fresh Accepted and resumed Accepted/Observed funding retain one order/send and paid-success navigation after local activity failure; failed funding persistence retains the original order without success.

  • updated TransferRepoTest.kt, SendPendingViewModelTest.kt — startup/event funding resumption, partial storage idempotency and original-wallet Details without acceptance inference.

  • updated HwWalletRepoTest.kt, HwSendViewModelTest.kt, ActivityRepoTest.kt — exact outgoing original-account observation, Sent activity durability and one native broadcast while proof completion is pending.

  • removed PaykitOnchainPaymentProofLookupTest.kt — address/amount matching no longer establishes that an interrupted attempt was accepted.

  • ran remote dependency validation with Maven local excluded — 0.7.0-rc.68 resolved AAR SHA-256 0cee2079291260aea12bf60ac9c8af8f459d9f24c3327d4cb021e5686035aa2f, identical to the published canonical artifact.

Recovery completion and identity (7984a64)

  • A retry returning Accepted keeps Pending visible until the exact original operation has durable local completion; both the immediate result and update observer apply the same identity/completion checks.
  • Restoring an already accepted ordinary payment resumes its original local follow-up after all restore context is installed, without requiring a new node lifecycle/event. Unknown and Shop request attempts do not enter this ordinary repair route.
  • Hardware observation verifies the original recipient outputs and exact amount before creating Sent activity or permitting a Shop proof; unrelated outgoing transactions are refused.
  • Each reported defect was reproduced by its focused regression before correction. Pending, backup, hardware-observation checks and application compilation completed afterward. Remote VSS and physical hardware acceptance remain unrun.

Restart-safe Shop preparation (5a07a0f)

  • Wallet backups exclude a never-dispatched preparation and its exact empty started proof together, while preserving signed candidates and unrelated proofs. Snapshot capture serializes with receipt retention.
  • Hardware Shop payments persist their exact signed candidate ID before broadcast. Reopening can reconcile that original candidate; persistence failure prevents dispatch, and candidate retention alone cannot create Sent activity or a delivered proof.
  • Focused backup, receipt ordering, response-loss, persistence-failure and repository-reopen checks completed. Physical hardware and funded merchant/channel validation remain unrun.
Historical validation before the shared-state Paykit integration

Local verification at 56398e3: master b29956a was integrated without manual edits; compilation and 484 focused tests passed (0 failures/errors/skips). Published rc68 AAR identity was verified with Maven local excluded. Detekt completed with ignoreFailures=true and 577 reported findings; it is not warning-free. All finding locations map to existing parent lines, which does not prove identical prior structural findings. Full-suite, device, native-fault and physical-hardware checks were not repeated; heavy hosted integration CI remains deferred to release.

Earlier verification at 62c32cc: compilation and 470 focused tests passed (0 failures/errors) against unchanged published rc68, with Maven local excluded. The identity-switch-during-authorization regression failed before the captured identity/context checks and passes now; captured callbacks cannot authorize a replacement request. No device, native fault, physical hardware or contact-deletion journey was run on this merged source.

Earlier verification at 6c75463: compilation and 169 focused tests passed (0 failures/errors). All three new duplicate-funding regressions failed on the preceding production source by funding a second order; the persistence-failure regression remained fail closed. Maven local was excluded and the resolved AAR hash matches unchanged published rc68. No device or storage-fault journey was run for this fix.

Earlier verification at 0ef4613: compile, 3,079 unit tests (0 failures/errors/skips), app/test APK builds and two emulator component tests passed. Startup funding and pre-admission regressions failed before their fixes. Detekt exited successfully with ignoreFailures: 570 findings remained, none on added or changed feedback lines. The installed APK’s native library matched rc68. Full-suite, Detekt and device checks were not repeated for 6c75463 or 62c32cc.

Prior rc68 validation: 95 affected tests and funded fixed-amount/Max native journeys passed with independently verified exact backend transactions. Those native journeys were not repeated for this batch. Controlled native refusal, response-loss, storage-fault and hardware Shop journeys remain unrun; the new Preview is synthetic component coverage only.

Current integration verification at d252bcb: 925 focused tests passed; strengthened Shop rerun (203 cases), 96 Jade/Paykit integration tests and 13 emulator component tests passed. The regression for acknowledgement without durable original Shop activity failed on old behavior and passes after repair. Exact request/txid mismatches and identity switching cannot deliver or acknowledge another proof, and recovery never broadcasts. Test API integration repairs and final test-only formatting compiled successfully. Detekt exits successfully with ignoreFailures=true; existing findings remain, so it is not warning-free. Component refusal screens use synthetic fixtures, not a real native refusal. Final matched release-pair Shop, response-loss and physical hardware validation remain required.

Feedback verification at 5f7839a: both new pre-dispatch regression cases failed before the fix; 334 affected send-flow tests and compilation passed afterward. Detekt completed with existing findings and none on added lines. No devices or native fault fixtures were used in this batch; final recovery and release-pair validation remain outstanding.

Current recovery and backup verification: 307 affected checks passed against the published remote rc69 AAR before backup additions. Then 57 focused backup/metadata checks passed, including shared wire round-trip, wallet binding, original proof authorization/completion and restore ordering. The final UInt32 fee-boundary regression failed before its correction and passed afterward, along with current app/test APK builds. These overlapping counts are not additive. Built app native bytes match the hosted rc69 arm64 library; the current backup batch has not been installed or driven on a device. Detekt exited with ignoreFailures=true: one changed-line complexity finding remains, zero changed-line formatting findings. Hosted Maven publication completed successfully. Earlier funded preparation and Pending/fee/PIN observations seeded uncertainty before submission; they do not prove backend refusal, response loss or a successful retry. Current funded fixed/Max retry, independent Android native VSS vector, physical hardware and final Shop release-pair QA remain unrun.

Pending UI verification at 15f45fa: 16 focused JVM checks and 3 emulator component checks passed, along with app/test APK builds. The preceding view model failed the exact observed-successor regression; the preceding error layout failed the non-overlap assertion. Detekt exited successfully with ignoreFailures=true and no changed-line findings; existing findings remain. Component checks use synthetic state. Funded auto-navigation on this UI batch has not been repeated.

Native rc69 validation on preceding production head152b43d: five native fixture checks passed with installed hosted package bytes. A fixed1,000-sat retry with withheld backend acknowledgements remained Pending until independent exact successor observation and durable local completion. Native transport repeated the same transaction; no distinct second payment was observed. Max retained99,890sats and exactinputs: a higher fee failed before broadcast, then original-fee retry succeeded. Original uncertainty was seeded before the first broadcast, so this does not prove process-death recovery after initial dispatch. Native VSS derivation vectors passed. Physical hardware, native refusal and final production Shop merchant pairing remain unrun.

Published 5f8e105:

  • Accepted funding resumes only with the original order amount, fee and wallet; changed or missing terms leave it guarded.
  • Backup restore resumes the accepted transfer after the original guard and transfer state are restored, including an already-running node.
  • Definite authorization denial before the first hardware broadcast releases only the exact matching prepared proof after durable storage; attempted or uncertain payments remain guarded.
  • Attempt timestamps use the injected clock.

Validation: four regressions failed against the previous production behavior. 209 focused tests passed; final overlapping 31-case and 62-case checks passed, alongside app/test APK builds against published rc69 and detekt. Lint reported existing findings with zero introduced-line findings. Physical hardware, live backup restore and final selected-version Shop checkout remain unrun. Draft status remains unchanged.

Published 6c1a9cb: added the Pending observation device integration fixture. Two checks passed using the current production APK and published rc69. The shared backup reader preserved the original wallet, inputs and candidate family; injected exact-positive observation completed the production Pending callback with the original txid and amount. The fixture restored its original saved operation afterward. This validates the store/ViewModel/screen callback, not a native event, full-app success route, remote VSS restore or merchant checkout.

Lint/detekt and fixture APK compilation completed. Full accepted funding/VSS device restore, authenticated hardware Shop rejection, physical hardware and final merchant pairing remain unrun. Five new review findings are being addressed; draft status was retained at that validation point.

Published 66d32dd: preparation cancellation releases only an empty pre-dispatch guard; completing an older payment does not present the blocked new send as successful; uncertain transfer funding opens the original recovery surface; each candidate retains its authorized fee rate; proof reconciliation avoids the reversed attempt/proof lock order. The original order, payer, amount and exact inputs remain protected.

Validation: 96 focused tests passed, with meaningful prior-behavior failures for cancellation, misleading navigation, transfer routing, fee metadata, shared restore and lock ordering. App/test APK builds passed against published rc69. Detekt completed with 515 existing findings and zero introduced-line findings (ignoreFailures=true). The shared optional candidate fee-rate map passed the same golden vector as iOS. Current transfer recovery device routing/PIN, funded restore, physical hardware and final selected-version merchant checkout remain outstanding.

Published c7745fc: Accepted successors use the actual signed transaction input/prevout fee instead of the original fee. Missing or invalid evidence keeps follow-up guarded; verified fee/rate repair also updates an existing activity placeholder.

Validation: 174 affected JVM tests passed (0 failures/errors/skips), including exact previous-output calculation, missing/substituted inputs, invalid values and arithmetic overflow. The earlier stale-fee regression failed before the writer fix. Current transfer/PIN/device validation, four new review findings and final selected-version Shop checkout remain outstanding.

Published d3f3bec: both retry fallback paths verify original attempt and wallet identity. The prior behavior failed the later-winner regression; all 17 coordinator tests passed afterward. Three remaining review findings and current device/release-pair validation remain outstanding.

Published b9cf568: restored shared/iOS contact attribution no longer prevents completion of an accepted payment. The original contact fills only an empty activity contact; later edits remain unchanged. A failed contact write retains the payment guard.

Validation: the shared golden backup failed acknowledgement before the fix. All 175 affected JVM tests passed afterward (13 attempt-store, 159 send-repository, 3 activity tests; no failures/errors/skips), including contact write failure and later-edit preservation. Two remaining findings concern pre-prepare proof crash ordering and transfer Pending completion. Both apps still need matching rc70 consumer and current funded/Shop validation.

Published df05937: transfer Pending now observes durable completion of its exact original order, wallet, input set and candidate family, then opens the funded order. Accepted funding stays Pending until local follow-up is saved, and this navigation does not dispatch another payment.

Validation: the stale transfer Pending regression failed before the fix. Compilation and all 3,465 JVM tests passed before formatting; the final 355 affected tests and detekt passed after formatting. Detekt has 577 existing findings with zero findings on introduced lines (ignoreFailures=true). No device or merchant checkout was run for this batch.

The signed receipt and private boundary are persisted before hardware endpoint consumption. Fresh-wallet Shop request/proof recovery, current expiry device coverage, physical hardware and final merchant order/proof checkout remain outstanding; this PR remained draft at that validation point.

Original retry deadline verification (6686f9a)

  • The expiry-after-authentication regression failed on the previous retry behavior and passes with the original deadline forwarded into native preparation/dispatch.
  • Affected coordinator, send-flow, native service and repository tests passed; the inclusive deadline remains valid and expiry retains the original amount, inputs and payment guard.
  • Funded recovery, actual PIN navigation, hardware/restore and final selected-version Shop checkout remain outstanding.

Original input validation (fea3f42)

  • Original retries pass only the saved signed receipt’s exact outpoints to native preparation. A fresh output-list snapshot no longer prevents authoritative native validation.
  • Native preparation still resolves actual values and requires eligible wallet inputs; this change does not allow spent inputs, input substitution or general replacement recovery.
  • The absent-output regression failed before the fix. Affected repository, coordinator and native-service tests and app/test APK builds completed. Native refusal remains before authentication or candidate dispatch.
  • Current funded Android PIN/recovery is documented below; final Shop merchant checkout remains unvalidated.

Funded Android payment-PIN verification (fea3f42, native rc70, Paykit rc65)

  • A disposable funded wallet retained a real signed native receipt as deliberately seeded Pending; the original transaction was not broadcast by the setup fixture.
  • Attempting another payment opened the original Pending screen rather than showing success. Retry displayed the original recipient and 1,000-sat amount; cancelling payment PIN kept the same Pending transaction.
  • Explicit retry with the normal payment PIN reached Bitcoin Sent. Details showed a 1,000-sat payment and 141-sat fee.
  • After stopping the app, persistence verification retained the original input set, both candidate IDs, the accepted successor and completed local follow-up. The backend independently returned that exact successor with the saved inputs and 1,000-sat recipient output.
  • This verifies the seeded-Pending UI/authentication/persistence path, not real acknowledgement loss, remote VSS restoration, hardware signing or merchant order/proof completion. Those checks remain outstanding.

Real acknowledgement-loss recovery (2cb41e0, native rc70, Paykit rc65)

  • A funded native broadcast was accepted by the backend while an app-specific proxy suppressed the acknowledgement. Native returned Unknown and the original signed receipt remained persisted. This case did not inject the outcome.
  • The device reproduced a sync-ordering defect: blocked observation failed sync before opening recovery. The regression failed before the fix; the current app checks the retained guard before sync and opens that exact original Pending payment.
  • Restoring observation completed the original 1,000-sat payment to Bitcoin Sent without authorizing a retry, dispatching another transaction or adding a candidate. Persistence retained the original exact inputs/transaction ID and completed local follow-up. The accepted transaction was independently decoded from the backend.
  • Focused repository/send-flow tests and app/test APK builds completed. Remote VSS restoration, hardware signing and final selected-version Shop merchant order/proof completion remain unvalidated; both app PRs retained their draft gate at that validation point.

First-submission expiry (875a3cb)

  • A deadline failure before the first native broadcast is returned as definitely not dispatched. The original Shop proof is removed before the expired preparation guard can be cleared.
  • Previously submitted, uncertain, recovered and reopened signed candidates retain their guard; no absence-based release is added.
  • The initial-expiry regression failed before the fix. Focused attempt-store, repository and send-flow checks and the app build completed; the proof-cleanup failure case preserves the guard. Device validation of this new expiry path remains outstanding.

Current-head device component verification at875a3cb7: rebuilt and installed APK identity verified; SendPendingScreenTest.kt and DrawerMenuWidgetsTest.kt ran on Android emulator. This covers uncertainty presentation, guarded retry controls and exact-candidate Details availability; it does not certify funded expiry/replacement, remote restore, hardware or merchant proof/order completion. Those acceptance checks remain open.

Original-winner recovery accounting (a6ddeb0)

  • A funded PIN-authorized replay on 875a3cb exercised real backend acknowledgement loss, an authenticated same-input/1,000-sat retry rejected by the backend, and recovery of the independently observed original after restart.
  • That replay exposed a zero-fee Details entry despite its actual 141-sat fee. The correction requires the exact winning fee before local completion and repairs the activity entry; missing fee evidence keeps Pending guarded.
  • The accounting regression failed before correction; repository recovery checks and application compilation completed afterward. The corrected funded Details readback at a6ddeb0 shows the original 1,000-sat payment and exact 141-sat fee, also retained in the durable receipt. Expiry, remote VSS restore, hardware and merchant order/proof acceptance remain open.

Restart-safe hardware expiry cleanup (d463af9)

  • A definite first-dispatch denial retains the exact signed candidate and a durable cleanup marker. Reopening releases only the captured original private payment-list version before removing its proof; failed storage retains the marker for retry.
  • Denied operations cannot submit a proof or export a partial wallet backup. Prior broadcast uncertainty and accepted candidates remain protected.
  • The process-reopen regression failed before correction. Proof/private-state repositories, hardware send and app send-flow checks plus the Android build completed. This is local repository restart coverage; physical hardware and remote restore remain unvalidated.

Current validation gaps: the rc65 SDK fresh-grant remote request fixture and same-wallet ordinary-payment remote VSS restore passed. Fresh-wallet Shop request/proof recovery, funded channel recovery, current expiry device coverage, physical hardware and final merchant order/proof checkout remain unvalidated.

Payment storage recovery (8f4b85e)

  • Transfer records are captured under the guarded-attempt snapshot lock, so accepted follow-up cannot leave a backup missing both its transfer and active guard.
  • Native acceptance whose outcome cannot be durably saved remains Pending until the exact signed candidate is persisted or independently observed; stale requested amount and zero-fee metadata cannot produce success.
  • Contact cleanup retains immediate attribution invalidation and retries the durable removal after storage failure, including when its filtered cache already matches.
  • All three regressions failed before correction. Affected backup, send-repository and private-reservation checks and the Android build completed. Remote restore, physical hardware, funded channel and merchant order/proof acceptance remain outstanding.

Retained hardware completion (2592aa2)

  • Reconciled hardware completions are retained by exact wallet and transaction ID until the matching pending hardware result consumes them. Later completions cannot overwrite an earlier one while the sheet is closed.
  • A mismatched candidate cannot consume an entry; lookup verifies the original payer identity. Completion resumes local follow-up without another signing or broadcast.
  • The multiple-completion regression failed before correction. Affected app send-flow and hardware-send checks and the Android build completed. Physical hardware and final merchant proof/order acceptance remain unvalidated.

Prior private payment boundary (e38369f)

  • Local verification: affected private-state, proof, send-flow and backup unit checks; app build and zero changed-line detekt findings.
    • Reproduced consumed version 6 being lost when cancelling an unsent version 7.
    • The original receipt and encoded backup retain both versions; definite cancellation restores 6, while newer consumption remains protected.
    • Bound requests without a payment-list version retain their existing behavior.
  • Funded merchant checkout, fresh-wallet Shop VSS and physical hardware acceptance remain outstanding.

Unsent private preparation cleanup (5214de9)

  • ran PaykitPaymentProofRepoTest, AppViewModelSendFlowTest, private payment and backup regressions, dev app build and changed-line formatting checks.
  • reproduced both prior failures: consumption preceded a failing hardware proof write, and interrupted unsigned cleanup deleted the proof without releasing version 7 back to 6.
  • cleanup retains the original proof and operation when private storage or proof deletion fails; a later retry completes the same cleanup.
  • fresh-wallet Shop VSS, current expiry device journey, physical hardware and funded merchant checkout acceptance remain unrun.

Retained private payment version (08d35b7)

  • updated PaykitPaymentProofRepoTest.kt — restored signed receipts consume their saved private version before retry; failed private storage blocks dispatch, existing consumption remains valid, and another order cannot reuse the same contact while its signed payment is retained.
  • Local verification: affected private/proof, send, hardware, backup and deadline checks; app build and static analysis.
  • Fresh-wallet Shop VSS, physical hardware and funded merchant checkout remain unrun for this head.

Unsigned restart recovery

  • updated OnchainSendAttemptStoreTest.kt: ordinary send and transfer restart can back up and admit a later payment; current-process preparation remains blocked.
  • ran focused store regressions and development build for the change.

Transfer backup totals

  • updated ActiveOnchainAttemptBackupTest.kt: totals above Long.MAX_VALUE are rejected; the maximum representable value remains valid.

Prepared funding total correction

  • The regression covers the exact approved total and rejects missing or excessive mining fees before retaining a candidate.
  • The app build and static analysis were checked with matching rc71 artifacts. Hosted publication completed; remote-only Maven resolution and an app build were then verified, including the packaged native libraries.

Retained winning fee correction

  • Local follow-up reuses the exact verified fee for the selected winner and still repairs Details; an unverified successor never borrows the original candidate fee.
  • The regression reproduced an unnecessary backend query despite retained exact evidence, then checked follow-up with missing backend details and durable provenance after reopening the store.

Ordinary activity recovery

  • Incomplete accepted ordinary sends retry local follow-up after transaction observation, startup, reconnection and sync; retries preserve the original wallet and exact transaction without preparing or broadcasting another payment.
  • A regression covers a consumed transaction event followed by transient activity failure and recovery without another event. Related repository regressions were exercised.

Previous transfer recovery

  • A new transfer blocked by an older unresolved transfer opens the original transfer Pending route, preserving its original order and funding context.
  • Focused coverage exercises different original and newly attempted order IDs; funded transfer recovery remains unrun.

Recovery feedback updates

  • A definite hardware Shop refusal now allows leaving the Send sheet while retaining its signed transaction in memory and durable storage. Retrying reuses those bytes without another signature. The original wallet/payment guard remains retained; uncertain transport outcomes still prevent dismissal.
  • Missing USD rates no longer block prepared retry approval or authentication; the exact Bitcoin fee and fee-over-half warning remain available. Node stop/restart now cancels suspended original-order recovery and starts a fresh recovery attempt without another payment.
  • A retry proven never submitted removes only its newly added candidate and fee rate. The original payment remains observable, while any candidate that reached native dispatch remains guarded. This also covers authorization failure and preserves original payer, request, order and inputs.
  • The original prepared payment records a durable dispatch marker on the native queue after wallet/deadline validation and before broadcast. Restart can release a payment proven never submitted, with original Shop proof/private-list cleanup before guard deletion; any submitted or uncertain payment remains guarded. A failed marker write stops native dispatch.
  • Original-payment retries cap the custom rate at 999 sat/vB, show the exact prepared transaction fee before authentication, and require the normal fee sanity confirmations. Cancellation stops dispatch; approval submits that same prepared object with the original amount and inputs.
  • Verified hardware Shop completion applies the saved tags only to the original wallet and transaction. It consumes that metadata after the tag write so later proof retries preserve subsequent tag edits; a failed write retains the pending tags.
  • Restore validates the original Bitcoin recipient and its network before installing an active payment guard or replacing its pending proofs. Invalid or wrong-network recipients fail before wallet payment state is changed.
  • A restored accepted hardware proof rechecks the exact original wallet transaction and verifies its local Sent activity before delivery. If reconstruction is unavailable the durable proof remains for retry, even when its acceptance was verified before backup.
  • Retained on-chain resolutions are observed together with the active payer identity and survive identity activation. Returning to the original payer replays its completion without needing another payment or another repository emission.
  • Preparing a retry retains its exact candidate family while preserving the original transaction, evidence and refusal reason. Only actual submission selects the new candidate as Pending; denied authorization no longer erases the original diagnostics.
  • Hardware Shop success no longer consumes the currently active contact. Its original proof completion owns contact association, so a late result cannot steal or relabel a newer payment contact.

Initial preset receipt retention

  • updated LightningServiceTest.kt — initial software send, send-all and Fast transfer pass through durable receipt retention before broadcast at 1000 sat/vB; the maximum UInt32 preset and saved fee provenance survive reopening and backup serialization.
  • updated OnchainSendCoordinatorTest.kt — zero and overflowing initial presets are rejected; a recovery rate above 999 remains rejected before native preparation and at receipt retention.
  • Local verification: focused service, coordinator and attempt-store regressions; changed-line static checks. Funded device validation of these high preset rates remains unrun.

Retained hardware retry follow-up

  • Retained Shop retries accept changed display tags without replacing the original request or signed transaction. Payer, request, recipient, amount and deadline remain exact.
  • A retry that fails before dispatch retains the earlier refusal navigation state; a new dispatch clears that hint and an unknown result remains guarded.
  • Local verification: focused HwSendViewModelTest and detekt passed; reproductions failed before the correction.
  • Hardware/device refusal acceptance remains unrun. Hosted CI and review must apply to the latest head.

Reported Electrum refusal formats (6558827)

  • Recognizes supported JSON-string/message envelopes and exact coded RPC -25/-26 responses. Complete replacement-fee refusals allow leaving the hardware sheet without releasing the original signed payment or durable wallet guard. Unknown and malformed responses remain guarded.
  • Focused parser and retained-payment navigation regressions were checked on the corrected source. The reported restart route is addressed in 37e4405; the original retained hardware payment can be reopened without preparing another payment.
  • Current-head CI/human review and actual funded Shop, fresh-device wallet/VSS and hardware acceptance remain pending.

Retained hardware request reopen (37e4405)

  • Keeps the exact original refused hardware request accessible after restart and opens its original wallet, recipient, amount, fee and signed transaction without resolving a new endpoint or preparing fresh inputs.
  • Unknown/completed/changed requests remain guarded. Explicit authorization is still required; cancellation cannot remove an in-flight request. Durable guards and original signed bytes remain intact.
  • Local verification: focused request-store reload, original-wallet reopen, receipt retention and authenticated retry regressions; introduced-line static checks. Actual hardware restart, funded Shop and fresh-device wallet/VSS acceptance remain unrun; current-head CI/human review are pending.

Retained hardware confirmation correction (b33dc94)

  • Reopening hands the existing request context to the Send sheet before showing it, so Swipe To Pay reaches the hardware retry route instead of reporting a request mismatch.
  • The retained confirmation keeps the original signed fee and hardware wallet fixed; fee refresh, speed selection and funding-source switching cannot replace them. In-flight requests disable Dismiss in the list and details, while repository cancellation remains guarded.
  • Focused JVM regressions cover zero-balance reopening, confirmation-to-hardware navigation, rejected fee/wallet edits, normal-state reset, one-time/recurring dismissal availability, and authenticated same-byte retry. Changed-line static checks are clean. A recurring test fixture assertion was corrected before its focused rerun.
  • Device retry after reopening, funded Shop checkout, fresh-device wallet/VSS restore and physical hardware acceptance remain unrun. Hosted CI and human review must apply to this head.

@ovitrif ovitrif self-assigned this Sep 30, 2026

@github-advanced-security github-advanced-security AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

detekt found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

@ovitrif
ovitrif marked this pull request as ready for review September 30, 2026 01:17
@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Regtest APK

Built from 6b000fe (run).

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

@greptile-apps

This comment has been minimized.

greptile-apps[bot]

This comment was marked as resolved.

@ovitrif

ovitrif commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

I pushed 6966111 with the scoped review fixes: original-send follow-up, exact-observation acknowledgement, running-process Accepted persistence repair, verified Shop proof delivery after guard replacement, and original transfer accounting. The visibility component test now states its actual coverage.

The source batch passed 71 focused tests. Process loss before Accepted is durable still leaves the attempt guarded. This PR remains draft while corrected node artifacts, consumer validation and current native journeys are pending.

@ovitrif

ovitrif commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

I pushed ae0be37 with the hardware Shop proof correction. A Core txid is retained only as the original lookup candidate; proof and Success require fresh observation of that exact outgoing transaction in the original wallet. Identity/request/wallet context is captured, preparation failures block dispatch, and pending proof work cannot start another payment.

The frozen batch passed 58 focused tests, including the guard, wrong-identity, inbound/mismatched lookup and original-context regressions. The hardware journey spec is parsed but unrun; physical hardware and native fault fixtures remain explicit QA gaps.

This remains draft while both apps validate the published rc68 dependency and current native fixed/Max journeys. Prior rc67 media is labelled historical.

@ovitrif

ovitrif commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

I pushed 9de645a with the final hardware Shop guards and the production rc68 dependency pin. A candidate-save failure or a Core exception with no returned txid keeps the started request protected after dismissal/reopening. Shop Sent activity now requires fresh exact outgoing observation and durable local follow-up in the original wallet.

The affected run passed 95 tests and app/test APK builds. The actual installed APK embeds the published rc68 native bytes; funded fixed 1,000-sat and Max 98,749-sat native sends passed, and both exact UI transaction IDs were independently observed on the backend. Current Preview replaces the historical accepted-send media.

Controlled native refusal/response-loss and hardware Shop execution remain unrun; their QA entries remain explicit. No recovery scope was added.

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two HIGH, one MEDIUM and two LOW inline. I reviewed this as a funds change. The refusal lockout and the legacy-proof gap are shared with synonymdev/bitkit-ios#844.

Checked and clean:

  • No failure is reported for a tx that broadcast: NodeException/NodeNotSetup release the guard only before dispatch in rc.68, and everything else keeps the guard.
  • Success is shown only on Accepted. The replays at :4018/:4172 are for the same request with its original txid. HW Shop needs a fresh exact observation.
  • admit blocks the same requestId/orderId, and Lightning proof association is refused while an on-chain attempt exists.
  • Pending is persisted before dispatch.
  • Old backups decode with the new field defaulting to false, and newer backups decode on older builds via ignoreUnknownKeys.
  • The Keychain change is storage-only, with no seed material.
  • The rc.66 → rc.68 bump carries the broadcast-result API.

Pre-existing: RBF/CPFP remain fire-and-forget.

Also LOW: the new toasts at TransferViewModel.kt:394/436 are hardcoded English.

Comment thread app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Comment thread app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt
Comment thread app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt Outdated
Comment thread app/src/main/java/to/bitkit/ui/screens/wallets/send/SendPendingScreen.kt Outdated
Comment thread app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt
@coreyphillips

Copy link
Copy Markdown
Contributor

Two independent reviews.

needs changing before merge

  • Preflight send failures now show the unresolved Pending screen (app/src/main/java/to/bitkit/repositories/LightningRepo.kt:1487). If sendOnChain fails before admission (sync, fee rate, coin selection or node not running), the app now shows the on-chain Pending screen instead of the error, even though no transaction or guard exists. AppViewModel.handleOnchainPaymentFailure treats every error except OnchainSendNotDispatchedError as unresolved and calls showUnresolvedOnchainSend. LightningRepo.sendOnChain only wraps failures from admit and the node call. Earlier failures come back raw: - ensureSyncedBeforeSend() returns SyncUnhealthyError (LightningRepoTest sendOnChain should fail when sync is unhealthy asserts the raw type). - getFeeRateForSpeed(...).getOrThrow() and determineUtxosToSpend throw through executeOperation unwrapped. - executeWhenNodeRunning returns NodeNotRunningError or NodeRunTimeoutError. The new VM test generic outer error after ordinary send remains unresolved pins that a plain IllegalStateException produces NavigateToPending("", amount, false, isOnchain = true). Net effect: with Electrum unreachable, an ordinary send that master rejected with an error toast now shows a Pending screen. The screen uses the new wallet__send_pending__onchain_description copy ("Bitkit will block another send while this outcome is unresolved") and has no txid, and nothing ever resolves it. That is the reverse of the issue: a payment that was never created looks like it might have been sent. The VM test onchain payment failure before send attempt cancels prepared proof wraps preflight errors in OnchainSendNotDispatchedError, which the repo never does, so the intended contract and the code disagree. Confirmed by tracing: LightningRepo.kt:1487-1505 returns or throws before admit, and AppViewModel.kt:4162 routes anything that is not OnchainSendNotDispatchedError to Pending. Fix: wrap every failure before admit in OnchainSendNotDispatchedError, or classify by "guard was persisted" rather than by error type.
  • Preflight failures are shown as unresolved payments (app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt:4162). sendOnChain returns SyncUnhealthyError at lines 1488 to 1490 before OnchainSendAttemptStore.admit at line 1508. Node-state and fee lookup errors can escape at the same stage. handleOnchainPaymentFailure then classifies every error except OnchainSendNotDispatchedError as unresolved and navigates to Pending. I confirmed this against the existing unhealthy-sync repository test and the exact call path: neither the guard nor native send runs. For Shop payments, this also leaves the consumed private payment state unreleased, so an unpaid request can be stranded instead of retried.

worth doing, does not block

  • Accepted transfer guard can only be cleared by reopening the same order (app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt:375). If transfer bookkeeping fails after LDK accepts a funding transaction, the send guard can only be cleared from the same Blocktank order, so leaving the flow blocks all later on-chain sends. The guard is cleared only by completeAcceptedTransferFollowup, and only TransferViewModel.paySpendingConfirmOrder calls it when previous.orderId == order.id. The onEvent observation path skips transfers (!attempt.isTransfer). The failure case: fundPaidOrder(requireTransferPersisted = true) throws, for example because findLspOrderIdByFundingTxId or createTransfer fails. The user then sees an error with paid = false. The order lives in _spendingUiState. If the user leaves the flow, the VM is cleared or the app restarts, the next attempt uses a new order. The attempt stays Accepted with localFollowupComplete = false, so blocksNextSend rejects every later on-chain send, ordinary ones included. Those show the Pending screen for the old transfer txid. Accepted Shop sends have a similar dependency. completeOnchainPayment returns early when currentIdentity() is null, and reconcile needs a live Pubky session, so signing out of Pubky before the proof persists leaves the guard blocking all sends. The PR says local follow-up resumes "without creating another payment". That holds, but the only resume route for transfers is the original order object. Consider letting reconciliation, such as onEvent or startup, finish accepted transfer attempts from the persisted transferContext/orderId.
  • Shop completion discards activity recovery state (app/src/main/java/to/bitkit/repositories/LightningRepo.kt:1623). completeAcceptedShopFollowup marks the guard complete after proof completion without rerunning or validating finishOnchainSendLocally. Since sendOnChain swallows that function's metadata or activity failure at line 1557, and event recovery excludes Shop attempts at lines 568 to 570, a transient local write failure can be forgotten and the guard overwritten by the next send. The payment remains protected, but missing activity metadata or tags are no longer recoverable from the saved attempt.

nits

  • Accepted ordinary sends write local metadata and activity twice (app/src/main/java/to/bitkit/repositories/LightningRepo.kt:1602). sendOnChain runs finishOnchainSendLocally(recorded) after an accepted outcome, and AppViewModel then calls completeAcceptedOrdinaryFollowup, which runs finishOnchainSendLocally a second time before marking the follow-up complete. createSentOnchainActivityFromSendResult skips existing activity, so this is harmless. Still, every successful send makes a second addPreActivityMetadata write and a second Core read-back. Marking the follow-up complete in sendOnChain when the first finish succeeds would avoid it.

@ovitrif
ovitrif requested a review from piotr-iohk October 9, 2026 11:04
jvsena42
jvsena42 previously approved these changes Oct 9, 2026

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approving at 45358b6 with two LOWs open, to fix here or in a follow-up.

Re-reviewed at 45358b6. The two MEDIUMs from my last review are fixed, and I have no HIGH or MEDIUM left. Two LOWs: one as a reply on the fee-rate thread (the same no-op Retry when a tag is added), one inline. Both are reproduced by unit tests.

Fixed and checked:

  • Refusal string: the parser now unwraps Broadcast failed: Electrum server error: {json} and a nested sendrawtransaction RPC error, reads only the message member and matches an allow-list. Unknown, malformed, non-string and look-alike messages stay guarded, so an uncertain broadcast remains pending.
  • Deadline re-lock: the refusal is kept as a navigation-only hint, persisted, restored after a restart, cleared at the next dispatch marker and excluded from backup. It never touches the durable guard, and no path signs again on Retry.
  • A send released before the dispatch marker is reported as not dispatched; one whose marker was written stays pending. Shop attempts are never released there.
  • Send-all success carries the recorded recipient amount.
  • The 999 sat/vB point raised in the other review is addressed in the latest commit: the ceiling now applies to recovery only.

Read only for the commit after 30504cb; the reproductions ran at 30504cb. CI was still running at this head.

⚠️ Device gate: not run at this head — it needs a hardware Shop payment refused by the server on the Trezor emulator, which I have not set up for this round.

Comment thread app/src/main/java/to/bitkit/ui/screens/wallets/send/HwSendViewModel.kt Outdated
@ovitrif

ovitrif commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed a11b479 to address the two remaining hardware retry findings. Changing display tags now retries the retained signed payment; a failure before the dispatch boundary keeps the prior refusal navigation state. A new dispatch still restores navigation protection, and the original payment guard remains intact.

Focused hardware ViewModel regressions and detekt passed. Device refusal acceptance remains unrun; hosted CI is pending for this head.

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approving at a11b479: both LOWs from my last review are fixed and no thread of mine is open.

  • Tags: a retained attempted Shop payment now matches when only the displayed fee rate or the tags differ, so Retry rebroadcasts the original signed bytes after a tag is added. A changed payment identity or recipient is still rejected, and the new test pins that.
  • Pre-dispatch failure: the failure path only sets the refusal hint and never clears it, and the lock is derived from the retained payment's own state. A Retry that fails at the dispatch marker or in device setup leaves the refused sheet dismissable; a real re-dispatch still clears the hint at the boundary.

Read only for this commit; it matches the fixes I had run with HwSendViewModelTest passing. CI was still running at this head.

⚠️ Device gate: not run — it needs a hardware Shop payment refused by the server on the Trezor emulator, which I have not set up.

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

QA review

Scope: Follow-up review of changes since 30504cb, from the completed baseline review, at a11b479. This pass covered the two later commits: retaining confirmed initial presets above 999 sat/vB, and preserving hardware Shop retry navigation when tags change or a retry fails before dispatch. Unchanged baseline coverage is inherited.

No new actionable code findings.

The earlier retain rejection is fixed on this revision. Initial receipt retention accepts a positive rate through the UInt32 maximum. Recovery preparation and retention stay capped at 999 sat/vB. That matches prepareOnchainSend and the iOS split checked at ace6252 on bitkit-ios#844.

A Shop payment that has already attempted broadcast rebroadcasts the original signed transaction when only the displayed fee or tags change. Payer, request, recipient, amount, and deadline still have to match. A retry that fails before dispatch leaves the refused sheet dismissable. Entering dispatch clears the refusal hint on the stored proof. On iOS at ace6252, a changed displayed fee remains a different in-progress operation; the baseline review already recorded that difference.

Validation: the new tests in LightningServiceTest, OnchainSendCoordinatorTest, and HwSendViewModelTest were inspected and not executed in this review. On this revision, build, detekt, and lint succeeded. The author reports focused local regressions passed.

Device testing: not performed in this review. Funded checks of presets above 999 sat/vB, and hardware refusal dismissal, remain unrun.

Ready for device testing.

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Device gate run at a11b479, and it changes my review: one MEDIUM, as a reply on the refusal thread. A real server refusal is not recognised, because this Electrum server wraps it differently from what the parser expects, so the sheet stays locked. My earlier approval at this head was given on the code read and the unit tests.

Device gate: a11b479 — Pixel 9 emulator with the Trezor emulator over the Bridge, a second emulator as the linked issuer.

shop-onchain-proof.xml, normal path: passed.

  • A 100,000 sat request opened on confirmation with My Trezor selected. The sign screen and the Trezor showed the issuer's address, 0.001 and a 141 sat fee.
  • One approval on the device ended on SendSuccess. The backend has one transaction, 04fac3d5…a44a, spending a hardware-account input and paying exactly 100,000 sats to the issuer's address. The issuer shows it received, the proof left the pending queue, and the request left the payer's pending list.
  • A second request for 50,000 sats, paid after two attempts that ended on the device (a Bridge timeout and a cancel), also produced one transaction and no duplicate.

Refusal: failed, see the thread reply.

  • Retry after the refusal did not prompt the Trezor again, so the original signed bytes were reused.

⚠️ Not checked: whether another hardware send is blocked while that payment is retained, and the issuer-side proof contents, since the issuer here was a second Bitkit and not the fixture issuer.

@ovitrif

ovitrif commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 6558827 to recognize the reported Electrum JSON-string and coded RPC -25/-26 refusals. Complete replacement-fee refusals now allow leaving the hardware sheet while preserving the original signed payment and durable guard; unknown or malformed responses remain guarded.

Focused parser and retained-payment navigation regressions passed. The reported restart case where the retained request appears Accepted without a Retry action still needs its original-payment reopen route fixed, so I am keeping the device finding and changes request open. Current-head CI/review and funded Shop acceptance remain pending.

@ovitrif
ovitrif requested a review from piotr-iohk October 9, 2026 13:03
@ovitrif

ovitrif commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 37e4405 to restore the original refused hardware payment after restart. The exact request remains accessible and reopening selects its original wallet, recipient, amount, fee and signed transaction without preparing another payment or resolving a replacement merchant endpoint. Explicit authorization and the durable wallet/payment guards stay enforced.

Focused reload/reopen, receipt-retention and authenticated retry regressions passed; introduced-line static findings are cleared. This completes the reported parser/restart-route code fixes. Actual hardware restart, funded Shop and fresh-device wallet/VSS acceptance remain unrun; current-head CI and human review are pending.

@ovitrif
ovitrif dismissed jvsena42’s stale review October 9, 2026 13:31

The reported refusal parser and restart reopen route are addressed in65588274c and37e4405b8 with focused regressions; the device finding thread is resolved. Dismissing the superseded verdict; current-head human review and actual device acceptance remain pending.

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Re-reviewed at 37e4405, with a device run on a Pixel 9 emulator and the Trezor emulator. One MEDIUM and two LOWs, all inline. The MEDIUM is reproduced by a unit test and on the device; the LOWs on the device.

Device gate: 37e4405 — failed on the reopen step.

  • Refusal, passed. A 30,000 sat request was signed, the hardware-account coin was spent by another wallet first, and the server answered sendrawtransaction RPC error -26: insufficient fee, rejecting replacement …. The button turned to Retry, Back returned to confirmation and swipe-down closed the sheet. The parser fix works with the real server string.
  • Restart, passed. After a mined block and a force-stop the request is listed again with Pay and Dismiss, and Pay opens the confirmation with the original wallet and fee.
  • Retry after reopen, failed. Swipe To Pay shows the mismatch toast and never reaches the sign screen. See the inline comment.
  • Normal path, passed at a11b479, not re-run at this head.

Checked by reading and clean:

  • Parser: still an allow-list. Both real server strings match; other codes, longer messages, malformed payloads and connectivity errors stay guarded.
  • Reopen uses the address, amount, fee and wallet from the retained proof, bound to the same identity and request. No path signs again, and a Lightning or software payment cannot reach it.
  • Back and swipe-down on the reopened sheet leave the proof and the request untouched.

⚠️ Not tested: a retry that reaches the server after a reopen, since the reopen step fails before it.

Comment thread app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Comment thread app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
@ovitrif

ovitrif commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed b33dc94 to address the retained hardware request findings. Reopening now transfers the original request context before showing confirmation, so Swipe To Pay reaches the hardware retry route. The original signed fee and wallet cannot be changed, and Dismiss is disabled while that payment remains in flight.

Focused JVM regressions and changed-line static checks cover the corrected route and controls. The original signed bytes, payer, request, amount and authenticated retry guard are preserved. Device retry after reopening, funded Shop checkout and fresh-device wallet/VSS acceptance remain unrun; current-head CI and human review remain pending.

@ovitrif
ovitrif dismissed jvsena42’s stale review October 9, 2026 15:08

Addressed the three retained-payment findings in b33dc94: original request context survives reopening through confirmation, signed fee/wallet controls are fixed, and in-flight Dismiss is disabled. Focused regressions verified; dismissing this superseded changes request for review of the updated head.

jvsena42
jvsena42 previously approved these changes Oct 9, 2026

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approving at b33dc94: the three findings from my last review are fixed, confirmed on the device, and I have no new finding in the commit.

Device gate: b33dc94 — passed, Pixel 9 emulator with the Trezor emulator. The refused 30,000 sat payment retained from the run at 37e4405 was still on the device.

  • Payment Requests lists it with Pay enabled and Dismiss greyed out.
  • Pay reopens the confirmation with My Trezor and Custom (₿ 141). Tapping the fee row and the funding-source button changes nothing.
  • Swipe To Pay now reaches the sign screen with no mismatch toast.
  • Retry did not prompt the Trezor, so the original bytes were reused. The server answered sendrawtransaction RPC error -25: bad-txns-inputs-missingorspent, and Back and system Back left the sheet. Nothing reached the issuer's address.

Read of the commit, clean: the preparation is handed over before the sheet is shown, so the request context survives; the reopened flow cannot reach a new preparation or a second signature; the retained-confirmation flag is reset on every normal Send entry; Dismiss is disabled only for requests with a payment in flight.

⚠️ Not re-run at this head: the normal payment path (passed at a11b479) and a fresh refusal (passed at 37e4405). ⚠️ Not tested: a retry that the server accepts after a reopen, which needs a refusal that later clears.

For a follow-up, not a finding here: iOS now reopens a refused hardware payment for a subscription period (synonymdev/bitkit-ios#844), while on Android such a payment stays guarded and unreachable after a restart.

@ovitrif

ovitrif commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Published feb7b16: merged master b2d116d and resolved the contact-sync test conflict, preserving the incoming sign-in/membership checks and the existing retained-payment regressions. The merge inherits the reviewed Paykit 0.1.0-rc72 update; native LDK stays at 0.7.0-rc.71.

Local verification covers the affected contact/link/reservation, payment proof/restore, authenticated hardware retry, migration and wallet-start behavior against the published SDK artifacts. The merged source introduced no conflict-resolution static finding. The PR description now distinguishes this integration from historical validation.

Current-head CI and human review remain required. Funded Shop checkout, fresh-device wallet/VSS restore and physical hardware acceptance remain unrun; the restore acceptance conversation stays open.

@ovitrif

ovitrif commented Oct 10, 2026

Copy link
Copy Markdown
Collaborator Author

Published aa33095: adds the local-only Paykit fallback journey and setup recipe. Remote staging stays the default; fallback is conditional on a recorded pairing failure. The docs distinguish rc11 server/rc72 client matching from actual pairing, payment observation and Shop order completion. XML and configuration references checked; the journey remains unrun. No application code changed.

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

QA review

Scope: Full reassessment of aa330959d against merge base b2d116d90 because the comparison base changed and no usable baseline was supplied. Covered send/funding outcomes, durable guards and retries, Shop/hardware proof recovery, backup/restore, affected callers, tests and journeys. Compared affected contracts with iOS bb02e835; this was not a full iOS review.

2 actionable findings — resolve or provide an evidence-backed rebuttal.

Validation: isolated Kotlin 2.3.20 checks confirmed both fixture defects using the callback contract and unchanged attempt-store source; signed-transaction golden fixtures also passed. App JVM tests did not execute because GitHub Packages returned HTTP 401. The current-head build does not compile the instrumentation APK, so it does not clear these findings. After correction, compile that APK and run the observation fixture.

The author reports current-head fresh-device pending-proof restore; that evidence covers original proof recovery, not active-guard crash windows. Device testing: not performed in this review. Funded fault/retry, active-guard VSS restore and physical hardware acceptance were not executed here; visual parity was not established.

Findings

  • [MEDIUM] Supply the third retry callback parameter in the instrumentation test — inline at app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingObservationDeviceTest.kt:127.
  • [MEDIUM] Seed an unresolved attempt before waiting for Pending recovery — inline at app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendPendingObservationDeviceTest.kt:100.

@ovitrif
ovitrif requested a review from piotr-iohk October 10, 2026 08:53
@ovitrif

ovitrif commented Oct 10, 2026

Copy link
Copy Markdown
Collaborator Author

I pushed 6b000fe to address the two pending-observation test findings: the retry callback now matches its three-argument interface, and the test seeds an unresolved receipt through its injected storage seam, then restores the exact original state. Production code is unchanged.

Local verification: Android test APK compilation, focused attempt-store JVM tests and detekt. Device instrumentation was not run.

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

QA review

Scope: Follow-up review of changes since aa330959d, from the completed baseline review, at 6b000fe. This pass covers the two recovery-test updates and the restore/observation paths they exercise. Inherited baseline coverage remains for durable admission, send and funding UI, Shop proofs, backup and restore, the unchanged tests, journeys and Paykit recipe, iOS parity, and the pinned native and e2e contracts.

No new actionable code findings.

The retry-callback and unresolved-seed comments are addressed in this commit. retryOriginal now takes the attempt, fee rate, and prepared-fee approval callback, matching SendPendingScreen. The fixture writes the backup round-trip with Unknown evidence through the test keychain, asserts that the loaded attempt is unresolved, has no positive evidence, and has incomplete local follow-up before the screen mounts, then restores the original keychain string. The added unit assertions keep the completed accepted receipt when restoreActive is given Unknown evidence for the same attempt, which matches the same-operation restore branch. Production send, Shop, and backup code is unchanged in this delta.

Validation: the current-head CI build passed, including its testDevDebugUnitTest step. This review did not execute Gradle. PR CI does not assemble or run the Android instrumentation APK; UI Tests run on master or manual dispatch. The author reports that assembleDevDebugAndroidTest succeeds in the resolved threads above. Device testing: not performed in this review.

Recommended before device testing

Run SendPendingObservationDeviceTest.restoredOriginalObservationNavigatesWithoutRetryOrNativeSend on the owned Android fixture with expectedBackendTxid. Expected result: Payment Pending is shown for the seeded unresolved receipt, exact observation completes with the original amount, and the stored attempt is restored to the pre-test receipt.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: prevent false success for rejected on-chain sends

6 participants