Skip to content

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

Open
ovitrif wants to merge 86 commits into
masterfrom
codex/717-explicit-broadcast-outcome
Open

ovitrif wants to merge 86 commits into
masterfrom
codex/717-explicit-broadcast-outcome

Conversation

@ovitrif

@ovitrif ovitrif commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #717
Twin: synonymdev/bitkit-android#1384
Refs:

Description

  • Keeps the original unpaid recurring hardware payment accessible after restart with exact payer, period, amount and endpoint checks; retained payments cannot be dismissed or canceled, and the request UI hides Dismiss while the original signed payment remains unresolved.

  • Recognizes actual Electrum refusal envelopes and preserves dismissal after an expired retry, while retaining the signed hardware payment and durable guard.

  • Releases abandoned unsigned Shop preparation on restart only when no signed receipt or dispatch could exist, restoring sends and wallet backups.

  • Persists the actual signed mining fee for the original candidate; a zero saved estimate requires observed winning-fee evidence before local completion.

  • Bounds initial Max funding by the original approved total, including the actual signed mining fee from native rc71; missing or excessive fees fail before candidate retention or broadcast.

  • Shows the original guarded payment when a differently keyed Shop request is blocked, preserving its amount, transaction and request rather than displaying the unsent new request.

  • Serializes ordinary accepted-payment activity completion across awaited fee and storage operations.

  • Preserves the precise recurring billing-period timestamp in an active payment backup by using its matching original proof.

  • Releases definitely unsigned ordinary sends and transfers left by process death before a prepared receipt exists; live preparation, signed candidates and Shop proof guards remain protected.

  • Keeps the original software payment guard if authorization cleanup cannot durably remove its started Shop proof.

    • Queued hardware expiry restores the original private consumption boundary before removing its signed proof, preserving retry ownership if deletion fails.
  • Preserves the prior consumed private payment-list boundary with the original receipt and backup, so cancelling an unsent version cannot reopen older payment details.

  • Keeps the captured private payment-list version in hardware receipts and backups; releases only that exact version before deleting a definitely unsent proof, so interrupted cleanup remains retryable.

  • 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.

  • Retains the original signed hardware Shop payment and request after broadcast failure. A recognized backend refusal permits leaving the sheet; connectivity or unknown outcomes keep navigation guarded. Explicit retry reuses the original signed transaction without another signature.

  • Defers the full wallet backup while an ordinary send or channel funding operation is admitted but unsigned; backups retain the exact signed receipt needed for original-payment recovery.

  • Refreshes the visible original Pending operation from durable acceptance when a completion event is missed; completed results suppress stale Unknown and Retry.

  • Reset funding confirmation’s swipe when its retained Pending sheet opens, without advancing setup or showing a generic error toast.

  • Rejected channel funding opens the exact retained Pending operation directly, with setup incomplete and explicit retry available.

  • 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.

  • Carries the original request deadline into native submission and hardware signing while retaining typed broadcast outcomes and original payer/wallet checks.

  • Opens the exact retained Payment Pending operation when channel funding has an unknown broadcast outcome; confirmation does not advance to setup or leave recovery hidden behind a generic error toast.

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. Current-head human approval 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 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.
  • Restores original Shop and order follow-up after local failures, retains transfer accounting across wallet changes, keeps an earlier ordinary payment separate from a new unsent payment, and preserves explicit verified acceptance in Shop proof backups.
  • 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 payer/request/wallet pending; the pending screen retains that context across profile and wallet changes.
  • Preserves the started hardware Shop guard after missing results or candidate-save failure. A denied authorization before the first native broadcast releases only the exact original payer/request/wallet proof; attempted retries remain guarded.
  • Releases only the exact attempt when its callback fails before native dispatch; a failed guard write remains unresolved. Delayed transaction events skip completed ordinary follow-up; explicit original-result resume reads the saved activity without replaying contact or metadata writes.
  • Serializes proof load/edit/save mutations so Lightning completion and pruning cannot overwrite an on-chain started marker.
  • Restores completed ordinary Pending results by exact original attempt, wallet and transaction ID even when the event arrives before initialization; older and replacement attempts cannot satisfy that context. The visible sheet refreshes from durable operation state when activity changes, with resolution delivered on the main thread.
  • Creates one transfer record for concurrent recovery of the same paid order and preserves its original accounting. Hardware resolution skips default-wallet contact replay after original-wallet follow-up.
  • Uses localization keys and interpolation for the new send, pending and funding messages, with English fallback.
  • Restores delayed hardware Sent activity and retained tags before proof delivery, acknowledges completed local follow-up before SDK delivery, and preserves later contact edits during retries. Reopened Pending resolves from exact original payment evidence after the listener consumes its event.
  • Preserves current contact/payment authorization before software dispatch and each hardware retry while retaining the original payer/request/wallet context and unresolved-send protection.
  • Uses published LDK 0.7.0-rc.71 prepared-send bindings at daeee4d2, including the signed mining-fee receipt. Current simulator compilation and affected service regressions use this package; earlier rc69/rc70 checks below are historical coverage.

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.

  • Includes the active original payment guard in shared wallet backups, checks wallet/network and proof association before restore, and preserves fractional-millisecond timestamps across Android/iOS round trips.
  • Publishes exact accepted-transfer recovery to Pending and reloads it when completion precedes screen initialization.

Out of Scope

  • Localization: non-English values for the new keys remain for translation sync.
  • Geographic policy: accepted funding remains guarded if local tracking is blocked by the existing geographic check; no bypass is added.
  • 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 opted-in private payment state: backward compatibility is excluded from the 2.6.0 release scope.

Design

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

Preview

Historical rc70/rc65 funded simulator run at 182b0a0: the original broadcast was accepted with its acknowledgement withheld. An explicitly PIN-authorized higher-fee retry used the same exact input set and 1,000-sat recipient amount. The open Pending sheet resolved to the accepted successor and enabled Details without reopening; Details showed its actual 626-sat fee.

Controlled rc69 UI fixtures: synthetic unresolved candidate, fee entry and invalid empty input. These are actual simulator UI captures, not funded retry, PIN, backend refusal or response-loss evidence. The fee capture precedes the final wording change adding the sats unit.

Pending (controlled) Retry fee (controlled) Empty fee (controlled)

Historical rc68 candidate before the current feedback batch: fixed 1,000-sat and Manual Max 198,745-sat regtest sends using the actual published and resolved LDK package. Both exact UI transaction IDs matched native successful-broadcast logs and independent backend lookup; Max spends the original fixed-send change output with no change remaining.

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

QA Notes

