Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import to.bitkit.repositories.ContactPaymentSettingsRepo
import to.bitkit.repositories.LightningRepo
import to.bitkit.repositories.OnchainSendAttemptStore
import to.bitkit.repositories.OnchainSendEvidence
import to.bitkit.repositories.PaykitPaymentProofRepo
import to.bitkit.repositories.PendingPaymentRepo
import to.bitkit.repositories.PrivatePaykitRepo
import to.bitkit.repositories.PubkyRepo
Expand Down Expand Up @@ -66,6 +67,8 @@ class SendPendingObservationDeviceTest {

@Inject lateinit var activityRepo: ActivityRepo

@Inject lateinit var paykitPaymentProofRepo: PaykitPaymentProofRepo

@Inject lateinit var settingsStore: SettingsStore

@Inject lateinit var pubkyRepo: PubkyRepo
Expand Down Expand Up @@ -119,7 +122,7 @@ class SendPendingObservationDeviceTest {
assertEquals(original.attemptId, pending.attemptId)
assertEquals(original.originalInputs, store.current()?.originalInputs)
assertEquals(original.candidateTxids, store.current()?.candidateTxids)
val vm = SendPendingViewModel(PendingPaymentRepo(), activityRepo, lightningRepo)
val vm = SendPendingViewModel(PendingPaymentRepo(), activityRepo, lightningRepo, paykitPaymentProofRepo)
val settings = SettingsViewModel(
context,
settingsStore,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ data class ActiveOnchainAttemptBackup(
val candidateTxids: List<String> = emptyList(),
val feeRateSatsPerVByte: String,
val candidateFeeRates: Map<String, String>? = null,
// Lets a release after a restore still outbid every candidate's absolute fee.
val candidateMiningFees: Map<String, String>? = null,
val followup: Followup? = null,
val transfer: Transfer? = null,
) {
Expand Down Expand Up @@ -75,6 +77,10 @@ data class ActiveOnchainAttemptBackup(
require(txid.matches(HEX) && txid in candidateTxids)
unsigned(rate).also { require(it > 0uL && it <= UInt.MAX_VALUE.toULong()) }
}
val restoredMiningFees = candidateMiningFees.orEmpty().mapValues { (txid, fee) ->
require(txid.matches(HEX) && txid in candidateTxids)
unsigned(fee)
}
val inputs = originalInputs?.map { input ->
require(input.txid.matches(HEX) && unsigned(input.vout) <= UInt.MAX_VALUE.toULong())
OnchainSendInput(input.txid, unsigned(input.vout).toUInt())
Expand Down Expand Up @@ -128,6 +134,7 @@ data class ActiveOnchainAttemptBackup(
channelId = followup?.channelId, tags = followup?.tags.orEmpty(), evidence = evidence,
txid = txid, refusalReason = rejectionReason, localFollowupComplete = false,
originalInputs = inputs, candidateTxids = candidateTxids, candidateFeeRates = restoredFeeRates,
candidateMiningFees = restoredMiningFees,
transferContext = transferContext,
backupFollowup = followup, restoredFromBackup = true,
)
Expand Down Expand Up @@ -175,6 +182,8 @@ data class ActiveOnchainAttemptBackup(
candidateTxids = attempt.candidateTxids, feeRateSatsPerVByte = attempt.feeRateSatsPerVByte.toString(),
candidateFeeRates = attempt.candidateFeeRates.takeIf { it.isNotEmpty() }
?.mapValues { it.value.toString() },
candidateMiningFees = attempt.candidateMiningFees.takeIf { it.isNotEmpty() }
?.mapValues { it.value.toString() },
followup = attempt.backupFollowup,
transfer = attempt.transferContext?.let {
Transfer(
Expand Down
53 changes: 52 additions & 1 deletion app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ class LightningRepo @Inject constructor(
private val vssBackupClientLdk: VssBackupClientLdk,
private val urlValidator: UrlValidator,
private val electrumProbeService: ElectrumProbeService,
) {
) : OnchainReleasing {
private val _lightningState = MutableStateFlow(LightningState())
val lightningState = _lightningState.asStateFlow()

Expand Down Expand Up @@ -1861,6 +1861,49 @@ class LightningRepo @Inject constructor(

suspend fun currentOnchainSendAttempt(): OnchainSendAttempt? = onchainSendAttemptStore.current()

/** Syncs the on-chain wallet, then checks that none of the attempt's candidates reached the mempool or chain. */
override suspend fun onchainCandidatesNeverReachedNetwork(attempt: OnchainSendAttempt): Boolean =
onchainCandidatesNeverReachedNetwork(attempt, ABANDON_SYNC_TIMEOUT)

suspend fun onchainCandidatesNeverReachedNetwork(
attempt: OnchainSendAttempt,
timeout: Duration,
): Boolean {
// The requested sync runs on the serial LDK queue after any sync already in flight, so its success proves a
// fresh view. Failing that, an on-chain sync that finishes later than the one read here also counts.
val previousSync = lightningService.status?.latestOnchainWalletSyncTimestamp ?: 0uL
// The sync blocks on the LDK queue and ignores cancellation, so it runs apart and the wait keeps its limit.
val requestedSyncSucceeded = AtomicBoolean(false)
scope.launch { if (runSuspendCatching { lightningService.sync() }.isSuccess) requestedSyncSucceeded.set(true) }
withTimeoutOrNull(timeout) {
while (true) {
val latestSync = lightningService.status?.latestOnchainWalletSyncTimestamp
if (isOnchainSyncAfter(latestSync, previousSync, requestedSyncSucceeded.get())) break
delay(500.milliseconds)
}
} ?: throw OnchainSendAbandonSyncError()
return lightningService.walletKnowsNoCandidate(attempt)
}

override suspend fun walletKnowsTransaction(txid: String): Boolean = lightningService.walletKnowsTransaction(txid)

override suspend fun originalCoinSpentElsewhere(attempt: OnchainSendAttempt): Boolean =
lightningService.originalCoinSpentElsewhere(attempt)

override suspend fun prepareRelease(attempt: OnchainSendAttempt): PreparedOnchainSend =
lightningService.prepareRelease(attempt)

suspend fun recordOnchainRelease(expected: OnchainSendAttempt, txid: String): OnchainSendAttempt? =
onchainSendAttemptStore.recordRelease(expected, txid)

suspend fun approveOnchainRelease(expected: OnchainSendAttempt): OnchainSendAttempt? =
onchainSendAttemptStore.approveRelease(expected)

suspend fun abandonUnbroadcastOnchainSend(
expected: OnchainSendAttempt,
removeOriginalProof: suspend (OnchainSendAttempt) -> Boolean,
): Boolean = onchainSendAttemptStore.abandonUnbroadcast(expected, removeOriginalProof)

suspend fun releaseInterruptedShopPreparation(
removeOriginalProof: suspend (OnchainSendAttempt) -> Boolean,
): Boolean = onchainSendAttemptStore.releaseInterruptedShopPreparation(removeOriginalProof)
Expand Down Expand Up @@ -2581,6 +2624,7 @@ class LightningRepo @Inject constructor(
private val BACKGROUND_STOP_DELAY = 5.seconds
private val CHANNELS_USABLE_TIMEOUT = 15.seconds
private val CHANNELS_USABLE_POLL_DELAY = 1.seconds
private val ABANDON_SYNC_TIMEOUT = 30.seconds
private val NO_USABLE_CHANNELS_FEEDBACK_DELAY = 2_500.milliseconds

/** Max time to wait for a starting node before its id is treated as unavailable. */
Expand Down Expand Up @@ -2768,3 +2812,10 @@ sealed interface ProbeOutcome {
val routeFeeMsat: ULong?,
) : ProbeOutcome
}

/**
* The sync requested for a check proves a fresh view once it succeeds. Otherwise only a sync that finished later than
* the one read before the request counts; one finished in the same second may predate it.
*/
internal fun isOnchainSyncAfter(latest: ULong?, previous: ULong, requestedSyncSucceeded: Boolean = false): Boolean =
requestedSyncSucceeded || (latest ?: 0uL) > previous
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ data class OnchainSendAttempt(
val verifiedWinningFeeSats: ULong? = null,
// Write-ahead boundary: false is proof that this original prepared payment never dispatched.
val initialDispatchAttempted: Boolean = true,
// Set once its request was found ended, so a release cut short after its proof was removed can finish.
val releaseApproved: Boolean = false,
// The transaction spending the original's coins back to the wallet, saved before it is broadcast.
val releaseTxid: String? = null,
) {
val winningFeeRateSatsPerVByte: ULong
get() = candidateFeeRates[txid?.lowercase()] ?: feeRateSatsPerVByte.takeIf {
Expand All @@ -112,11 +116,48 @@ data class OnchainSendAttempt(

val blocksNextSend: Boolean
get() = isUnresolved || (hasPositiveEvidence && !localFollowupComplete)

val highestCandidateFee: ULong
get() = maxOf(candidateMiningFees.values.maxOrNull() ?: 0uL, verifiedWinningFeeSats ?: 0uL)
Comment thread
jvsena42 marked this conversation as resolved.

/** A Shop payment from the node wallet that dispatched but never had its acceptance confirmed. */
val canBeAbandoned: Boolean
get() = isUnresolved && requestId != null && !isTransfer &&
walletId == WalletScope.default && !payerIdentity.isNullOrBlank() && initialDispatchAttempted &&
txid != null && !originalInputs.isNullOrEmpty() && candidateTxids.isNotEmpty()
}

class OnchainSendBlockedError(val attempt: OnchainSendAttempt? = null) :
AppError("A previous on-chain send is unresolved or this payment was already sent.")

class OnchainSendAbandonRefusedError(val requestOpen: Boolean) :
AppError("This on-chain payment may still reach the network or its request is still open.")

class OnchainSendAbandonSyncError : AppError("The on-chain wallet could not sync before releasing a payment.")

class OnchainSendAbandonPendingError : AppError("The release transaction was sent but is not confirmed yet.")

/**
* A release must spend at least one of the original's coins and pay more than any candidate, so it replaces a
* candidate a node may still hold, and no candidate can confirm once it does.
*/
fun releaseOutbids(receipt: OnchainPreparedReceipt, attempt: OnchainSendAttempt): Boolean {
val original = attempt.originalInputs.orEmpty().toSet()
val fee = receipt.miningFeeSats ?: return false
if (receipt.inputs.none { it in original }) return false
return fee > attempt.highestCandidateFee
}

/** The wallet side of cancelling: proving no candidate reached the network, and spending its coins back. */
interface OnchainReleasing {
suspend fun onchainCandidatesNeverReachedNetwork(attempt: OnchainSendAttempt): Boolean
suspend fun walletKnowsTransaction(txid: String): Boolean

/** Whether a transaction that is not a candidate already spent one of the original's coins. */
suspend fun originalCoinSpentElsewhere(attempt: OnchainSendAttempt): Boolean
suspend fun prepareRelease(attempt: OnchainSendAttempt): PreparedOnchainSend
}

class OnchainSendAttemptUnreadableError(cause: Throwable) : AppError("Failed to read on-chain send attempt", cause)

class OnchainSendNotDispatchedError(cause: Throwable) : AppError("On-chain send did not reach the backend", cause)
Expand Down Expand Up @@ -250,6 +291,53 @@ class OnchainSendAttemptStore @Inject constructor(
}
}

/** Records that the payment's request has ended before its original proof is removed. */
suspend fun approveRelease(expected: OnchainSendAttempt): OnchainSendAttempt? =
withContext(ioDispatcher + NonCancellable) {
mutex.withLock {
val current = loadWithRetainedAccepted(expected.walletIndex)
val approved = expected.copy(releaseApproved = true)
when {
current == approved -> current
current != expected || !current.canBeAbandoned || current.attemptId in inFlightPreparations -> null
else -> approved.also { persist(it) }
}
}
}

/** Saves the release transaction before it is broadcast, so a later cancel can find it in the wallet. */
suspend fun recordRelease(expected: OnchainSendAttempt, txid: String): OnchainSendAttempt? =
withContext(ioDispatcher + NonCancellable) {
mutex.withLock {
val current = loadWithRetainedAccepted(expected.walletIndex)
val isCandidate = current?.candidateTxids?.any { it.equals(txid, ignoreCase = true) } == true
val releasable = current == expected && current.canBeAbandoned && current.releaseApproved
if (!releasable || isCandidate) return@withLock null
current.copy(releaseTxid = txid.lowercase()).also { persist(it) }
Comment thread
ovitrif marked this conversation as resolved.
}
}

/**
* Releases a Shop payment whose request has ended once the caller has shown that none of its candidates
* reached the network. The original proof is removed first, so a crash in between leaves an attempt that
* a later call can still release.
*/
suspend fun abandonUnbroadcast(
expected: OnchainSendAttempt,
removeOriginalProof: suspend (OnchainSendAttempt) -> Boolean,
): Boolean = withContext(ioDispatcher + NonCancellable) {
mutex.withLock {
val current = loadWithRetainedAccepted(expected.walletIndex) ?: return@withLock false
if (current != expected || !current.canBeAbandoned || current.attemptId in inFlightPreparations) {
return@withLock false
}
if (!removeOriginalProof(current)) return@withLock false
keychain.delete(KEY, current.walletIndex)
_backupStateVersion.update { it + 1 }
true
}
}

suspend fun retainPreparedReceipt(
attemptId: String,
walletIndex: Int,
Expand Down Expand Up @@ -513,6 +601,9 @@ class OnchainSendAttemptStore @Inject constructor(
preparationPending = attempt.preparationPending,
verifiedWinningFeeSats = attempt.verifiedWinningFeeSats,
initialDispatchAttempted = attempt.initialDispatchAttempted,
// A local release approval and its transaction are not part of the shared backup record.
releaseApproved = attempt.releaseApproved,
releaseTxid = attempt.releaseTxid,
Comment thread
ovitrif marked this conversation as resolved.
) == attempt
check(sameOperation && existing.candidateTxids.containsAll(attempt.candidateTxids)) {
"Cannot change a restored on-chain operation's original context"
Expand Down
Loading
Loading