Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ data class OnchainSendAttempt(
val originalInputs: List<OnchainSendInput>? = null,
val candidateTxids: List<String> = emptyList(),
val candidateFeeRates: Map<String, ULong> = emptyMap(),
val candidateMiningFees: Map<String, ULong> = emptyMap(),
val backupFollowup: ActiveOnchainAttemptBackup.Followup? = null,
val restoredFromBackup: Boolean = false,
val preparationPending: Boolean = false,
Expand Down Expand Up @@ -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,
Expand All @@ -309,6 +311,14 @@ class OnchainSendAttemptStore @Inject constructor(
}
}

private fun retainedMiningFees(current: OnchainSendAttempt, receipt: OnchainPreparedReceipt): Map<String, ULong> {
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 {
Expand All @@ -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,
))
}
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
68 changes: 68 additions & 0 deletions app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<ActivityService>()
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<ActivityService>()
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 {
Expand All @@ -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<ActivityService>()
whenever(coreService.activity).thenReturn(activityService)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Keychain>()
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<String>(1).toByteArray())
output.fd.sync()
}
}
val service = mock<LightningService>()
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<IllegalArgumentException> {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand All @@ -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<OnchainSendAttempt>(
requireNotNull(f.saved),
)
assertEquals(mapOf(firstTxid to 50uL), durable.candidateMiningFees)
sends++
OnchainSendOutcome.Unknown(firstTxid)
}
Expand All @@ -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)
}

Expand All @@ -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
Expand Down Expand Up @@ -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<OnchainSendAttempt>(
requireNotNull(f.saved)
)
assertEquals(3uL, saved.candidateFeeRates[nextTxid])
assertEquals(mapOf(firstTxid to 50uL, nextTxid to 150uL), saved.candidateMiningFees)
OnchainSendOutcome.Unknown(nextTxid)
}
}
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1459.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed on-chain payment completion waiting for transaction history when its exact prepared fee is already available.
1 change: 1 addition & 0 deletions journeys/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
3 changes: 2 additions & 1 deletion journeys/send/onchain-original-payment-retry.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
</description>
<actions>
<action>Open the retained original payment's Pending screen and record its displayed transaction ID</action>
Expand Down
Loading
Loading