Merchant acceptance (10 October)

  • Android aa330959d 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 bb02e83 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 remains available for the remaining tests.
  • Android ordinary wallet restore passed on aa330959d: 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 bb02e83: 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 aa330959d (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.
  • 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. Reusable provisioning and the full setup contract are being packaged in feat: run the shop's marketplace on staging with our own paykit server bitkit-docker#23; its unpublished changes are not yet certified by this run.
  • Remaining validation: equivalent full local Shop order-paid acceptance on iOS is running; final reusable setup references and reviewer reproduction instructions remain to be completed. Both platforms’ separate Locks merchant and ordinary pending-proof restore cases are already verified.

Local-only fallback documentation (bb02e83)

  • 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 (0e00f06)

  • Merges master 91fffad and its reviewed Paykit 0.1.0-rc72 update while retaining published LDK 0.7.0-rc.71. The resolution keeps Max drain selection, original signed hardware fees/receipts and the incoming send-context cancellation checks. The lifecycle-test override matches the combined selection API.
  • Local verification: simulator compilation and affected payment, proof, backup, retry, contact and confirmation tests against the published native rc71 and Paykit rc72 packages.
  • Validation limitation: testCustomFeeWalletSwitchSurvivesFeeScreenNavigation fails with a repeated preparation and route mismatch. The same single-case failure reproduces on exact master 91fffad with its own dependencies; it is inherited, not a green result. Whether the cause is the fixture or base behavior remains unresolved. Other selected checks completed without failure.
  • Hosted CI on 0e00f06 completed successfully: unit tests, integration tests and E2E. This does not replace the local validation limitation above.
  • Earlier approval belongs to b24a470; current-head dev review remains pending. Fresh-device wallet/VSS restore, funded Shop merchant acceptance and physical hardware acceptance remain unrun.

Current verification and acceptance

  • Local verification: focused refusal-envelope, expiry-navigation, unsigned Shop restart and signed-fee provenance regressions; simulator app compilation. Original signed receipts and unknown-result guards remain retained.
  • Local verification: simulator app compilation and the affected hardware-signing and attempt-service regressions cover recognized backend refusal navigation, retained original signed bytes, rejected alternate requests, prefixed unknown errors and connectivity guards and deterministic send serialization. Earlier transfer, exact-fee and original-payer replay coverage remains applicable.
  • Journey specifications: common Android/iOS actions and expectations are aligned; platform-specific commands and identifiers remain separate.
  • 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.

Interrupted unsigned preparation

  • Reproduced ordinary-send and transfer admission blocking after restart without any signed receipt.
  • Verified first-load cleanup permits wallet backup and later admission, while live preparation and unsigned Shop proof guards remain blocked; ran the affected send-attempt regressions on an iOS 27 simulator.

Authorization rollback

  • Reproduced cancelled software authorization leaving a started proof without its matching attempt when proof deletion failed.
  • Verified retained original request and guard with no broadcast, successful later proof cleanup, and affected proof, confirmation and send-attempt regressions on an iOS 27 simulator.
  • Funded merchant acceptance remains unrun for this change.

Recovered hardware follow-up

  • Only one invocation owns completion for the original payer/request/hardware wallet while activity restoration is suspended.
  • Publish the original resolution when that invocation saves the local-completion acknowledgement, with one replay if the original payer returns after a profile switch. Failed proof delivery retries without repeating the activity write or completion event.
  • The regression overlaps direct hardware completion and reconciliation, then retries failed proof delivery. Returning-payer replay after restart or proof removal remains under correction. Physical hardware and funded merchant checkout remain unrun.

Hardware cancellation before submission

  • Recheck cancellation and attempt ownership after authorization and signed receipt persistence; an abandoned task cannot submit its transaction or reset a newer send.
  • Clear only its exact retained candidate when cancellation is definite and there was no earlier submission; uncertain prior submissions remain guarded.
  • Keep a cancelled operation registered through receipt persistence and cleanup. The shared proof service owns the hardware wallet before loading a receipt, so a recreated send sheet cannot adopt it while the previous sheet is clearing it. Failed cleanup retains the durable guard; a fresh send can start after successful cleanup.
  • Local verification: hardware coordinator and proof-service simulator checks and application compilation completed. The regression uses two separate coordinators, suspends receipt persistence and cleanup, and verifies that the reopened sheet cannot load or submit the old receipt. Physical hardware and funded merchant checkout remain unrun.

Cancelled original-payment retry

  • Remove only a newly prepared successor when authentication is cancelled or fails, the task is cancelled, or the original wallet/node changes before submission. Keep the original payer/request/order, amount, inputs and previously submitted candidates.
  • Persist cleanup before updating the in-memory receipt. Failed cleanup keeps the original payment guarded; cancellation never unlocks another payment.
  • Local verification: affected on-chain attempt simulator checks and application compilation completed. Regression covers authentication cancellation, wallet change, original received-event completion after reopening, and cleanup-write failure. Funded merchant checkout remains unrun.

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.

Hardware dispatch-state recovery (c742e37)

  • 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 (a302ea2)

  • The original signed hardware transaction, derived transaction ID and fee metadata are saved atomically with the started Shop proof before authorization. Failed persistence leaves the proof unstarted and stops preparation.
  • Restoring the encoded proof backup retains the original receipt. The matching payer/request/wallet/address/amount can retry its exact bytes after authorization, without another hardware signature or preparation; unrelated contexts cannot reuse it.
  • Updated PaykitPaymentProofServiceTests and HwFundingSignerTests; the missing-receipt regression failed before correction. Local verification: affected simulator checks and app build completed. Physical hardware, fresh-wallet Shop VSS and funded merchant acceptance remain unrun.
  • Hardware Shop candidates save the original signed transaction and derived txid with the payer/request/wallet before dispatch. Encoded backup/recreate resumes observation of that exact transaction without a broadcast return value; the regression failed before correction. Receipt-save failure prevents dispatch, malformed receipts fail closed, and definite first queued expiry requires durable removal of only its exact candidate. Affected hardware/proof checks and simulator app build completed at ceb327a.
  • Physical hardware Shop signing → acknowledgement loss → terminate/restore → original transaction observation and merchant proof delivery. Hosted restore coverage does not complete this acceptance check.

Journeys

  • temporary wallet-restore-unfinished-payment.xml — normal fresh-wallet restore reconciles the original pending proof without another payment (verified with the disposable Debug hook).
Temporary wallet restore journey and test hook

In a detached worktree at bb02e835d5b59af26869b8dab52da6dbe8d6ebf2, save the diff below as restore.patch, run git apply --check restore.patch and git apply restore.patch, then build the Bitkit scheme in Debug for an iOS simulator using the pinned Package.resolved. Install that same build on the source and newly created restore simulator. After the test, run git apply -R restore.patch and verify a clean worktree. Keep recovery words private.

Not pushed. Defers proof delivery for one exact request after ordinary checks; observes normal remote backup and restore payload identity.

<journey name="wallet restore unfinished payment">
  <description>Temporary uncommitted journey. On the exact PR head plus the disclosed Debug hook, restore a real accepted payment whose original proof is retained in an ordinary remote wallet backup.</description>
  <actions>
    <action>Record the exact source and hooked binary, preserve the existing payer and seller, and create one new original request</action>
    <action>Set the device-local proof deferral marker to that exact request UUID and record its payer, amount, recipient and funding input</action>
    <action>Pay once through normal review and authentication, then retain the accepted transaction and exact signed bytes</action>
    <action>Verify ordinary remote WALLET and METADATA backups completed with matching put and get hashes and the original pending proof</action>
    <action>Stop the source wallet and restore its same recovery phrase through normal UI on a new simulator with no proof deferral marker</action>
    <action>Verify the restored backup retains the original request and transaction, and normal reconciliation submits that proof</action>
    <action>Verify the original merchant bundle completes and authenticated protected content opens without a second payment or replacement request</action>
    <action>Freeze the patch, deciding logs and identities, then stop owned runtimes and preserve the recovery evidence</action>
  </actions>
</journey>
diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift
index 3998eba2..35cf4efc 100644
--- a/Bitkit/Services/BackupService.swift
+++ b/Bitkit/Services/BackupService.swift
@@ -1,5 +1,8 @@
 import BitkitCore
 import Combine
+#if DEBUG // test hook
+    import CryptoKit // test hook
+#endif // test hook
 import Foundation
 import VssRustClientFfi
 
@@ -233,6 +236,22 @@ class BackupService {
                     try await getBackupDataBytes(category: category)
                 }
                 try await uploadBackup(category.rawValue, data)
+                #if DEBUG // test hook
+                    if category == .wallet || category == .metadata { // test hook
+                        let hookPutHash = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() // test hook
+                        if let hookReadback = try? await VssBackupClient.shared.getObject(key: category.rawValue) { // test hook
+                            let hookGetHash = SHA256.hash(data: hookReadback.value).map { String(format: "%02x", $0) }.joined() // test hook
+                            Logger.info("test hook: VSS readback category=\(category.rawValue) put_sha256=\(hookPutHash) get_sha256=\(hookGetHash) version=\(hookReadback.version)", context: "BackupService") // test hook
+                        } // test hook
+                    } // test hook
+                    if category == .wallet, let hookPayload = try? JSONDecoder().decode(WalletBackupV1.self, from: data) { // test hook
+                        let hookHash = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() // test hook
+                        Logger.info("test hook: wallet upload completed sha256=\(hookHash) pending=\(hookPayload.paykitPaymentState?.pendingProofs.count ?? 0)", context: "BackupService") // test hook
+                        for hookProof in hookPayload.paykitPaymentState?.pendingProofs ?? [] { // test hook
+                            Logger.info("test hook: uploaded proof request=\(hookProof.requestId.paymentRequestId) payer=\(hookProof.identity) txid=\(hookProof.paymentIdentifier ?? "none") amount=\(hookProof.onchainAmountSats ?? 0)", context: "BackupService") // test hook
+                        } // test hook
+                    } // test hook
+                #endif // test hook
 
                 updateBackupStatus(category: category) { status in
                     BackupItemStatus(
@@ -335,6 +354,13 @@ class BackupService {
             try await performRestore(category: .wallet, retainedData: retainedWalletBackup) { dataBytes in
                 try walletRestoreGate.retain(dataBytes)
                 let payload = try JSONDecoder().decode(WalletBackupV1.self, from: dataBytes)
+                #if DEBUG // test hook
+                    let hookHash = SHA256.hash(data: dataBytes).map { String(format: "%02x", $0) }.joined() // test hook
+                    Logger.info("test hook: wallet restore read sha256=\(hookHash) pending=\(payload.paykitPaymentState?.pendingProofs.count ?? 0)", context: "BackupService") // test hook
+                    for hookProof in payload.paykitPaymentState?.pendingProofs ?? [] { // test hook
+                        Logger.info("test hook: restored proof request=\(hookProof.requestId.paymentRequestId) payer=\(hookProof.identity) txid=\(hookProof.paymentIdentifier ?? "none") amount=\(hookProof.onchainAmountSats ?? 0)", context: "BackupService") // test hook
+                    } // test hook
+                #endif // test hook
                 if let paymentState = payload.paykitPaymentState {
                     // VssBackupClient's existing custom backup namespace is explicitly wallet index 0.
                     let backupWallet = try await PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet.backupNamespace(index: 0)
diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift
index 50370686..32d96420 100644
--- a/Bitkit/Services/PaykitPaymentProofService.swift
+++ b/Bitkit/Services/PaykitPaymentProofService.swift
@@ -1464,6 +1464,13 @@ actor PaykitPaymentProofService {
             })
 
             if !isAlreadyQueued {
+                #if DEBUG // test hook
+                    let hookMarker = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("shop05-hold-proof") // test hook
+                    if let hookRequest = try? String(contentsOf: hookMarker, encoding: .utf8), hookRequest.trimmingCharacters(in: .whitespacesAndNewlines) == pendingProof.requestId.paymentRequestId { // test hook
+                        logInfo("test hook: proof delivery deferred request=\(pendingProof.requestId.paymentRequestId) payer=\(pendingProof.identity) txid=\(pendingProof.paymentIdentifier ?? "none") amount=\(pendingProof.onchainAmountSats ?? 0)") // test hook
+                        return false // test hook
+                    } // test hook
+                #endif // test hook
                 _ = try await sdk.submitPaymentProof(
                     counterparty: pendingProof.requestId.counterparty,
                     paymentRequestId: pendingProof.requestId.paymentRequestId,
diff --git a/Bitkit/Views/Scanner/ScannerSheet.swift b/Bitkit/Views/Scanner/ScannerSheet.swift
index 14731bea..e9baef13 100644
--- a/Bitkit/Views/Scanner/ScannerSheet.swift
+++ b/Bitkit/Views/Scanner/ScannerSheet.swift
@@ -47,7 +47,8 @@ struct ScannerSheet: View {
                         await scanner.handlePaste(context: .main)
                     }
 
-                    if Env.isE2E {
+                    #if DEBUG // test hook: expose existing manual QR input on normal network-backed Debug builds
+                    if true { // test hook: input staging only; the existing parser and authorization remain unchanged
                         CustomButton(
                             title: "Enter QRCode String",
                             variant: .secondary,
@@ -59,6 +60,7 @@ struct ScannerSheet: View {
                         .padding(.top, 12)
                         .accessibilityIdentifier("ScanPrompt")
                     }
+                    #endif // test hook
                 }
             }
             .navigationBarHidden(false)
SOURCE BEFORE STOP
INFOℹ️: test hook: proof delivery deferred request=7c73247a-47aa-47c2-8486-3c5fce98ddd6 payer=pubkyptc78cux3cq541ypbnf5bw4ginfber6jy6edeja5jhnun7pi9wdo txid=a6e55ae0b09afa69f216d3753d3fd040af5302d5c56ccd23ab7d4ee7a28bfcab amount=1000 - PaykitPaymentProof [PaykitPaymentProofService.swift: init(sdk:store:lightningPaymentLookup:hardwareTransactionLookup:hardwareFollowup:attemptService:logInfo:logWarning:) line: 322]
INFOℹ️: test hook: uploaded proof request=7c73247a-47aa-47c2-8486-3c5fce98ddd6 payer=pubkyptc78cux3cq541ypbnf5bw4ginfber6jy6edeja5jhnun7pi9wdo txid=a6e55ae0b09afa69f216d3753d3fd040af5302d5c56ccd23ab7d4ee7a28bfcab amount=1000 - BackupService [BackupService.swift: triggerBackup(category:) line: 251]
INFOℹ️: test hook: proof delivery deferred request=7c73247a-47aa-47c2-8486-3c5fce98ddd6 payer=pubkyptc78cux3cq541ypbnf5bw4ginfber6jy6edeja5jhnun7pi9wdo txid=a6e55ae0b09afa69f216d3753d3fd040af5302d5c56ccd23ab7d4ee7a28bfcab amount=1000 - PaykitPaymentProof [PaykitPaymentProofService.swift: init(sdk:store:lightningPaymentLookup:hardwareTransactionLookup:hardwareFollowup:attemptService:logInfo:logWarning:) line: 322]
INFOℹ️: test hook: VSS readback category=METADATA put_sha256=ea10c2c37f6d89ebed8fba9c3f8073168cdea34c6234a5259cb86c29b0e8fb82 get_sha256=ea10c2c37f6d89ebed8fba9c3f8073168cdea34c6234a5259cb86c29b0e8fb82 version=1 - BackupService [BackupService.swift: triggerBackup(category:) line: 244]
INFOℹ️: test hook: VSS readback category=WALLET put_sha256=380b66e9b7d9a85c02432ad6a71b7cf175800c5c89aa839c94cae1c0990e29f9 get_sha256=380b66e9b7d9a85c02432ad6a71b7cf175800c5c89aa839c94cae1c0990e29f9 version=1 - BackupService [BackupService.swift: triggerBackup(category:) line: 244]
INFOℹ️: test hook: wallet upload completed sha256=380b66e9b7d9a85c02432ad6a71b7cf175800c5c89aa839c94cae1c0990e29f9 pending=1 - BackupService [BackupService.swift: triggerBackup(category:) line: 249]
INFOℹ️: test hook: uploaded proof request=7c73247a-47aa-47c2-8486-3c5fce98ddd6 payer=pubkyptc78cux3cq541ypbnf5bw4ginfber6jy6edeja5jhnun7pi9wdo txid=a6e55ae0b09afa69f216d3753d3fd040af5302d5c56ccd23ab7d4ee7a28bfcab amount=1000 - BackupService [BackupService.swift: triggerBackup(category:) line: 251]
FRESH NORMAL RESTORE
INFOℹ️: test hook: wallet restore read sha256=380b66e9b7d9a85c02432ad6a71b7cf175800c5c89aa839c94cae1c0990e29f9 pending=1 - BackupService [BackupService.swift: performFullRestoreFromLatestBackup(retainedWalletBackup:) line: 359]
INFOℹ️: test hook: restored proof request=7c73247a-47aa-47c2-8486-3c5fce98ddd6 payer=pubkyptc78cux3cq541ypbnf5bw4ginfber6jy6edeja5jhnun7pi9wdo txid=a6e55ae0b09afa69f216d3753d3fd040af5302d5c56ccd23ab7d4ee7a28bfcab amount=1000 - BackupService [BackupService.swift: performFullRestoreFromLatestBackup(retainedWalletBackup:) line: 361]
INFOℹ️: Full restore success - BackupService [BackupService.swift: performFullRestoreFromLatestBackup() line: 325]
INFOℹ️: Queued a Paykit payment proof for private delivery - PaykitPaymentProof [PaykitPaymentProofService.swift: init(sdk:store:lightningPaymentLookup:hardwareTransactionLookup:hardwareFollowup:attemptService:logInfo:logWarning:) line: 322]
INFOℹ️: Queued a Paykit payment proof for private delivery - PaykitPaymentProof [PaykitPaymentProofService.swift: init(sdk:store:lightningPaymentLookup:hardwareTransactionLookup:hardwareFollowup:attemptService:logInfo:logWarning:) line: 322]

Manual restore steps

Exact head: bb02e83. Not pushed. The attached patch exposes the existing manual scanner prompt in Debug, defers proof delivery for one exact request after normal on-chain acceptance and identity/request checks, and records ordinary wallet backup upload/restore payload identities. It does not change payment, request, wallet restore, or proof decisions.

  1. Use the preserved iOS payer and saved seller. Capture recovery words privately through normal wallet backup UI. Create one NEW merchant bundle/request; preserve its payer, request, invoice, amount, endpoint, funding input and transaction bytes independently of the already completed merchant payment.
  2. Create only the device-local Documents/shop05-hold-proof marker containing the exact request UUID before paying. The Debug hook returns false promptly at the single proof-submission gate for this request, retaining normal pending retry state. Confirm the hook logs the real accepted transaction for the new original request.
  3. Wait for the normal VSS wallet upload to succeed with that pending proof. Retain the payload SHA256 and request/payer/txid/amount observation. Do not fabricate or inject backup state.
  4. Stop the app and shut down the source simulator after verified VSS WALLET and METADATA put/get hash readbacks. Create a NEW simulator, not a reinstall of the old one, to avoid surviving Keychain data. Install the same hook build; do not create the hold marker there.
  5. Restore the same wallet through its normal recovery UI. Verify the ordinary remote wallet backup read has the retained pending request and transaction. The fresh device's unmodified recovery path must reconcile that original proof.
  6. Assert the original invoice matches the original amount, proof is submitted, same Locks bundle completes, and protected content opens. Compare transaction bytes and spent input; assert no second payment or replacement request. Record UI, logs and backend evidence.
  7. Save the patch, logs, build identity and manifest; stop owned devices/helpers and transfer or stop shared services through their current owner. Preserve reusable wallet data. Keep this temporary journey, patch and deciding logs with the test result; do not commit debug behavior.
  • temporary ios-locks-merchant-input.xml — reproduce the mixed Locks purchase using normal authorization and one payment.

    Disposable simulator input and diagnostic hooks; not pushed

    Apply the following diff to bb02e83 in a disposable worktree with git apply --check followed by git apply; build the normal Debug app with Paykit rc72/native rc71. It exposes the existing manual scanner input and logs only deferred-session error classification. Parser, authorization, payment and proof decisions remain unchanged. Do not commit the patch. After testing, reverse it with git apply -R and verify a clean worktree.

    Use staging-invite identities on the staging homeserver and Bitcoin regtest funding. Retain the original grant privately; never publish an invite, mnemonic, client secret or access credential. The server must retain both the driver and Locks signing keys additively. Record the actual Paykit/Locks revisions and URLs; tunnel names change between starts. The reusable setup is being completed in feat: run the shop's marketplace on staging with our own paykit server bitkit-docker#23; this result does not certify its pending full Shop profile.

    <journey name="ios locks merchant input">
      <description>Normal Debug payment with disposable simulator input and diagnostic logging only; standalone mixed Locks acceptance.</description>
      <actions>
        <action>Create a fresh wallet normally and enter the original staging signup grant through the scanner manual input; approve its normal consent</action>
        <action>Save the Ring seller as a contact and fund the buyer's regtest address</action>
        <action>Create one paid Lock and retain its original bundle, invoice, SDK request, seller, payer and amount</action>
        <action>Open the received request and verify seller, 1000 sat amount and fee before one authenticated swipe</action>
        <action>Verify the original invoice is confirmed with amount_matched and the SDK request is proof_submitted</action>
        <action>Verify that same Locks bundle completes and its credential reads the exact original protected content</action>
        <action>Capture the transaction, input/output identity, receipt and logs; stop owned devices and remove the disposable patch</action>
      </actions>
    </journey>

    If saved-session restoration reports SharedStateBusy, preserve the original request and session. In this run, stopping the only payer app for 82 seconds allowed the SDK's 60-second lease to expire; normal relaunch then delivered the original request. This is observed recovery, not proof of the prior lock holder or permission to remove remote locks.

    diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift
    index 305af93c..246e015c 100644
    --- a/Bitkit/Managers/PubkyProfileManager.swift
    +++ b/Bitkit/Managers/PubkyProfileManager.swift
    @@ -1900,6 +1900,17 @@ class PubkyProfileManager: ObservableObject {
                     return .restored(publicKey: publicKey)
                 } catch {
                     if isTemporarySessionRestorationError(error) {
    +                    #if DEBUG // test hook: classify deferred restore without recording credentials or error payloads
    +                    let hookKind: String // test hook
    +                    switch error { // test hook
    +                    case is CancellationError: hookKind = "CancellationError" // test hook
    +                    case PaykitError.ConcurrentUpdate: hookKind = "ConcurrentUpdate" // test hook
    +                    case PaykitError.SharedStateBusy: hookKind = "SharedStateBusy" // test hook
    +                    case PaykitError.Transport: hookKind = "Transport" // test hook
    +                    default: hookKind = "other" // test hook
    +                    } // test hook
    +                    Logger.warn("test hook: deferred session kind=\(hookKind)", context: "PubkyProfileManager") // test hook
    +                    #endif // test hook
                         Logger.warn("Deferred session restoration, keeping saved session", context: "PubkyProfileManager")
                         return .restorationDeferred
                     }
    diff --git a/Bitkit/Views/Scanner/ScannerSheet.swift b/Bitkit/Views/Scanner/ScannerSheet.swift
    index 14731bea..e9baef13 100644
    --- a/Bitkit/Views/Scanner/ScannerSheet.swift
    +++ b/Bitkit/Views/Scanner/ScannerSheet.swift
    @@ -47,7 +47,8 @@ struct ScannerSheet: View {
                             await scanner.handlePaste(context: .main)
                         }
     
    -                    if Env.isE2E {
    +                    #if DEBUG // test hook: expose existing manual QR input on normal network-backed Debug builds
    +                    if true { // test hook: input staging only; the existing parser and authorization remain unchanged
                             CustomButton(
                                 title: "Enter QRCode String",
                                 variant: .secondary,
    @@ -59,6 +60,7 @@ struct ScannerSheet: View {
                             .padding(.top, 12)
                             .accessibilityIdentifier("ScanPrompt")
                         }
    +                    #endif // test hook
                     }
                 }
                 .navigationBarHidden(false)
  • 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 — Includes backup deferral before signing for ordinary sends and channel funding; that new device step remains unrun. 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. Funded rc70/rc65 replay at 182b0a0 used actual PIN and withheld backend acknowledgement, then accepted a higher-fee retry of the original mempool payment with unchanged inputs/amount. Live Pending and winner Details refreshed without reopening. A second actual PIN replay at 182b0a0 rejected the successor through a controlled Electrum fixture and restored independent observation of the accepted original; the open Pending sheet enabled original Details without reopening (1,000 sats, actual143-sat fee). This original was unconfirmed, not mined-confirmed. Funded original-Max channel recovery at 2db78a1 preserved the exact inputs and 18,351-sat funding output through normal PIN authorization, backend acceptance with withheld acknowledgement, restart and eventual channel settlement. That run reproduced a stale open Pending result; a subsequent focused simulator regression verifies the durable refresh correction. Funded replay on cd18c1f with published LDK rc70 and Paykit rc65 accepted the original 29,671-sat funding transaction with its acknowledgement withheld. With Pending left open, independent observation restored the original order/transfer, removed Unknown and Retry, and enabled exact original Details without another submission. Final Shop merchant acceptance remains open.

  • new shop-onchain-proof.xml — linked issuer and funded Bridge hardware wallet: exact original payer, request, transaction, proof and wallet Details, including delayed Sent/contact/tags and reopened Pending after listener consumption, with no repayment while observation is pending. Spec parsed; native hardware journey unrun.

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

Manual Tests

  • Hardware Shop signing → explicit backend broadcast refusal → leave and reopen the sheet → original signed payment and wallet guard remain; authenticated retry reuses the same bytes — controlled broadcast fault outside the listed Capabilities; physical hardware remains unrun.
  • Hardware Shop signing → controlled connectivity failure before transaction observation → Back/cancel stays guarded → explicit retry reuses the same signed transaction and original request. Controlled broadcast fault is outside the listed Capabilities; physical hardware remains unrun.
  • Controlled response loss → actual PIN send → Pending → explicitly PIN-authorize original-payment retry → live Pending resolves without reopening and Details shows the accepted successor's exact 1,000 sats and 626-sat fee — broadcast-response-loss injection not in Capabilities. Ran at 182b0a0 with published native rc70 and Paykit rc65.
  • regression: accepted original with withheld acknowledgement → controlled fixture rejects successor → restore exact original observation while Pending remains open → original Details becomes available without reopening — controlled original/successor timing not in Capabilities. Ran on 182b0a0: same input set/1,000 sats, original fee143; original remained unconfirmed. The successor was not forwarded to the backend.
  • Transfer To Spending → lose the funding acknowledgement after dispatch → exact original order/wallet/transaction opens Payment Pending directly, with no setup success or generic error toast — controlled funding-response loss not in Capabilities. Funded original-Max recovery ran at 2db78a1 with native rc70 and Paykit rc65: a normally PIN-authorized same-input, same-amount retry was accepted with its acknowledgement withheld; restart retained exact Pending. Restored observation saved the original order transfer and the channel settled with 16,106 sats in Spending. The already-open Pending sheet incorrectly retained Unknown/Retry on that head. The corrected cd18c1f replay kept Pending open after acknowledgement loss on an accepted 29,671-sat funding transaction. Restored observation cleared Unknown/Retry and enabled original Details without reopening or another submission; the original order and transfer were saved. This replay did not perform an explicit retry or verify the new channel settlement.
  • regression: exact outgoing transaction resolves before Pending opens → original amount, transaction ID and Details recover without another send — controlled event/follow-up timing not in Capabilities.
  • regression: hardware Shop signing → deny authorization before the first broadcast → exact prepared proof released with no transaction sent; deny an attempted retry → original guard retained — controlled authorization timing during hardware signing not in Capabilities.
  • regression: fail SDK proof delivery or delay a received event → remove/reassign the Sent activity contact → retry/event preserves the edit without another payment — SDK-failure and delayed native-event injection not in Capabilities.
  • Controlled refusal → Send Confirm → Pending → no success/proof or repayment — controlled broadcast-refusal fixture not in Capabilities.
  • Controlled response loss → Send Confirm → Pending → restart retains the original receipt; explicit authenticated retry preserves the recipient amount and exact inputs — broadcast-response-loss fixture not in Capabilities.
  • regression: fail outcome persistence after Accepted → preserve the known result in the running app; restart with unresolved guard → no repayment — storage-fault injection not in Capabilities.
  • regression: interrupt ordinary Accepted local follow-up → restart → Send → finish saved metadata/activity and show the original exact transaction ID without another broadcast — interruption/storage-fault injection after acceptance not in Capabilities.
  • regression: physical Trezor Shop payment → approve transaction → original hardware payment/proof follow-up survives transport changes — physical USB and BLE transport not in Capabilities.

Automated Checks

  • updated HwFundingSignerTests.swift — recognized backend refusal and invalid transaction errors permit leaving while retaining the original payment; prefixed unknown and connectivity failures remain guarded.
  • updated OnchainSendAttemptServiceTests.swift — deterministic first admission and an actual signed fee prove competing fixed, Max and transfer sends cannot dispatch.
  • updated PaykitPaymentStateBackupTests.swift — send-all funding backup round-trips retain the actual signed recipient amount and original order terms; underfunding, amounts above the retained transaction total and fixed-amount mismatch remain rejected.
  • updated PaykitPaymentStateBackupTests.swift and TransferServiceActivityTests.swift — restore requires all replacement fee rates, consistent original fees and the original funding amount; original shared fixture bytes remain unchanged.
  • updated PaykitPaymentProofServiceTests.swift — the hardware restart fixture persists the full signed receipt before dispatch and verifies its fee metadata survives backup restore.
  • updated PaykitPaymentStateBackupTests.swift — incomplete signed hardware receipts fail restore when fee, fee rate or total spent is missing or null; complete receipts retain all fields.
  • updated PaykitPaymentStateBackupTests.swift — accepted replacement restore requires its own candidate fee rate; missing and partial maps fail, while valid replacement rates and the original transaction’s fee fallback remain supported.
  • updated PaykitPaymentProofServiceTests.swift — a proof/attempt capture interrupted by original-attempt cleanup defers the upload instead of backing up an orphaned started proof; a settled empty state can be captured normally. Proof/backup simulator checks and app compilation completed. Funded merchant and fresh-wallet acceptance remain unrun.
  • updated PaykitPaymentProofServiceTests.swift — overlapping recovery reconciliation writes the original accepted Shop activity and publishes its completion once; a suspended first completion cannot be repeated by another call. Proof/attempt simulator checks and app compilation completed. Funded merchant acceptance remains outstanding.
  • updated OnchainSendAttemptServiceTests.swift and TransferServiceActivityTests.swift — unresolved payments with multiple retained candidates ignore unconfirmed received events and choose the confirmed candidate, preserving the original operation, wallet, amount and inputs. Single-candidate received observations and native Accepted results retain their existing completion path. Attempt/transfer simulator checks and app compilation completed; funded replacement and merchant acceptance remain unrun.
  • updated PaykitPaymentProofServiceTests.swift — after proof delivery and a failed local follow-up, restart reconciliation emits the exact original payment resolution once durable completion succeeds. Failed follow-up emits no new event; repeated reconciliation does not replay completion. The missing-event regression failed before correction. Local verification: proof-service simulator tests and app compilation; funded merchant and fresh-wallet Shop acceptance remain unrun.
  • ran HwFundingSignerTests.swift and PaykitPaymentProofServiceTests.swift: failed pre-broadcast candidate persistence permits fresh preparation on retry; software Shop completion publishes its resolution only after durable local follow-up. Accepted proof delivery remains retryable independently. Both reported defects were reproduced before correction; simulator checks and app compilation passed at a4eacf2.
  • updated HwFundingSignerTests.swift — observed original hardware Shop payment unlocks the signing screen and routes to Success; other payer/request/wallet/transaction resolutions are ignored, including a resolution while dispatch is still running. No second signature or broadcast.
  • Hardware Shop broadcast failure keeps the original signed payment guarded across cancellation and retry. The regression failed before correction; focused hardware/proof checks and simulator app build completed. Physical hardware remains unrun.
  • Unsigned ordinary/transfer admissions defer the full wallet snapshot; a prepared Pending receipt round-trips with the original identity, amount, inputs and candidate IDs. Both unsigned regressions failed before the correction; focused attempt-service and Paykit backup tests passed on iOS 27. The signing-pause device journey remains unrun.
  • updated TransferServiceActivityTests.swift — actual visible Pending reloads an accepted original channel-funding operation without a native completion callback, saves its one original transfer/activity and avoids success for a different unsent payment. The regression failed on the preceding source; corrected transfer/Pending simulator checks completed.
  • updated TransferServiceActivityTests.swift at 73d2b35 — unknown order funding opens its retained exact-operation Pending sheet, retains the order and wallet, keeps the guard and avoids transfer setup completion. The previous route failed this regression; corrected source and existing accepted-funding identity/accounting recovery passed.
  • ran TransferServiceActivityTests.swift at 182b0a0 — exact durable resolution before initialization, old/replacement context rejection and original activity without success for a new unsent amount. Simulator build and installed application binary matched the validated source.
  • ran temporary funded outgoing recovery with the production Keychain attempt store at 30a4de1:
    • Real acknowledgement loss retained the original receipt; the shared backup envelope restored its original wallet, amount, inputs and candidate family into Keychain after creating a fresh recovery service.
    • Explicit retry was accepted for the external test recipient. Bounded native sync supplied exact transaction details before durable Sent activity completion with the original 1,000 sats and accepted successor ID.
    • This checks local backup decoding/restoration and service reconstruction; it is not app process-death recovery, remote VSS restore, PIN navigation, hardware or merchant proof/order completion.
  • ran temporary funded production-service/native fixtures at 30a4de1 against published LDK rc70 and Paykit rc65:
    • Real withheld backend acknowledgements retained Unknown and the original receipt. Fixed-amount higher-fee retry was accepted; independent backend decoding confirmed the same input and 1,000-sat output.
    • Max retained 19,888 sats and exact inputs. Higher-fee construction failed before dispatch; original-fee retry accepted the exact original transaction.
    • Uses a temporary in-memory attempt store and authorization callback. PIN navigation, full app process restart/remote VSS restore, physical hardware and merchant checkout remain unvalidated on this head.
  • ran OnchainSendAttemptServiceTests.swift, PaykitPaymentProofServiceTests.swift, SendConfirmationViewTests.swift and the native queue deadline test on iOS 27 / Xcode 27: original retry keeps its request deadline through preparation and dispatch; expiry after authentication prevents another broadcast while retaining the original payment guard. Read-only recovery validation preserves started and unstarted proof state.
  • updated TransferServiceActivityTests.swift, HwFundingSignerTests.swift, PaykitPaymentProofServiceTests.swift — shared-state SDK fixture APIs and captured proof app IDs.
  • ran simulator payment tests against Paykit rc65 and published LDK rc70 at fc2eb30: attempt/retry guards, proof acceptance and identity recovery, backup state, hardware coordinator, transfer/activity follow-up, send confirmation and native-queue deadline checks. This historical test batch did not exercise funded device, physical hardware or Shop checkout journeys; current device evidence is listed above.
  • ran native rc70 SwiftPM resolution and consumer compilation against the published release; the resolved native revision matches its release tag.
  • added OnchainSendAttemptServiceTests.swift — durable admission, outcome persistence, exact observation and callback failure release with zero dispatch; failed release storage remains guarded.
  • updated PaykitPaymentProofServiceTests.swift, PaykitPaymentStateBackupTests.swift — proof acceptance evidence, exact payer/request/wallet cancellation with failed-save protection, request protection and a suspended Lightning save cannot overwrite the concurrent on-chain start marker.
  • updated HwFundingSignerTests.swift, HwWalletManagerTests.swift — original hardware account context, durable proof release after denied first authorization with zero dispatch, retained attempted retries, and verified Shop versus Pending navigation.
  • updated ChannelPurchaseFlow.swift, UtxoSelectionTests.swift — matching outcome API integration.
  • updated TransferServiceActivityTests.swift — original local follow-up and actual reopened hardware Pending navigation; delayed verification restores Core Sent activity/tags before proof delivery. Actual SDK-failure retry and received-callback regressions preserve deleted/reassigned contacts without another native send. Existing accepted-order tests inject the geo decision while production retains the same geographic check.
  • ran published dependency validation — source 773792d, archive SHA-256 1955a179fdb6acc5159ead68225dd27029d2f2a74c4b8b13164038eb9f95462f, and downloaded/SwiftPM simulator binaries agree.

Funding recovery continuation (3000b91)

  • Dismissing Pending and swiping again reopens the exact original funding context before order creation or native dispatch, including when the original order has expired.
  • Positive durable recovery of that same funding attempt resumes original order watching and Lightning setup without restarting the app. Unrelated resolutions and repeated events do not advance it.
  • The dismissed-sheet regression reproduced the previous order recreation. Focused simulator coverage exercises reopening, received-event recovery, unrelated-operation exclusion, duplicate events and accepted local-follow-up repair; application compilation completed.
  • The manual funding journey includes dismissal/reopening and recovered order progression. Funded original-Max retry and channel settlement were exercised at 2db78a1 (see Manual Tests). The stale open Pending result was corrected and verified by a funded cd18c1f replay: accepted original funding, withheld acknowledgement, restored independent observation, and live original Details without reopening or another submission. This is not Shop merchant checkout evidence.

Accepted funding follow-up (2db78a1)

  • Accepted funding with incomplete local transfer/activity persistence stays bound to the original order after expiry. Re-entry attempts local repair of that accepted transaction; failed repair reopens exact Pending instead of creating another order.
  • After repair, original order watching/setup resumes without another broadcast. The expired-order regression failed before correction; accepted repair, rejected/unknown re-entry and exact received-event routing completed in the simulator.
  • Funded original-Max recovery on this head retained the original order across restart and settled its channel. The open Pending sheet remained stale after durable follow-up on that head. A subsequent correction reloads durable completion while visible; funded replay and final Shop merchant checkout are still unrun.
Historical validation before the shared-state Paykit integration

Local verification at bfa9646: simulator build and 46 focused tests passed (0 failures/skips), covering Pending initialization after completed Unknown/Rejected observation, original identity across wallet changes, stale/replacement rejection, local follow-up failure details, concurrent transfer creation and hardware contact replay. Four regression methods failed against the prior relevant behavior and passed with the fixes; initial fixture compilation errors are excluded. All 1,435 tracked source hashes match the tested snapshot and resolved rc68 binary. Changed Swift formatting and translation validation passed (0 errors; translation warnings remain). No new funded, hardware or native-fault journey is claimed.

Earlier verification: 64dea0b passed its simulator build and 122 affected tests (0 failures/skips): hardware signing/authorization, proof protection, on-chain attempt reconciliation and actual Core transfer/activity callbacks. Three regression methods failed with the old cancellation/contact-restoration behavior and passed with the fixes; the first-denial red used extracted production callback wiring retaining the old cancellation behavior, rather than an unmodified-head binary. All 1,397 tracked source hashes and the resolved rc68 simulator binary match the tested snapshot. The local hardware follow-up acknowledgement is not acceptance evidence and is omitted from backups so another device restores its own activity. Standard Debug geographic checks remain enabled; local-only -packageFingerprintPolicy warn handles the existing VSS fingerprint conflict.

Previous master integration verification at a733791: master 62563af was integrated without manual edits after committing the frozen d623a1a integration as 3cf7b91. The exact current source passed its simulator build and 315 affected tests (0 failures/skips): hardware signing, proof protection, attempt reconciliation, transfer/activity callbacks, send confirmation, request authorization and private payment integration. All 1,435 tracked source hashes and the resolved rc68 native binary match the tested snapshot. The earlier frozen merge at 3cf7b91 separately passed 301 affected tests. No new funded/hardware/native-fault journey or Preview run is claimed.

Prior merge validation at c339e9e: master 1eabf70 was integrated and the simulator build plus 263 affected tests passed.
Earlier feedback validation at 2039d39: 99 affected tests passed; three semantic regressions failed on its preceding head and passed with those fixes. This is historical evidence, not a fresh red run for the merge.

Earlier CI at 2039d39 passed unit/integration/build checks; the E2E run was cancelled and its aggregate status failed. The cancellation cause remains unknown. No rerun of that unchanged cancelled run was requested; heavy checks were deferred while the PR was draft; earlier CI does not validate this current head.

Prior rc68 validation: 101 affected tests and funded fixed/Manual Max native journeys passed with independently verified exact backend transactions. Those device runs were not repeated for this batch. The updated hardware journey, physical hardware and controlled native fault scenarios remain unrun; no new hardware Preview is claimed. Missing original metadata after a failed write or crash cannot restore lost tags.

Current integration verification at 402d006: simulator build and 267 affected tests passed, including original-wallet hardware recovery, broadcast attempt guards, proof recovery, transfer accounting and send confirmation. Initial merge/API compile failures were corrected and are not regression evidence. No funded Shop, physical hardware or controlled native-fault journey was run on this integration. That historical integration retained refusal/unknown lockout; the current prepared-send recovery supersedes it.

Startup correction at 994ab3b: accepted ordinary-send recovery routes Pending with the exact fetched attempt ID, original wallet and transaction ID. Completion before Pending initialization reloads the original durable result; an older or replacement attempt cannot satisfy it. The old startup route failed the ordering regression (20 passed/1 failed); corrected source passed simulator build and 21 affected tests (0 failures/skips). No second payment was dispatched. The earlier 267-test integration evidence remains applicable to unchanged areas; that historical batch did not validate prepared-send recovery or final release-pair/native fault scenarios.

Current recovery verification at 498fdeb: merged master f4710eb, resolved published LDK 0.7.0-rc.70 at 4ef96f9, built the simulator app and passed 69 focused tests (0 failures/skips). Coverage includes receipt persistence before dispatch, fixed original amount/exact inputs, serialized retries and accepted-original races, fresh payer/request/order authorization, proof successor association and received-event reconciliation. All 1,480 tracked source hashes were checked; the final English-only amount-unit wording change compiled separately. The actual published native entrypoints were exercised with a stopped node and returned NotRunning; this proves binding/runtime compatibility, not funded broadcasting.

Published archive SHA-256: bd5ddd188adffbd86cc1f9f384b823450dfb865b423b2e7a5c9c7adddde2b8ce; the resolved simulator binary and generated Swift hashes match the validated artifact. Paykit remains 0.1.0-rc56 and Core remains 0.5.18. Owned test resources were stopped and cleaned up.

Funded retry/PIN and controlled acknowledgement loss were subsequently exercised on 7632642 as detailed below. Initial native refusal, live Shop/server pairing and physical hardware remain unrun. Draft status was preserved at that validation point pending these release checks and current-head review; previous CI and historical funded media do not certify this head.

Backup and transfer Pending verification at 7632642: simulator build and 77 focused tests passed (0 failures/skips). The published reader failed the fractional-millisecond restore regression; earlier published-source regressions also reproduced the missing backup receipt and transfer resolution. Shared wallet binding vectors, guard-before-proof restore, exact candidate proof association, original transfer resolution and cross-platform timestamp preservation passed. The committed tree matches the tested snapshot. Funded iOS retry, native fault, physical hardware and final chosen Paykit/server Shop checkout remain required; hosted draft checks are not substituted for these tests.

Published 2bccf53: send-all retries are restricted to the original fee before transaction preparation or authentication. Pending explains the constraint and disables fee editing for Max; fixed-amount retries retain fee selection. Recipient amount and exact inputs remain unchanged.

Validation: the previous service failed the Max-fee regression; 33 focused tests passed and the current Pending UI compiled against published rc69.

Funded validation on preceding 7632642: fixed 1,000 sats and Max 18,745 sats exercised real PIN entry and controlled loss of backend acknowledgement. Both original transactions reconciled to their exact Details with original inputs and amounts. A fixed successor met an already-confirmed original; Max higher-fee construction failed safely before dispatch. Successful unconfirmed replacement, initial native refusal, physical hardware and final selected-version Shop checkout remain unproven. These device runs precede the new Max UI constraint. Draft status remains unchanged.

Published 95f7566: successor Details and transfer accounting use the exact winning transaction fee and its own authorized fee rate. Missing successor fee evidence keeps local completion pending. Candidate rates are saved before dispatch and validated in the shared backup format; the original operation and flat candidate IDs remain intact. Max confirmation now has no editable fee field.

Validation: 83 affected tests passed and the simulator application built against published rc69. Prior behavior failed actual fee/rate assertions; exact fee arithmetic, winning-candidate metadata, Core activity updates and shared backup golden passed. Current Max UI was driven: zero fee text fields, original receipt retained and zero native dispatches. Funded winning-Details rerun, true original-in-mempool replacement, physical hardware and final selected-version merchant checkout remain outstanding.

Earlier 2bccf53 device run exercised actual native Rejected through an injected Electrum non-final response, without forwarding the original. One explicit PIN retry was accepted by staging while unconfirmed, with the same input and unchanged 1,000 sats. This proves same-input retry acceptance after controlled refusal, not replacement of an original already in the mempool. The run reproduced the fee Details defect corrected above.

Published b229508: Post-broadcast attempt-store read failures now retain the captured native result and exact Pending context for both first submission and explicit recovery. The durable receipt stays guarded across restart.

Validation: 38 attempt-service tests passed (0 failures/skips). The new post-dispatch read-failure regression failed before the fix and passed for initial and recovery dispatch; the owned simulator was shut down. Funded winning-Details and final selected-version Shop checkout remain outstanding.

Private hardware cleanup (f2f14d0)

  • Local verification: iOS 27 simulator build and affected request-cleanup, proof and payment-backup regression checks.
    • Reproduced a private-service restart deleting an unsent signed proof while leaving its exact version consumed.
    • Verified encoded backup restoration retains the version, failed proof deletion retains the original receipt for idempotent retry, and cleanup cannot release a newer consumed version.
    • Existing attempted-broadcast retention checks remain covered.
  • Funded merchant checkout, fresh-wallet Shop VSS and physical hardware acceptance remain unrun for this head.

Prior private payment boundary (7deedcd)

  • Local verification: iOS 27 simulator build and affected request-cleanup, proof and payment-backup checks.
    • 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 (cbe12e9)

  • ran queued hardware expiry after private-service restart and payment proof regressions on a standalone simulator; built the app.
  • reproduced the previous failure: clearing unsent version 7 reset the boundary to nil rather than restoring version 6.
  • verified retained signed proof after failed deletion, idempotent cleanup retry and protection of newer consumption.
  • fresh-wallet Shop VSS, physical hardware and funded merchant checkout acceptance remain unrun.

Retained private payment version (309e5c9)

  • updated PaykitPaymentRequestServiceTests.swift — failed signed-proof deletion keeps version 7 consumed, restart repairs the release/deletion gap before retry, successful cancellation restores version 6, and newer consumption remains protected.
  • Local verification: affected proof and private-service restart checks on an iOS 27 simulator; app compilation.
  • Fresh-wallet Shop VSS, physical hardware and funded merchant checkout remain unrun for this head.

Accepted payment completion (40e97a2)

  • updated OnchainSendAttemptServiceTests.swift — accepted ordinary and Shop payments retain their exact original operation, wallet and transaction on Pending until proof/local follow-up completes; completed payments still reach Sent.
  • Local verification: attempt-service and confirmation regressions on an iOS 27 simulator; app compilation. The incomplete-acceptance route failed before correction.
  • Funded merchant checkout and fresh-wallet Shop restoration remain unrun for this head.

Recurring payment backup identity

  • updated PaykitPaymentStateBackupTests.swift: exact millisecond and nanosecond billing timestamps survive active-attempt backup and match the retained proof.
  • ran focused backup regressions and app compilation on the corrected source.

Ordinary activity completion

  • updated OnchainSendAttemptServiceTests.swift: a competing call cannot repeat a suspended activity write, and a failed owner permits a later retry.

Prepared funding total correction

  • The app resolves published rc71 and reports the exact signed mining fee before dispatch.
  • The Max-funding regression reproduced over-total acceptance; it now checks the exact approved total, missing and excessive fees, overflow, and original order minimum.
  • The affected attempt-service tests and simulator app build were checked against the published rc71 package. This adds no funded merchant acceptance claim.

Recurring payment recovery

  • Reconstructs only the original unpaid billing-period request from an active payer subscription, retaining exact period timestamps and original amount and endpoint. Settled periods, canceled subscriptions and changed endpoints remain blocked.
  • Added authorization regressions for the original recurring period and rejected variants; exercised related proof-service regressions. The later-payment preservation fixture now uses a submitted unresolved payment.
  • Native funded recurring-payment retry remains unrun.

Initial queued expiry

  • The native adapter identifies failures before its broadcast call is entered.
  • An unsent initial Shop payment releases its wallet guard only after durable rollback of the exact original proof; failed cleanup preserves both.
  • Initial queued expiry, restart admission and proof-cleanup failure have focused regression coverage. Previously submitted payments remain guarded.

Recovery feedback updates

  • A retry proven never submitted removes only its newly added candidate, so an unconfirmed original can complete; earlier submitted candidates and any uncertain dispatch remain guarded. Starting a new funding estimate routes the retained original operation before creating another order, including hardware confirmation. The Max recovery fixture now supplies its actual signed fee and recipient amount.
  • Original-payment retries validate the normal custom-fee ceiling, confirm the exact prepared mining fee and normal fee warnings before PIN or biometrics, and submit that same prepared object. Cancelled approval retains only the earlier candidates. Shared journey names/actions now match Android, with necessary iOS Max, channel-funding and Manual coin-selection adaptations recorded in descriptions and the README.
  • Hardware completion keeps its original proof durably until the original payer consumes the local result. Proof delivery cannot remove that pending acknowledgement, and restart reconciliation replays the exact original transaction without another payment or repeated local follow-up.
  • Wallet snapshots defer while a native send or retry operation is in progress. An undispatched retry candidate cannot be uploaded during authentication; cancellation still removes that candidate while preserving original payment recovery.
  • Cancelled hardware authorization retains its original operation until definitely unsent proof cleanup finishes, preventing a newer send from adopting it. Hardware recovery Success consumes stale contact context instead of reapplying the original counterparty over a user edit. Updated against master72507dc9, preserving the prepared-receipt API and exact original transaction recovery contract.
  • Reopened Shop recovery uses the exact durable original payment authorization instead of a lost process-local approval. Payer, request, proof, deadline and blocked-peer checks remain enforced; recovery grants no approval for a new payment.

Hardware refusal navigation after restart

  • Retains a device-local refusal navigation hint with the original signed Shop payment. Relaunch and fee refresh preserve Back/dismissal if Retry fails before dispatch.
  • Clears the hint during dispatch preparation and restores it if the native call is proven not entered; a fresh uncertain result remains guarded. Wallet backups omit the hint, so recovery restores conservative navigation.
  • Local verification: focused HwFundingSignerTests and PaykitPaymentProofServiceTests passed, covering restore, fee refresh, pre-dispatch denial, queued expiry, original signed bytes and backup exclusion.
  • Hardware/device refusal acceptance remains unrun. Hosted CI and human review are pending for this head.

Hardware Shop deadline at dispatch (cbe332e)

  • Rechecks deadline and cancellation after awaited preparation and before native broadcast. A deadline that elapses during identity/proof storage cannot submit an expired request.
  • Positive pre-dispatch failure evidence restores prior refusal navigation while retaining original signed bytes and the durable payment guard.
  • Focused expiry/cancellation, refusal navigation and original-byte regressions were checked on the frozen source. Funded Shop, fresh-device wallet/VSS and physical hardware acceptance remain unrun; current-head hosted CI and human review are pending.

Reported Electrum refusal formats (ec99bd0)

  • Accepts supported JSON-string and object-message envelopes and exact RPC -25/-26 prefixes. Recognized missing-input and replacement-fee refusals allow leaving the hardware sheet while retaining the original signed payment and durable guard.
  • Replacement-fee recognition requires the complete transaction ID and fee-rate format; unknown, malformed and lookalike responses remain guarded.
  • Local verification: focused refusal/navigation, original signed-payment retention and restart reconciliation regressions on the corrected source. Actual iOS device refusal, funded Shop and fresh-device wallet/VSS acceptance remain unrun; current-head hosted CI and human review are pending.

Retained hardware request reopen (1067305)

  • Keeps the exact original refused hardware request accessible after restart. Reopening selects the original hardware wallet, recipient, amount and retained signed transaction, without preparing fresh inputs or resolving another merchant endpoint.
  • Checks request identity, amount, billing period and endpoint; completed/unknown/changed requests remain guarded. Retained retries do not auto-open or auto-pay; explicit authorization still precedes submission.
  • Shows the original signed fee and actual wallet balance. Pending proof and wallet guards remain intact.
  • Local verification: focused request reload/reopen, original launch context, receipt and changed-term guard regressions passed on the corrected source. Actual hardware restart and funded Shop/fresh-wallet acceptance remain unrun; current-head CI and human review are pending.

Maximum sends and retained hardware fees (b24a470)

  • Software-wallet Max uses spendable drain inputs without applying fixed-payment selection overhead to the already calculated maximum. Fixed-amount sends retain the selected algorithm and existing payment preparation and reserve guards.
  • Retained hardware confirmation displays the signed fee rate and blocks fee edits. Repeated authenticated retries use that rate and the original signed bytes even if the wallet preset changes; no new preparation or signature occurs.
  • Focused selection-boundary and retained-fee/retry regressions failed before the fix and passed on the corrected source. The Max fixture models the reported 60,000/40,000 sat case; it is not a funded UI reproduction. Funded Shop, fresh-device wallet/VSS and physical hardware acceptance remain unrun. Current-head CI and human review are pending.

@ovitrif ovitrif self-assigned this Sep 30, 2026
@ovitrif
ovitrif marked this pull request as ready for review September 30, 2026 01:36
@ovitrif
ovitrif marked this pull request as draft September 30, 2026 01:40
@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 e447862 with original Shop and order follow-up recovery, exact native observation acknowledgement, earlier-payment isolation, shared verified proof metadata and original transfer accounting across wallet changes. The focused simulator build and 90 tests passed.

This addresses accepted payments becoming stuck after local proof/activity/tracking failures without funding again. Process loss before a durable result still remains guarded. Corrected artifacts, hardware proof integration and current consumer journeys remain draft gates.

@ovitrif

ovitrif commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

I pushed 9c84067 with the hardware Shop acceptance gates. A Core txid remains an original lookup candidate until a fresh exact outgoing observation in the original hardware wallet verifies it. Only that positive result can create Shop proof, Sent activity or Success; Pending retains the original payer/request/wallet and does not inspect an unrelated Savings guard.

The frozen batch passed its simulator build and 70 focused tests with no failures or skips. The UI, payer and activity regressions failed before their fixes; mocks do not establish a real hardware acceptance journey. Physical hardware and native fault fixtures remain QA gaps.

This remains draft while current rc68 remote consumption, affected tests and fixed/Manual Max native journeys finish. Historical rc67 media is labelled accordingly.

@ovitrif

ovitrif commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

I pushed f16736d with the production rc68 package pin and current journey documentation. The normal SwiftPM revision is 773792d; the published archive, Package.swift checksum and extracted/resolved simulator framework independently match the canonical artifact.

The current simulator build and 101 affected software, hardware and backup tests passed. The matching installed app completed funded fixed 1,000-sat and Manual Max 198,745-sat native sends; both exact UI transaction IDs matched native successful-broadcast logs and independent backend lookup. Current Preview replaces the historical accepted-send media.

The hardware Shop journey is specified and parsed but unrun; controlled native refusal/response-loss and physical USB/BLE remain explicit QA gaps. Autopilot Max remains outside this fix.

@ovitrif
ovitrif marked this pull request as ready for review September 30, 2026 03:24
Comment thread Bitkit/Views/Wallets/Send/HwSendSignView.swift
Comment thread Bitkit/Views/Wallets/Send/SendPendingScreen.swift

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

One 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-android#1384.

Checked and clean:

  • Only NodeError maps to .preDispatch; in rc.68 send_with_broadcast_result errors only before submission. Panics and other errors stay .unresolved, and the non-cancellable queue records the outcome.
  • Success is shown only on .accepted, and Electrum returns Accepted only for a matching txid. Observation promotion needs BDK sync events. HW Shop needs a matching getTransactionDetail with sent > 0.
  • admit is synchronous, so a concurrent send sees .pending.
  • The new keychain entry holds no key material and uses the existing accessibility/group, and full wipes cover it.
  • The ldk-node rc.66 → rc.68 bump carries the broadcast-result API with no storage migrations. It also brings rc.67 coin-selection changes.

Pre-existing and out of scope: ordinary HW sends and Boost still use the legacy broadcast.

Comment thread Bitkit/Services/OnchainSendAttemptService.swift
Comment thread Bitkit/Services/OnchainSendAttemptService.swift
Comment thread Bitkit/Services/PaykitPaymentProofService.swift
Comment thread Bitkit/Services/PaykitPaymentProofService.swift
@coreyphillips

Copy link
Copy Markdown
Contributor

Two independent reviews.

needs changing before merge

  • Pre-broadcast hook failure leaves an unclearable guard that blocks every on-chain send (Bitkit/Services/OnchainSendAttemptService.swift:218). If beforeBroadcastAttempt throws, the pending attempt is left in the keychain, and it can never be resolved. OnchainSendAttemptService.send (Bitkit/Services/OnchainSendAttemptService.swift, the do { try await beforeBroadcastAttempt() } catch { throw OnchainSendAttemptError.unresolved } block) first persists a .pending attempt via admit. Then it rethrows .unresolved without calling clearBeforeDispatch. At that point the node has not been called, so the attempt has txid == nil. Every release path needs a txid or a non-pending status: - observeConfirmedTransaction matches on attempt.txid. - resumeAcceptedOrdinarySend, resumeAcceptedRequestSend and resumeAcceptedTransfer all require a txid. - admit refuses while blocksNewSend is true. The only exit is Keychain.wipeEntireKeychain through a wallet reset. Ordinary sends, send-all, Shop payments and transfer-to-spending funding all stay blocked indefinitely. This is reachable. In SendConfirmationView the hook is markOnchainPaymentStarted, and that throws in several cases: - sdk.identityStatus() throws or returns no key (currentIdentity). - The prepared proof is no longer found (requestUnavailable). - The keychain read or write fails. The user then sees "An earlier on-chain send is unresolved" and a Pending screen saying the transaction "may have been sent". Neither is true. testCallbackNodeErrorAndCancellationRetainGuardWithoutDispatch asserts exactly this state: node.calls == 0 with the .pending guard retained. So the behaviour is deliberate, but its consequence is not the accepted "lost result" trade-off from the issue. In that case a transaction may exist. Here it is known that nothing was dispatched. I confirmed this by tracing every function that writes or clears the store in OnchainSendAttemptService.swift, and by reading the test above. I did not run it on a device.

worth doing, does not block

  • Geoblock after accepted funding blocks all on-chain sends (Bitkit/Services/TransferService.swift:45). If createTransfer fails after funding is accepted, all on-chain sends stay blocked. TransferViewModel now routes transfer bookkeeping through resumeAcceptedTransfer, which calls TransferService.createTransfer and propagates its errors. createTransfer throws when GeoService.shared.isGeoBlocked is true for a to-spending LSP transfer. If geoblock status turns true after the funding transaction is accepted, the attempt stays .accepted with localFollowupComplete == false. The WalletViewModel startup resume fails the same way on every launch while geoblocked, and admit refuses every new on-chain send in the meantime. Before this PR the tracking failure was logged and ignored. Consider bypassing the geoblock check when a funding txid for the order already exists, since that check guards creating a new order, not recording one already paid.
  • New user-facing send and pending strings bypass localization (Bitkit/Views/Wallets/Send/SendPendingScreen.swift:451). None of the new user-facing messages go through t(...), although the surrounding code localizes its strings. Affected strings: - The Pending screen messages ("Earlier on-chain payment", "Transaction ID: …", the rejected, unknown and follow-up texts). - The SendConfirmationView toasts ("Broadcast rejected", "Broadcast unconfirmed"). - The OnchainSendAttemptError.errorDescription values. - The TransferViewModel AppError messages. Non-English users will see English on the exact screens meant to stop them paying twice.
  • Transfer recovery can create duplicate records (Bitkit/Services/OnchainSendAttemptService.swift:373). Accepted transfer restoration can run twice because restoreAcceptedTransfer suspends before setting localFollowupComplete. The direct payOrder recovery and confirmation callback can both pass the guard, then race through TransferService.createTransfer, whose order lookup and insert are not atomic. This can persist duplicate records for one order and double-count the pending transfer balance.
  • Hardware Shop resolution loses wallet scope (Bitkit/Services/PaykitPaymentProofService.swift:461). A delayed hardware Shop resolution carries its hardware wallet ID, but AppScene.associateResolvedPaykitOnchainPayment calls findActivity and setContact without that ID. Both default to the main wallet, then the resolution is consumed even when lookup fails. If the send screen is closed, the hardware activity permanently misses its contact association.

@ovitrif

ovitrif commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 2039d39 to address callback admission, proof mutation races, and delayed/reopened hardware follow-up. Callback errors clear only the exact attempt before native dispatch; a failed clear remains guarded. Proof mutations now serialize without holding the lock through SDK network work. Delayed verification restores original Sent/contact/tags before delivery, and Pending can reopen after the resolution event was consumed. Accepted payments are never broadcast again.

Local verification: simulator build and 99 affected tests passed against published rc68 (0 failures/skips). The three regressions failed on the preceding head. Geographic test state is isolated while the production check remains unchanged.

Prior native fixed/Manual Max media is historical for this batch. Hardware journey and controlled native faults remain unrun. Previous-head E2E failures remain unresolved; new-head CI is pending. Refusal lockout and software legacy migration remain open review discussions.

@ovitrif
ovitrif requested a review from jvsena42 September 30, 2026 12:57

@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-checked 2039d39. The callback-failure MEDIUM and the lock LOW are resolved, and the greptile threads on delayed HW verification and the pending screen check out. No new findings.

Still open, waiting on my decision: the refusal lockout thread and the legacy-proof migration thread, where you asked whether they block this PR.

@ovitrif

ovitrif commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed ec99bd0 to fix the reported Electrum refusal formats. The parser now accepts supported JSON-string envelopes and coded RPC -25/-26 messages, including the complete replacement-fee refusal. Recognized refusals permit leaving the sheet while retaining the original signed payment and wallet guard; malformed and unknown responses remain guarded.

Focused refusal/navigation, original-byte retention and restart reconciliation regressions passed on the corrected source. The device report was from Android; actual iOS device refusal and funded Shop acceptance remain unrun. Current-head CI and human review are pending.

@ovitrif
ovitrif dismissed jvsena42’s stale review October 9, 2026 12:57

The reported Electrum JSON-string/coded RPC refusal finding is addressed in ec99bd0 with focused navigation and retained-payment regressions; its thread is resolved. Dismissing the superseded verdict; current-head human review and device acceptance remain pending.

@ovitrif

ovitrif commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 1067305 to restore access to the original refused hardware payment after restart. The original request stays accessible, and reopening uses its hardware wallet, recipient, amount, signed bytes and fee without preparing a new payment. Request identity/amount/billing period/endpoint checks and durable guards stay enforced; retries still require explicit authorization.

Focused reload/reopen and original-payment guard regressions passed. Actual hardware restart, funded Shop and fresh-wallet acceptance remain unrun; current-head CI and human review are 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.

Device gate run on an iPhone 17 simulator with the Trezor emulator over the Bridge, an Android emulator as the linked issuer, and a second wallet holding the same hardware-account coins to make the conflicting spend. No finding from the run. The read of the last two commits is still in progress, so this is a comment and not yet an approval.

Device gate: ec99bd0 for the first two parts, 1067305 for the third.

  • Normal path, passed. A 100,000 sat request opened with the Trezor as the funding source. The sign screen and the Trezor showed the issuer's address, 0.001 and a 141 sat fee. One approval ended on the success screen, and the backend has one transaction, bc2b0cd85baa…, paying exactly 100,000 sats to that address.
  • Refusal, passed. For a 30,000 sat request, the hardware-account coin was spent by the other wallet after the Trezor showed the recipient. The server answered insufficient fee, rejecting replacement. The button turned to Retry, Back returned to confirmation and swipe-down closed the sheet.
  • Reopen after relaunch, passed. At ec99bd0 the relaunched app showed the request as Pending with no action. At 1067305 its details offer Pay and Dismiss; Pay reopened the confirmation, Retry reused the signed bytes without a Trezor prompt, the server answered bad-txns-inputs-missingorspent, and the sheet could be left. Nothing was paid to the issuer's address for this request.

⚠️ Not checked on the device: Dismiss on the retained request, the deadline-expiry cases, and a retry that succeeds. The hardware wallet row reads "Connected via Bluetooth" while connected through the Bridge.

Comment thread Bitkit/Services/PaykitPaymentRequestService.swift Outdated

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

Read of the last two commits finished at 1067305: one MEDIUM, inline, reproduced by a unit test and seen on the simulator. The device results from my previous comment stand.

Checked and clean in the same read:

  • Refusal parser: still an allow-list. The JSON-string and object forms, the -25 / -26 prefixes and the anchored replacement pattern cannot match a connectivity error, a longer message or another error code; those stay guarded.
  • Reopen: address, amount and wallet come from the retained proof, the funding source cannot be switched, and the fee shown is the signed transaction's fee. The stored bytes are reused, so there is no second signature.
  • The retained retry is bound to the same identity, request id, amount and endpoint; a Lightning or software payment cannot reach this path, and an identity switch clears the cache.
  • Closing the reopened sheet, during preparation or after, leaves the proof and the request as they were.

Comment thread Bitkit/Services/PaykitPaymentRequestService.swift
@ovitrif

ovitrif commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 72814b0 to preserve access to the original unpaid recurring hardware payment after restart. Retained retries now match the original payer, subscription period, amount and endpoint. Dismiss is hidden and cancellation is rejected while the original signed payment remains unresolved, preserving its retry route and durable guard.

This addresses the recurring restart and retained-payment dismissal findings. Focused restart, original-byte retention, paid/changed-period exclusion and cancellation regressions passed. Actual funded Shop, fresh-device wallet/VSS and physical hardware acceptance remain unrun; current-head CI and human review are pending.

@ovitrif
ovitrif dismissed jvsena42’s stale review October 9, 2026 14:20

The retained-payment dismissal finding is addressed in 72814b0 with focused regression evidence and verified thread resolution. Current-head review and acceptance checks remain pending.

@ovitrif
ovitrif requested a review from ben-kaufman October 9, 2026 14:21

@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-read at 72814b0: the Dismiss finding is fixed and I have no new finding in this commit. No thread of mine is open.

Checked in the commit:

  • A retained payment cannot be dismissed, for one-time and recurring requests, at the manager and in the three views. Requests that could be dismissed before still can.
  • Recurring retained retries are matched on the request id, which includes the period start, plus amount, billing period and endpoint. A paid or locally completed period is excluded, and a one-time proof cannot satisfy a period.
  • The pay path re-validates wallet, address and amount against the retained proof.

Device gate (partial): 72814b0 — not run yet at this head. The refusal, reopen and retry steps passed on the simulator at 1067305 (my earlier comment). The hidden Dismiss needs a fresh refused payment on the simulator; I will run it and edit this line.

Platform note, for a follow-up and not a finding here: with this commit iOS reopens a refused hardware payment for a subscription period, while Android keeps such a payment guarded and unreachable after a restart (synonymdev/bitkit-android#1384).

ben-kaufman
ben-kaufman 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.

Holding this before merge: on the simulator, sending the maximum on-chain amount fails at 72814b0 and works on a master-based build with the same wallet. MEDIUM, regression. I am posting the device result now because the PR is approved; the root cause and a unit test follow in a reply.

Steps, iPhone 17 simulator, regtest, plain software wallet with 100,000 sats in Savings as two confirmed coins (60,000 and 40,000), automatic coin selection, no Paykit request involved:

  1. Open bitcoin:<a regtest address> so the Amount screen appears.
  2. Tap AVAILABLE. The amount fills with 99,819.
  3. Tap Continue.

Result at 72814b0: a toast "Send Error — Coin selection failed", and the screen stays on Amount. Repeating Continue gives the same result.

Result on a master-based build (0304889, the head of #911) with the same wallet and the same steps: the Confirm screen opens with 99,819.

I first saw it at 1067305 with a different wallet (one coin of about 24M sats); a fixed amount of 1,000,000 from that wallet went through to Confirm, so it is the maximum amount that fails. The app log at 72814b0 shows repeated estimateSendAllFee calls and one-input PSBTs alternating between the two coins, with no error line.

The Android twin is not affected: at b33dc94 the same steps reach Confirm with the full balance.

@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 the changes since ace62523, including affected payment paths and prior findings, at 72814b0c. Inherited baseline: previous QA review.

1 actionable finding — resolve or provide an evidence-backed rebuttal.

The pre-dispatch deadline and cancellation recheck, quoted Electrum RPC refusal parsing, recurring-period reopen, and retained-payment dismiss block match the current source. The deadline thread, refusal-envelope thread, recurring-reopen thread, and dismiss thread are addressed at this revision. The reopened confirmation still leaves the fee control editable, unlike the retained-payment lock on Android b33dc941.

Validation: the new refusal, expiry, and reopen regressions were inspected and not executed here. The unit-test run succeeded for this revision. Android comparison was limited to refusal parsing and retained confirmation terms.

Device testing: not performed in this review.

Suggested additional test cases

  • iOS, retained refused hardware Shop receipt: open Pay, change the fee speed, and return to confirmation. The speed, confirmation estimate, and sat fee stay on the original signed transaction, and swipe broadcasts those same bytes. After that refusal, change the speed again and swipe without leaving the sheet. The second attempt still submits the original transaction.

Findings

  • [LOW] Lock the fee on a reopened hardware payment — inline at Bitkit/Views/Wallets/Send/SendConfirmationView.swift:1612.

Comment thread Bitkit/Views/Wallets/Send/SendConfirmationView.swift
@ovitrif

ovitrif commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

sending the maximum on-chain amount fails

@jvsena42 Fixed in b24a470: Max uses the spendable drain inputs instead of running fixed-amount selection against an amount that already includes the drain fee. Normal fixed-amount selection keeps its chosen algorithm. The focused regression uses the reported 60,000/40,000 sat case at the selection boundary; a funded UI retest remains pending.

The same push fixes @piotr-iohk's retained hardware fee finding: confirmation uses the original signed fee rate, prevents fee edits, and repeats explicitly authorized retries with the same signed bytes even if the wallet preset changes. Focused regressions reproduced the old failures and passed on the corrected source. Funded Shop, fresh-device wallet/VSS and physical hardware acceptance remain unrun; new-head CI and human review are pending.

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

Superseded by b24a470: the reported Max selection regression is fixed with focused failing/passing evidence. Funded UI retest and current-head review 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 b24a470: the send-max regression is fixed and confirmed on the simulator. No new finding from me in this commit. The hardware part still gets a device run, so this is a comment for now.

Send max, iPhone 17 simulator at b24a470, plain software wallet with 60,000 + 40,000 sats:

  • Available, Continue: Confirm opens with 99,819 and a 181 sat fee. The broadcast transaction 02d3cc841799… spends both coins into one output of 99,819.
  • Available, then edited down to 10,000, Continue: sent as an ordinary payment. Transaction 3c28e3093195… spends the 40,000 coin into 10,000 plus 29,857 change, fee 143.

Root cause, for the record: the ldk-node bump in this PR (rc.66 to rc.71) makes selectUtxosWithAlgorithm add recipient overhead to its target, so "balance minus send-all fee" no longer fits as an exact amount and the selector reports CoinSelectionFailed. setUtxoSelection now takes every spendable output for a max send, and the actual send still goes through sendAllToAddress after shouldUseMaxOnchainSend re-checks the amount.

Read of the commit, clean:

  • isMaxAmountSend is recomputed on every Continue, so an edited amount, a unit switch or Back to Amount cannot leave it stale; it is reset with the send state.
  • The hardware and transfer flows never call setUtxoSelection.
  • On a reopened retained hardware payment the confirmation shows the signed fee and the fee row cannot be opened.

Not posted, no reproduction: in the same sheet, Retry after a refusal now passes ceil(signed fee rate) where the first attempt passed the preset. If those two ever differ, the retained-payment check in HwFundingSigner.swift:543-549 throws operationInProgress until the sheet is reopened. On the Trezor emulator the signed fees I saw were exact multiples of the preset (141 at 1 sat/vB), so I could not make them differ. The device run below includes a same-sheet Retry.

Device gate: b24a470 — the hardware run is done and passed; the result is in my approval on this PR.

@ovitrif

ovitrif commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

If those two ever differ, the retained-payment check in HwFundingSigner.swift:543-549 throws operationInProgress until the sheet is reopened.

@jvsena42 Checked against the pinned Bitkit Core producer: finish_psbt returns the supplied fee rate, and both Trezor and Jade signing retain it unchanged. The focused same-sheet fixture retries the original bytes at rates 1 and 17 without another compose/sign. Injecting an inconsistent returned rate of 1.25 for a requested rate of 1 reproduces the rejection before authorization/broadcast, but does not match that producer. No guard change is needed for this observation. Your actual Max and edited-amount confirmation is recorded; the hardware device gate remains pending.

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 b24a470: every finding I raised is fixed and confirmed on the simulator, and no thread of mine is open.

Device gate: b24a470 — passed, iPhone 17 simulator with the Trezor emulator over the Bridge, an Android emulator as the linked issuer, and a second wallet holding the same hardware-account coins for the conflicting spend.

  • Send max, software wallet: passed, details in my previous comment.
  • Hardware refusal: a 30,000 sat request was signed, the coin was spent by the other wallet first, and the server answered insufficient fee, rejecting replacement. The button turned to Retry with the back arrow available.
  • Retry in the same sheet: no Trezor prompt, a second broadcast and a second refusal, no "in progress" error. The concern I mentioned about the retained rate did not show: the signed fee is an exact multiple of the preset.
  • Leaving: Back returned to confirmation and swipe-down closed the sheet.
  • After relaunch: the request's details show Pay and no Dismiss. Pay reopened the confirmation with the Trezor as source and the fee shown as 1 ₿/vbyte (₿ 141) with no edit control; tapping the fee row did nothing.
  • Retry after reopen, one block later: no Trezor prompt, the server answered bad-txns-inputs-missingorspent, and the sheet could be left. Nothing was paid to the issuer's address for this request.

⚠️ Not tested: a retry that the server accepts after a refusal, the deadline-expiry cases, and a recurring (subscription) retained payment. No CI run was reported at this head when I posted this; it needs one before merge.

@ovitrif

ovitrif commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 0e00f06 to integrate master 91fffad and its reviewed Paykit 0.1.0-rc72 update, keeping LDK 0.7.0-rc.71. The merge preserves Max-send selection, original hardware fees/receipts and send-context cancellation guards; the lifecycle-test override now matches the combined API.

Simulator compilation and affected payment/recovery checks ran against the published packages. One lifecycle test remains failing: testCustomFeeWalletSwitchSurvivesFeeScreenNavigation reports repeated preparation and a route mismatch, also reproduced on exact master 91fffad. The remaining selected checks completed without failure; this is not an all-green test result.

Current-head dev approval and required hosted CI are pending. Fresh-device wallet/VSS restore, funded Shop proof/paid-order acceptance and physical hardware acceptance remain unrun.

@ovitrif

ovitrif commented Oct 10, 2026

Copy link
Copy Markdown
Collaborator Author

Published bb02e83: 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.

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

5 participants