From f08cffe1e6b2a88475295cfb98bee76b6d883220 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 11 Oct 2026 00:05:03 -0500 Subject: [PATCH 1/3] fix: retain prepared transaction fees --- .../to/bitkit/repositories/LightningRepo.kt | 6 +- .../repositories/OnchainSendAttemptStore.kt | 14 ++++ .../bitkit/repositories/LightningRepoTest.kt | 68 +++++++++++++++++++ .../OnchainSendAttemptStoreTest.kt | 51 ++++++++++++++ .../OnchainSendCoordinatorTest.kt | 12 +++- changelog.d/next/prepared-fees.fixed.md | 1 + journeys/send/prepared-fee-recovery.md | 10 +++ 7 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 changelog.d/next/prepared-fees.fixed.md create mode 100644 journeys/send/prepared-fee-recovery.md diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 26b1b27980..ff36cbda28 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1772,7 +1772,7 @@ class LightningRepo @Inject constructor( val needsExactFee = attempt.verifiedWinningFeeSats == null && (isSuccessor || (attempt.candidateTxids.isNotEmpty() && originalFollowup?.feeSats?.toULongOrNull() == 0uL)) val fee = attempt.verifiedWinningFeeSats ?: if (needsExactFee) { - requireNotNull(lightningService.observedOriginalSendFee(attempt)) { + requireNotNull(winningCandidateFee(attempt)) { "Winning transaction fee is unavailable" }.also { onchainSendAttemptStore.retainWinningFee(attempt.attemptId, attempt.walletIndex, txId, it) } } else { @@ -1813,6 +1813,10 @@ class LightningRepo @Inject constructor( } } + private suspend fun winningCandidateFee(attempt: OnchainSendAttempt): ULong? = + attempt.candidateMiningFees[requireNotNull(attempt.txid).lowercase()] + ?: lightningService.observedOriginalSendFee(attempt) + private fun scheduleOrdinaryFollowupRetry() { val previous = ordinaryFollowupRetryJob.get() if (previous?.isActive == true) return diff --git a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt index abb23cefbf..907ffe1c08 100644 --- a/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt +++ b/app/src/main/java/to/bitkit/repositories/OnchainSendAttemptStore.kt @@ -89,6 +89,7 @@ data class OnchainSendAttempt( val originalInputs: List? = null, val candidateTxids: List = emptyList(), val candidateFeeRates: Map = emptyMap(), + val candidateMiningFees: Map = emptyMap(), val backupFollowup: ActiveOnchainAttemptBackup.Followup? = null, val restoredFromBackup: Boolean = false, val preparationPending: Boolean = false, @@ -297,6 +298,7 @@ class OnchainSendAttemptStore @Inject constructor( originalInputs = current.originalInputs ?: receipt.inputs, candidateTxids = (current.candidateTxids + receipt.txid.lowercase()).distinct(), candidateFeeRates = current.candidateFeeRates + (receipt.txid.lowercase() to feeRate), + candidateMiningFees = retainedMiningFees(current, receipt), txid = if (isRecovery) current.txid else receipt.txid.lowercase(), evidence = if (isRecovery) current.evidence else OnchainSendEvidence.Pending, refusalReason = if (isRecovery) current.refusalReason else null, @@ -309,6 +311,14 @@ class OnchainSendAttemptStore @Inject constructor( } } + private fun retainedMiningFees(current: OnchainSendAttempt, receipt: OnchainPreparedReceipt): Map { + val fee = receipt.miningFeeSats ?: return current.candidateMiningFees + val txid = receipt.txid.lowercase() + val existingFee = current.candidateMiningFees[txid] + require(existingFee == null || existingFee == fee) + return current.candidateMiningFees + (txid to fee) + } + suspend fun discardUnsubmittedRetryCandidate(original: OnchainSendAttempt, txid: String) = withContext(ioDispatcher + NonCancellable) { mutex.withLock { @@ -328,6 +338,7 @@ class OnchainSendAttemptStore @Inject constructor( refusalReason = if (current.hasPositiveEvidence) current.refusalReason else original.refusalReason, candidateTxids = current.candidateTxids - candidate, candidateFeeRates = current.candidateFeeRates - candidate, + candidateMiningFees = current.candidateMiningFees - candidate, )) } } @@ -368,6 +379,7 @@ class OnchainSendAttemptStore @Inject constructor( originalInputs = null, candidateTxids = emptyList(), candidateFeeRates = emptyMap(), + candidateMiningFees = emptyMap(), preparationPending = current.requestId != null, )) // Shop proof cleanup still precedes guard deletion through the existing preparation path. @@ -495,6 +507,7 @@ class OnchainSendAttemptStore @Inject constructor( localFollowupComplete = attempt.localFollowupComplete, candidateTxids = attempt.candidateTxids, candidateFeeRates = attempt.candidateFeeRates, + candidateMiningFees = attempt.candidateMiningFees, backupFollowup = existing.backupFollowup?.copy(feeSats = attempt.backupFollowup?.feeSats.orEmpty()), restoredFromBackup = attempt.restoredFromBackup, preparationPending = attempt.preparationPending, @@ -535,6 +548,7 @@ class OnchainSendAttemptStore @Inject constructor( require(!attempt.localFollowupComplete || attempt.hasPositiveEvidence) require(attempt.candidateTxids.distinct().size == attempt.candidateTxids.size) require(attempt.candidateTxids.all { it.matches(Regex("[0-9a-f]{64}")) }) + require(attempt.candidateMiningFees.keys.all { it in attempt.candidateTxids }) if (attempt.originalInputs != null) { require(attempt.originalInputs.isNotEmpty()) require(attempt.originalInputs.distinct().size == attempt.originalInputs.size) diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 70d00bb7db..7a4763935d 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -65,6 +65,7 @@ import to.bitkit.data.backup.VssBackupClientLdk import to.bitkit.data.keychain.Keychain import to.bitkit.ext.createChannelDetails import to.bitkit.ext.of +import to.bitkit.models.ActiveOnchainAttemptBackup import to.bitkit.models.CoinSelectionPreference import to.bitkit.models.ElectrumServer import to.bitkit.models.NodeLifecycleState @@ -1983,6 +1984,72 @@ class LightningRepoTest : BaseUnitTest() { verify(onchainSendAttemptStore).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) } + @Test + fun `positive winner uses only its prepared fee while transaction history is unavailable`() = test { + val first = "ab".repeat(32) + val replacement = "cd".repeat(32) + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) + whenever(lightningService.observedOriginalSendFee(any())).thenReturn(null) + for ((txid, fee) in listOf(first to 0uL, replacement to 281uL)) { + val attempt = pendingSendAttempt().copy( + evidence = OnchainSendEvidence.Accepted, + txid = txid, + candidateTxids = listOf(first, replacement), + candidateFeeRates = mapOf(first to 1uL, replacement to 3uL), + candidateMiningFees = mapOf(first to 0uL, replacement to 281uL), + backupFollowup = ActiveOnchainAttemptBackup.Followup( + "0", + emptyList(), + createdAtMillis = "123", + ), + ) + whenever(onchainSendAttemptStore.current()).thenReturn(attempt) + whenever(activityService.getOnchainActivityByTxId(txid, attempt.walletId)).thenReturn(mock()) + sut.completeAcceptedOrdinaryFollowup(txid.uppercase()) + inOrder(onchainSendAttemptStore, activityService) { + verify(onchainSendAttemptStore).retainWinningFee(attempt.attemptId, attempt.walletIndex, txid, fee) + verify(activityService).createSentOnchainActivityFromSendResult( + txid, + attempt.address, + attempt.amountSats, + fee, + attempt.winningFeeRateSatsPerVByte, + false, + null, + attempt.walletId, + ) + verify(onchainSendAttemptStore).markLocalFollowupComplete(attempt.attemptId, attempt.walletIndex) + } + clearInvocations(onchainSendAttemptStore, activityService) + } + verify(lightningService, never()).observedOriginalSendFee(any()) + } + + @Test + fun `prepared fee does not complete unknown or mismatched shop payments`() = test { + val txid = "ab".repeat(32) + val request = PaykitPaymentRequestId("request", "payer") + val attempt = pendingSendAttempt().copy( + requestId = request, + evidence = OnchainSendEvidence.Unknown, + txid = txid, + candidateTxids = listOf(txid), + candidateMiningFees = mapOf(txid to 141uL), + ) + val activityService = mock() + whenever(coreService.activity).thenReturn(activityService) + whenever(onchainSendAttemptStore.current()).thenReturn(attempt) + sut.completeAcceptedShopFollowup(request, txid) + whenever(onchainSendAttemptStore.current()).thenReturn(attempt.copy(evidence = OnchainSendEvidence.Accepted)) + sut.completeAcceptedShopFollowup(request.copy(paymentRequestId = "other"), txid) + sut.completeAcceptedShopFollowup(request, "cd".repeat(32)) + verify(onchainSendAttemptStore, never()).retainWinningFee(any(), any(), any(), any()) + verify(preActivityMetadataRepo, never()).addPreActivityMetadata(any()) + verify(onchainSendAttemptStore, never()).markLocalFollowupComplete(any(), any()) + } + @Test fun `successor activity fee never reuses original nonzero fee and missing exact fee stays guarded`() = test { @@ -1993,6 +2060,7 @@ class LightningRepoTest : BaseUnitTest() { candidateTxids = listOf("ab".repeat(32), txid), candidateFeeRates = mapOf("ab".repeat(32) to 1uL, txid to 3uL), backupFollowup = to.bitkit.models.ActiveOnchainAttemptBackup.Followup("143", emptyList(), createdAtMillis = "123"), + candidateMiningFees = mapOf("ab".repeat(32) to 143uL), ) val activityService = mock() whenever(coreService.activity).thenReturn(activityService) diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt index 4205e9cbdc..6ae148f70c 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendAttemptStoreTest.kt @@ -5,27 +5,78 @@ import kotlinx.coroutines.async import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.launch import kotlinx.coroutines.test.runCurrent +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder import org.mockito.kotlin.any import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import to.bitkit.models.ActiveOnchainAttemptBackup import to.bitkit.models.WalletScope import to.bitkit.data.keychain.Keychain import to.bitkit.services.LightningService import to.bitkit.test.BaseUnitTest +import java.io.FileOutputStream import kotlin.test.assertFalse import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Clock @OptIn(kotlin.time.ExperimentalTime::class) class OnchainSendAttemptStoreTest : BaseUnitTest() { private val key = Keychain.Key.ONCHAIN_SEND_ATTEMPT.name + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `prepared candidate fees survive disk reopen without promoting unknown outcomes`() = test { + val file = temporaryFolder.newFile() + val keychain = mock() + whenever(keychain.loadString(key, 0)).thenAnswer { file.readText().takeIf(String::isNotEmpty) } + whenever(keychain.upsertString(eq(key), any(), eq(0))).doSuspendableAnswer { + FileOutputStream(file).use { output -> + output.write(it.getArgument(1).toByteArray()) + output.fd.sync() + } + } + val service = mock() + val store = OnchainSendAttemptStore(testDispatcher, keychain, service, Clock.System) + val attempt = store.admitForTest(walletId = WalletScope.default) + val receipt = OnchainPreparedReceipt( + "ab".repeat(32), + listOf(OnchainSendInput("11".repeat(32), 0u)), + attempt.address, + attempt.amountSats, + miningFeeSats = 141uL, + ) + store.retainPreparedReceipt(attempt.attemptId, 0, receipt, false) + store.recordOutcome(attempt.attemptId, OnchainSendOutcome.Unknown(receipt.txid), 0) + val replacement = receipt.copy(txid = "cd".repeat(32), miningFeeSats = 281uL) + store.retainPreparedReceipt(attempt.attemptId, 0, replacement, true) + val reopened = OnchainSendAttemptStore(testDispatcher, keychain, service, Clock.System) + val pending = requireNotNull(reopened.current()) + assertEquals(mapOf(receipt.txid to 141uL, replacement.txid to 281uL), pending.candidateMiningFees) + assertFalse(pending.hasPositiveEvidence) + assertTrue(pending.blocksNextSend) + assertNull(pending.verifiedWinningFeeSats) + assertFailsWith { + reopened.retainPreparedReceipt(attempt.attemptId, 0, replacement.copy(miningFeeSats = 282uL), true) + } + assertEquals(pending, reopened.current()) + val accepted = reopened.recordOutcome(attempt.attemptId, OnchainSendOutcome.Accepted(replacement.txid), 0) + val backup = ActiveOnchainAttemptBackup.from(accepted, "regtest", "11".repeat(32)) + val restored = backup.restored("regtest", backup.wallet.binding, attempt.walletId, 0) + assertTrue(restored.candidateMiningFees.isEmpty()) + reopened.restoreActive(restored) + assertEquals(accepted.candidateMiningFees, reopened.current()?.candidateMiningFees) + } + @Test fun `backup proof capture blocks receipt retention until the snapshot is complete`() = test { var saved: String? = null diff --git a/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt index 0940058c4d..7cd113d4d9 100644 --- a/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/OnchainSendCoordinatorTest.kt @@ -2,6 +2,7 @@ package to.bitkit.repositories import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async +import kotlinx.serialization.json.Json import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.doSuspendableAnswer @@ -160,6 +161,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { assertEquals(!expired, nextTxid in retained.candidateTxids) if (expired) { assertEquals(original.candidateTxids, retained.candidateTxids) + assertEquals(original.candidateMiningFees, retained.candidateMiningFees) assertEquals(original.txid, retained.txid) assertEquals(original.evidence, retained.evidence) } @@ -175,6 +177,10 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { override suspend fun prepareInitial(attempt: OnchainSendAttempt) = PreparedOnchainSend(f.receipt()) { it() assertTrue(requireNotNull(f.saved).contains(firstTxid)) + val durable = Json.decodeFromString( + requireNotNull(f.saved), + ) + assertEquals(mapOf(firstTxid to 50uL), durable.candidateMiningFees) sends++ OnchainSendOutcome.Unknown(firstTxid) } @@ -188,6 +194,8 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { val reopened = OnchainSendAttemptStore(testDispatcher, f.keychain, f.service, kotlin.time.Clock.System) assertEquals(listOf(firstTxid), reopened.current()?.candidateTxids) assertEquals(listOf(input), reopened.current()?.originalInputs) + assertEquals(mapOf(firstTxid to 50uL), reopened.current()?.candidateMiningFees) + assertTrue(reopened.current()?.isUnresolved == true) assertEquals(1, sends) } @@ -212,6 +220,7 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { assertTrue(OnchainSendCoordinator(f.store, sender, testDispatcher).sendInitial(attempt).isFailure) assertEquals(0, sends) assertTrue(f.store.current()?.blocksNextSend == true) + assertTrue(f.store.current()?.candidateMiningFees.isNullOrEmpty()) } @Test @@ -615,12 +624,13 @@ class OnchainSendCoordinatorTest : BaseUnitTest() { val sender = object : OnchainPreparedSender { override suspend fun prepareInitial(attempt: OnchainSendAttempt): PreparedOnchainSend = error("unused") override suspend fun prepareRecovery(attempt: OnchainSendAttempt, feeRateSatsPerVByte: ULong, paymentDeadlineAt: Instant?) = - PreparedOnchainSend(f.receipt(nextTxid)) { + PreparedOnchainSend(f.receipt(nextTxid).copy(miningFeeSats = 150uL)) { it() val saved = kotlinx.serialization.json.Json.decodeFromString( requireNotNull(f.saved) ) assertEquals(3uL, saved.candidateFeeRates[nextTxid]) + assertEquals(mapOf(firstTxid to 50uL, nextTxid to 150uL), saved.candidateMiningFees) OnchainSendOutcome.Unknown(nextTxid) } } diff --git a/changelog.d/next/prepared-fees.fixed.md b/changelog.d/next/prepared-fees.fixed.md new file mode 100644 index 0000000000..25cacc1b74 --- /dev/null +++ b/changelog.d/next/prepared-fees.fixed.md @@ -0,0 +1 @@ +Fixed on-chain payment completion waiting for transaction history when its exact prepared fee is already available. diff --git a/journeys/send/prepared-fee-recovery.md b/journeys/send/prepared-fee-recovery.md new file mode 100644 index 0000000000..f7adf1ea02 --- /dev/null +++ b/journeys/send/prepared-fee-recovery.md @@ -0,0 +1,10 @@ +# Prepared transaction fee recovery + +These checks need a disposable funded regtest wallet and a controlled native transaction-history or persistence fault. The journey capabilities do not provide those faults; ordinary payment success alone does not cover them. Never use an existing wallet or repeat a send after an unknown outcome. + +1. Prepare a software-wallet payment and record the candidate transaction ID and exact mining fee. Allow the original broadcast to return Accepted while withholding transaction-history details. Verify local activity records that candidate's exact fee and the original Paykit proof can finish without another send. +2. Interrupt the app after the receipt is persisted. Verify restart retains the fee but does not finish an Unknown payment until the existing exact-transaction evidence identifies the winner. +3. Authorize a same-input replacement with a different exact fee. Verify completion uses the actual winner's fee, including when the older candidate wins, and never borrows the other candidate's fee. +4. Fail the prepared-receipt save before dispatch. Verify no broadcast occurs. Separately replay the existing wallet backup and switch wallet or payer identity; verify the original identity and duplicate-payment guards still apply. + +If the winning fee is not already known, an attempt without a retained candidate fee must still wait for verified transaction-history data. These checks do not authorize changing acceptance, safety waits, backup formats, or proof delivery semantics. From 10e0065472f25307224ee37b6d74204cd284bc63 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 11 Oct 2026 00:06:45 -0500 Subject: [PATCH 2/3] chore: rename changelog fragment --- changelog.d/next/{prepared-fees.fixed.md => 1459.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{prepared-fees.fixed.md => 1459.fixed.md} (100%) diff --git a/changelog.d/next/prepared-fees.fixed.md b/changelog.d/next/1459.fixed.md similarity index 100% rename from changelog.d/next/prepared-fees.fixed.md rename to changelog.d/next/1459.fixed.md From 46a9a9de6484ca89f473621b11c89d1ec641df40 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 12 Oct 2026 05:24:25 -0500 Subject: [PATCH 3/3] docs: align prepared fee recovery checks --- journeys/README.md | 1 + journeys/send/onchain-original-payment-retry.xml | 3 ++- journeys/send/prepared-fee-recovery.md | 16 +++++++++++----- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/journeys/README.md b/journeys/README.md index c1181d7ad0..2ff8878e0a 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -160,6 +160,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `node-lifecycle/cancelled-node-restart.xml` | not ported — the routes run through Android's LDK Debug and Rapid-Gossip-Sync screens and assert on Android app-log lines | | `restore-wallet/paste-seed-fragment.xml` | not ported — the iOS Restore screen still has the 12/24-only paste guard, so the behaviour does not exist there yet | | `send/own-invoice-guard.xml` | not ported — iOS has no own-invoice guard | +| `send/prepared-fee-recovery.md` | Same four manual fault cases and file on both platforms; the recipe records original-send versus replacement fee retention and iOS Max/identity-flow adaptations. These faults are not journey capabilities. | | `settings/electrum-server-error-toasts.xml` | not ported — iOS still shows one generic message for every manual Electrum connect failure | | `transfers/closed-channel-transfer-settles.xml` | not ported — the closed-channel and order-closure settle rules are an iOS follow-up | | `onchain-receive/*` | port pending in synonymdev/bitkit-ios#588, which wires `onchainTransactionConfirmed` into the same received-sheet flow but carries no `journeys/` files. Two adaptations when it lands: iOS suppresses replayed historical receives with a `pendingRestoreActivitySeen` flag cleared by the first post-restore on-chain sync, not the one-hour block-timestamp guard used here, so a stale-confirmation step has to drive a restore instead of a clock; and iOS has no foreground-service path, so `confirmed-only-background-notification.xml` has no counterpart | diff --git a/journeys/send/onchain-original-payment-retry.xml b/journeys/send/onchain-original-payment-retry.xml index 2983032f3b..13902a8797 100644 --- a/journeys/send/onchain-original-payment-retry.xml +++ b/journeys/send/onchain-original-payment-retry.xml @@ -8,7 +8,8 @@ as state injection, never as proof of a native fault. Manual Tests must verify the exact backend winning transaction retains the complete original input set and recipient amount, and any original Shop request or Blocktank order completes once without a new request or order. Local activity alone - cannot prove these checks. The funded native fault journey remains unrun. + cannot prove these checks. See prepared-fee-recovery.md in this directory for the four shared + manual fee-recovery cases and platform adaptations. Record native fault coverage separately. Open the retained original payment's Pending screen and record its displayed transaction ID diff --git a/journeys/send/prepared-fee-recovery.md b/journeys/send/prepared-fee-recovery.md index f7adf1ea02..d4dcbcbfc8 100644 --- a/journeys/send/prepared-fee-recovery.md +++ b/journeys/send/prepared-fee-recovery.md @@ -1,10 +1,16 @@ # Prepared transaction fee recovery -These checks need a disposable funded regtest wallet and a controlled native transaction-history or persistence fault. The journey capabilities do not provide those faults; ordinary payment success alone does not cover them. Never use an existing wallet or repeat a send after an unknown outcome. +These checks need a disposable funded regtest wallet and controlled native transaction-history, broadcast-response or persistence faults. The journey capabilities do not provide those faults; ordinary payment success alone does not cover them. Never use an existing wallet or start a new payment after an unknown outcome. An explicit retry must retain the original payment's inputs, recipient and amount, with normal fee approval and spend authentication. 1. Prepare a software-wallet payment and record the candidate transaction ID and exact mining fee. Allow the original broadcast to return Accepted while withholding transaction-history details. Verify local activity records that candidate's exact fee and the original Paykit proof can finish without another send. -2. Interrupt the app after the receipt is persisted. Verify restart retains the fee but does not finish an Unknown payment until the existing exact-transaction evidence identifies the winner. -3. Authorize a same-input replacement with a different exact fee. Verify completion uses the actual winner's fee, including when the older candidate wins, and never borrows the other candidate's fee. -4. Fail the prepared-receipt save before dispatch. Verify no broadcast occurs. Separately replay the existing wallet backup and switch wallet or payer identity; verify the original identity and duplicate-payment guards still apply. +2. Interrupt the app after the receipt is persisted. Verify restart retains the fee but does not finish an Unknown payment until the existing exact-transaction evidence identifies the winner. Separately interrupt after replacement acceptance but before local follow-up; reopen with history still withheld and verify the accepted winner's exact fee in Sent details, without another broadcast. +3. Authorize a same-input replacement with a different exact fee. Verify completion uses the actual winner's fee, including when the older candidate wins, and never borrows the other candidate's fee. Confirm the winning transaction and original inputs/recipient amount against the backend; local activity alone is not proof. +4. Fail the prepared-receipt save before dispatch. Verify no broadcast occurs. Separately replay an identical wallet backup over the same local operation and verify retained fees survive; altered operation, candidate or progress data must not overwrite it. Switch wallet or payer identity during retry preparation/authentication and verify the original identity and duplicate-payment guards still apply. -If the winning fee is not already known, an attempt without a retained candidate fee must still wait for verified transaction-history data. These checks do not authorize changing acceptance, safety waits, backup formats, or proof delivery semantics. +If the winning fee is not already known, an attempt without a retained candidate fee must still wait for verified transaction-history data. The private candidate-fee map is not exported in the shared backup: reset or a new installation uses the existing history fallback, not an imported fee. A retained fee is bookkeeping evidence, never payment-acceptance evidence. + +## Platform adaptations + +Android retains candidate fees for original sends and replacements in `candidateMiningFees`. iOS already retains the original prepared fee; `candidateFeeSats` covers successors/replacements. Run the same four cases on both platforms. Use each platform's normal identity-selection flow; iOS Max recovery keeps the original rate without an editable field. The common UI actions are in [onchain-original-payment-retry.xml](onchain-original-payment-retry.xml). + +Record the platform, exact build, fixture/fault controls, observed results and untested cases separately. Injected Pending state is not proof of a native broadcast fault, and one platform's results do not establish the other's. These checks do not authorize changing acceptance, safety waits, backup formats or proof delivery semantics.