diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 406e16127b..d7ab8e09a1 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -42,6 +42,7 @@ import org.lightningdevkit.ldknode.Address import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.BestBlock import org.lightningdevkit.ldknode.Bolt11Invoice +import org.lightningdevkit.ldknode.BroadcastOutcome import org.lightningdevkit.ldknode.ChannelConfig import org.lightningdevkit.ldknode.ChannelDataMigration import org.lightningdevkit.ldknode.ChannelDetails @@ -1449,6 +1450,16 @@ class LightningRepo @Inject constructor( Result.success(payments) } + suspend fun getOnchainBroadcastOutcome(txid: Txid): Result = + executeWhenNodeRunning("getOnchainBroadcastOutcome") { + runSuspendCatching { lightningService.getOnchainBroadcastOutcome(txid) } + } + + suspend fun acknowledgeOnchainBroadcastOutcome(txid: Txid): Result = + executeWhenNodeRunning("acknowledgeOnchainBroadcastOutcome") { + runSuspendCatching { lightningService.acknowledgeOnchainBroadcastOutcome(txid) } + } + suspend fun getAddressBalance(address: String): Result = executeWhenNodeRunning("getAddressBalance") { runCatching { lightningService.getAddressBalance(address) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt index 26038aade6..04358c77b5 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentProofRepo.kt @@ -12,6 +12,8 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import org.lightningdevkit.ldknode.BroadcastOutcome +import org.lightningdevkit.ldknode.BroadcastOutcomeStatus import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentDirection import org.lightningdevkit.ldknode.PaymentKind @@ -24,6 +26,7 @@ import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.services.PaykitSdkService import to.bitkit.utils.Logger import java.security.MessageDigest +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -41,6 +44,10 @@ data class PendingPaykitPaymentProof( val kind: PaykitPaymentProofKind, val paymentIdentifier: String? = null, val proofData: String? = null, + val preparationId: String? = null, + val requiresBroadcastOutcome: Boolean = false, + val broadcastLineage: List = emptyList(), + val broadcastOutcomeHandled: Boolean = false, ) @Singleton @@ -61,19 +68,12 @@ class PaykitPaymentProofRepo @Inject constructor( request: PaykitPaymentRequest, paymentEndpointIdentifier: String, kind: PaykitPaymentProofKind, - ): Result = withContext(ioDispatcher) { + ): Result = withContext(ioDispatcher) { runSuspendCatching { operationMutex.withLock { val proof = pendingProof(request, paymentEndpointIdentifier, kind) - val proofs = loadProofs() - .filterNot { - PubkyPublicKeyFormat.matches(it.identity, proof.identity) && - it.requestId == request.id && - it.paymentIdentifier == null && - it.proofData == null - } + - proof - persist(proofs) + persist(loadProofs() + proof) + requireNotNull(proof.preparationId) } }.onFailure { Logger.warn("Failed to prepare a Paykit payment proof", it, context = TAG) } } @@ -105,6 +105,50 @@ class PaykitPaymentProofRepo @Inject constructor( }.onFailure { Logger.warn("Failed to associate a Paykit Lightning payment proof", it, context = TAG) } } + suspend fun associateOnchainPayment( + request: PaykitPaymentRequest, + txid: String, + paymentEndpointIdentifier: String, + preparationId: String?, + ): Result = withContext(ioDispatcher) { + runSuspendCatching { + if (!txid.isHex(HASH_BYTE_COUNT)) throw PaykitPaymentRequestError.RequestUnavailable + operationMutex.withLock { + val proofs = loadProofs().toMutableList() + val index = proofs.indexOfLast { + if (preparationId != null) { + it.preparationId == preparationId && + it.requestId == request.id && + it.kind == PaykitPaymentProofKind.Onchain && + it.paymentIdentifier == null && + it.proofData == null + } else { + it.requestId == request.id && + it.kind == PaykitPaymentProofKind.Onchain && + it.paymentIdentifier == null && + it.proofData == null + } + } + val proof = if (index >= 0) { + proofs[index].copy( + paymentIdentifier = txid.lowercase(), + requiresBroadcastOutcome = true, + broadcastLineage = listOf(txid.lowercase()), + ) + } else { + pendingProof(request, paymentEndpointIdentifier, PaykitPaymentProofKind.Onchain) + .copy( + paymentIdentifier = txid.lowercase(), + requiresBroadcastOutcome = true, + broadcastLineage = listOf(txid.lowercase()), + ) + } + if (index >= 0) proofs[index] = proof else proofs += proof + persist(proofs) + } + }.onFailure { Logger.warn("Failed to associate a Paykit on-chain payment proof", it, context = TAG) } + } + suspend fun completeLightningPayment(paymentHash: String, preimage: String?) = withContext(ioDispatcher) { if (preimage == null) return@withContext if (!preimage.matchesPaymentHash(paymentHash)) { @@ -195,8 +239,8 @@ class PaykitPaymentProofRepo @Inject constructor( it.kind == PaykitPaymentProofKind.Lightning && it.paymentIdentifier.equals(paymentHash, ignoreCase = true) } - suspend fun cancelPreparation(request: PaykitPaymentRequest) = removeProofs { - it.requestId == request.id && it.paymentIdentifier == null && it.proofData == null + suspend fun cancelPreparation(preparationId: String) = removeProofs { + it.preparationId == preparationId && it.paymentIdentifier == null && it.proofData == null } suspend fun reconcile() = withContext(ioDispatcher) { @@ -238,6 +282,17 @@ class PaykitPaymentProofRepo @Inject constructor( proof: PendingPaykitPaymentProof, payments: List, ) { + if (proof.broadcastOutcomeHandled) { + acknowledgeHandledBroadcastOutcome(proof) + return + } + if ( + proof.kind == PaykitPaymentProofKind.Onchain && + (proof.requiresBroadcastOutcome || proof.proofData == null) + ) { + reconcileOnchainProof(proof) + return + } if (proof.proofData != null) { submitReady(proof) return @@ -268,21 +323,87 @@ class PaykitPaymentProofRepo @Inject constructor( } } + private suspend fun reconcileOnchainProof(proof: PendingPaykitPaymentProof) { + val txid = proof.paymentIdentifier ?: return + val outcome = lightningRepo.getOnchainBroadcastOutcome(txid).getOrThrow() ?: return + when (outcome.status) { + BroadcastOutcomeStatus.PENDING -> persistBroadcastOutcome(proof, outcome, proofData = null) + BroadcastOutcomeStatus.ACCEPTED -> handleAcceptedBroadcastOutcome(proof, outcome) + BroadcastOutcomeStatus.ABANDONED -> handleAbandonedBroadcastOutcome(proof, outcome) + } + } + + private suspend fun handleAcceptedBroadcastOutcome( + proof: PendingPaykitPaymentProof, + outcome: BroadcastOutcome, + ) { + val completed = persistBroadcastOutcome(proof, outcome, proofData = outcome.txid) ?: return + if (!queueReadyProof(completed)) return + val handled = completed.copy( + proofData = null, + broadcastOutcomeHandled = true, + ) + persistHandledBroadcastOutcome(handled) + acknowledgeHandledBroadcastOutcome(handled) + } + + private suspend fun handleAbandonedBroadcastOutcome( + proof: PendingPaykitPaymentProof, + outcome: BroadcastOutcome, + ) { + val canonical = persistBroadcastOutcome(proof, outcome, proofData = null) ?: return + val handled = canonical.copy(broadcastOutcomeHandled = true) + persistHandledBroadcastOutcome(handled) + acknowledgeHandledBroadcastOutcome(handled) + } + + private suspend fun persistBroadcastOutcome( + proof: PendingPaykitPaymentProof, + outcome: BroadcastOutcome, + proofData: String?, + ): PendingPaykitPaymentProof? { + val updated = proof.copy( + paymentIdentifier = outcome.txid.lowercase(), + proofData = proofData?.lowercase(), + requiresBroadcastOutcome = true, + broadcastLineage = outcome.lineage.map { it.lowercase() }, + ) + if (!replaceProofLocked(proof, updated)) return null + return updated + } + + private suspend fun persistHandledBroadcastOutcome(proof: PendingPaykitPaymentProof) { + val proofs = loadProofs().filterNot { it.matchesRequest(proof) } + proof + persist(proofs) + } + + private suspend fun acknowledgeHandledBroadcastOutcome(proof: PendingPaykitPaymentProof) { + val txid = proof.paymentIdentifier ?: return + lightningRepo.acknowledgeOnchainBroadcastOutcome(txid).getOrThrow() + removeProofsLocked { it == proof } + } + private suspend fun submitReady(proof: PendingPaykitPaymentProof) { - val proofData = proof.proofData ?: return + if (!queueReadyProof(proof)) return + runSuspendCatching { removeProofsLocked { it.matchesRequest(proof) } } + .onFailure { Logger.warn("Failed to clear a submitted Paykit payment proof", it, context = TAG) } + } + + private suspend fun queueReadyProof(proof: PendingPaykitPaymentProof): Boolean { + val proofData = proof.proofData ?: return false val identityStatus = paykitSdkService.identityStatus() if ( identityStatus?.liveSessionAvailable != true || !PubkyPublicKeyFormat.matches(identityStatus.publicKey, proof.identity) ) { - return + return false } val record = paykitSdkService.paymentRequests().firstOrNull { it.paymentRequestId == proof.requestId.paymentRequestId && PubkyPublicKeyFormat.matches(it.counterparty, proof.requestId.counterparty) && it.counterpartyReceiverPath == proof.requestId.counterpartyReceiverPath - } ?: return + } ?: return false val proofJson = proofJson(proof.kind, proofData) val alreadyQueued = record.paymentProofs.any { it.billingPeriod == null && @@ -307,11 +428,7 @@ class PaykitPaymentProofRepo @Inject constructor( ) } } - runSuspendCatching { - removeProofsLocked { - PubkyPublicKeyFormat.matches(it.identity, proof.identity) && it.requestId == proof.requestId - } - }.onFailure { Logger.warn("Failed to clear a submitted Paykit payment proof", it, context = TAG) } + return true } private suspend fun removeProofs(predicate: (PendingPaykitPaymentProof) -> Boolean) = withContext(ioDispatcher) { @@ -327,6 +444,18 @@ class PaykitPaymentProofRepo @Inject constructor( if (remaining != current) persist(remaining) } + private suspend fun replaceProofLocked( + currentProof: PendingPaykitPaymentProof, + updatedProof: PendingPaykitPaymentProof, + ): Boolean { + val proofs = loadProofs().toMutableList() + val index = proofs.indexOf(currentProof) + if (index < 0) return false + proofs[index] = updatedProof + persist(proofs) + return true + } + private suspend fun persistAndSubmit( completedProofs: List, allProofs: List, @@ -363,6 +492,7 @@ class PaykitPaymentProofRepo @Inject constructor( requestId = request.id, paymentEndpointIdentifier = paymentEndpointIdentifier, kind = kind, + preparationId = UUID.randomUUID().toString(), ) } @@ -381,6 +511,9 @@ private fun endpointSupports(identifier: String, kind: PaykitPaymentProofKind): } } +private fun PendingPaykitPaymentProof.matchesRequest(other: PendingPaykitPaymentProof): Boolean = + PubkyPublicKeyFormat.matches(identity, other.identity) && requestId == other.requestId + private fun proofJson(kind: PaykitPaymentProofKind, data: String): String = buildJsonObject { put("data", JsonPrimitive(data)) put("type", JsonPrimitive(kind.type)) diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 4e95520c70..f5c35fc5e6 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -23,6 +23,7 @@ import org.lightningdevkit.ldknode.BackgroundSyncConfig import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.Bolt11Invoice import org.lightningdevkit.ldknode.Bolt11InvoiceDescription +import org.lightningdevkit.ldknode.BroadcastOutcome import org.lightningdevkit.ldknode.BuildException import org.lightningdevkit.ldknode.Builder import org.lightningdevkit.ldknode.ChannelConfig @@ -71,6 +72,7 @@ import to.bitkit.utils.LdkError import to.bitkit.utils.LdkLogWriter import to.bitkit.utils.Logger import to.bitkit.utils.LoggerLdk +import to.bitkit.utils.PendingOnchainBroadcastError import to.bitkit.utils.ServiceError import to.bitkit.utils.jsonLogOf import java.io.File @@ -915,20 +917,23 @@ class LightningService @Inject constructor( ): Txid { val node = this.node ?: throw ServiceError.NodeNotSetup() - Logger.info( - "Sending $sats sats to $address, satsPerVByte=$satsPerVByte, isMaxAmount = $isMaxAmount", - context = TAG, - ) - return ServiceQueue.LDK.background { + val onchainPayment = node.onchainPayment() + onchainPayment.listPendingBroadcasts().firstOrNull()?.let { + throw PendingOnchainBroadcastError(it.txid) + } + Logger.info( + "Sending '$sats' sats to '$address', satsPerVByte='$satsPerVByte', isMaxAmount='$isMaxAmount'", + context = TAG, + ) if (isMaxAmount) { - node.onchainPayment().sendAllToAddress( + onchainPayment.sendAllToAddress( address = address, retainReserve = true, feeRate = FeeRate.fromSatPerVbUnchecked(satsPerVByte), ) } else { - node.onchainPayment().sendToAddress( + onchainPayment.sendToAddress( address = address, amountSats = sats, feeRate = FeeRate.fromSatPerVbUnchecked(satsPerVByte), @@ -1331,6 +1336,20 @@ class LightningService @Inject constructor( node.listPayments() } } + + suspend fun getOnchainBroadcastOutcome(txid: Txid): BroadcastOutcome? { + val node = this.node ?: throw ServiceError.NodeNotSetup() + return ServiceQueue.LDK.background { + node.onchainPayment().broadcastOutcome(txid) + } + } + + suspend fun acknowledgeOnchainBroadcastOutcome(txid: Txid) { + val node = this.node ?: throw ServiceError.NodeNotSetup() + ServiceQueue.LDK.background { + node.onchainPayment().acknowledgeBroadcastOutcome(txid) + } + } // endregion // region debug diff --git a/app/src/main/java/to/bitkit/utils/Errors.kt b/app/src/main/java/to/bitkit/utils/Errors.kt index 7b144e2272..e82ff2461b 100644 --- a/app/src/main/java/to/bitkit/utils/Errors.kt +++ b/app/src/main/java/to/bitkit/utils/Errors.kt @@ -4,6 +4,7 @@ package to.bitkit.utils import org.lightningdevkit.ldknode.BuildException import org.lightningdevkit.ldknode.NodeException +import org.lightningdevkit.ldknode.Txid open class AppError( override val message: String? = null, @@ -29,6 +30,18 @@ sealed class ServiceError(message: String) : AppError(message) { class HttpError(message: String, val code: Int = 500, cause: Throwable? = null) : AppError(message, cause) +class PendingOnchainBroadcastError(val txid: Txid) : AppError( + "On-chain transaction '$txid' has unresolved broadcast status", +) + +sealed interface PendingOnchainBroadcast { + val txid: Txid + + data class Current(override val txid: Txid) : PendingOnchainBroadcast + + data class Existing(override val txid: Txid) : PendingOnchainBroadcast +} + // region ldk class LdkError(private val inner: LdkException) : AppError("Unknown LDK error.") { constructor(inner: BuildException) : this(LdkException.Build(inner)) @@ -135,6 +148,22 @@ fun Throwable.asNodeException(): NodeException? = when (this) { is LdkError -> nodeExceptionOrNull() else -> cause?.asNodeException() } + +fun Throwable.asPendingOnchainBroadcast(): PendingOnchainBroadcast? { + val existing = asPendingOnchainBroadcastError() + if (existing != null) return PendingOnchainBroadcast.Existing(existing.txid) + + return when (val nodeException = asNodeException()) { + is NodeException.OnchainTxBroadcastFailed -> PendingOnchainBroadcast.Current(nodeException.txid) + is NodeException.OnchainTxBroadcastTimeout -> PendingOnchainBroadcast.Current(nodeException.txid) + else -> null + } +} + +private fun Throwable.asPendingOnchainBroadcastError(): PendingOnchainBroadcastError? = when (this) { + is PendingOnchainBroadcastError -> this + else -> cause?.asPendingOnchainBroadcastError() +} // endregion fun Throwable.isTxSyncTimeout(): Boolean = this is NodeException.TxSyncTimeout || cause is NodeException.TxSyncTimeout diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 91dfb48792..2e45192777 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -188,6 +188,8 @@ import to.bitkit.utils.AppError import to.bitkit.utils.Bip21Utils import to.bitkit.utils.Logger import to.bitkit.utils.NetworkValidationHelper +import to.bitkit.utils.PendingOnchainBroadcast +import to.bitkit.utils.asPendingOnchainBroadcast import to.bitkit.utils.jsonLogOf import to.bitkit.utils.timedsheets.TimedSheetManager import to.bitkit.utils.timedsheets.sheets.AppUpdateTimedSheet @@ -3336,16 +3338,16 @@ class AppViewModel @Inject constructor( if (!validateIncomingPaymentRequest(contactPaymentContext)) return val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest - var preparedPaymentProofRequest = preparePaymentProof(incomingPaymentRequest).getOrNull() + val preparedPaymentProof = preparePaymentProof(incomingPaymentRequest).getOrNull() consumePrivatePaymentListIfNeeded(contactPaymentContext).onFailure { - cancelPaymentProofPreparation(preparedPaymentProofRequest) + cancelPaymentProofPreparation(preparedPaymentProof) handlePaymentPreparationFailure(it) return } acceptIncomingPaymentRequestIfNeeded(contactPaymentContext).onFailure { - cancelPaymentProofPreparation(preparedPaymentProofRequest) + cancelPaymentProofPreparation(preparedPaymentProof) handlePaymentPreparationFailure(it) return } @@ -3366,7 +3368,7 @@ class AppViewModel @Inject constructor( it.copy(decodedInvoice = invoice) } }.onFailure { - cancelPaymentProofPreparation(preparedPaymentProofRequest) + cancelPaymentProofPreparation(preparedPaymentProof) val message = getLnurlInvoiceFetchErrorMessage(it) toast(Exception(message)) hideSheet() @@ -3380,7 +3382,6 @@ class AppViewModel @Inject constructor( val tags = _sendUiState.value.selectedTags sendOnchain(address, amount, tags = tags) .onSuccess { txId -> - preparedPaymentProofRequest = null completeOnchainPaymentProofInBackground(incomingPaymentRequest, txId) Logger.info("Onchain send result txid: $txId", context = TAG) onSendSuccess( @@ -3396,13 +3397,32 @@ class AppViewModel @Inject constructor( activityRepo.syncActivities() _successSendUiState.update { it.copy(isLoadingDetails = false) } }.onFailure { e -> - cancelPaymentProofPreparation(preparedPaymentProofRequest) Logger.error("Error sending onchain payment", e, context = TAG) - toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.wallet__error_sending_title), - description = e.message ?: context.getString(R.string.common__error_body) - ) + when (val pendingBroadcast = e.asPendingOnchainBroadcast()) { + is PendingOnchainBroadcast.Current -> { + associateOnchainPaymentProof( + request = incomingPaymentRequest, + txid = pendingBroadcast.txid, + preparationId = preparedPaymentProof?.id, + ) + showPendingOnchainBroadcast(pendingBroadcast.txid) + } + + is PendingOnchainBroadcast.Existing -> { + cancelPaymentProofPreparation(preparedPaymentProof) + showPendingOnchainBroadcast(pendingBroadcast.txid) + } + + null -> { + cancelPaymentProofPreparation(preparedPaymentProof) + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.wallet__error_sending_title), + description = e.message ?: context.getString(R.string.common__error_body), + testTag = "OnchainSendFailedToast", + ) + } + } hideSheet() } } @@ -3420,8 +3440,7 @@ class AppViewModel @Inject constructor( // Extract payment hash from invoice for pre-activity metadata val paymentHash = decodedInvoice.paymentHash.toHex() associateLightningPaymentProof(incomingPaymentRequest, paymentHash).onFailure { - cancelPaymentProofPreparation(preparedPaymentProofRequest) - preparedPaymentProofRequest = null + cancelPaymentProofPreparation(preparedPaymentProof) } // Create pre-activity metadata before sending @@ -3438,7 +3457,6 @@ class AppViewModel @Inject constructor( } sendLightning(bolt11, paymentAmount).onSuccess { actualPaymentHash -> - preparedPaymentProofRequest = null Logger.info("Lightning send result payment hash: $actualPaymentHash", context = TAG) onSendSuccess( NewTransactionSheetDetails( @@ -3450,7 +3468,6 @@ class AppViewModel @Inject constructor( ) }.onFailure { if (it is PaymentPendingException) { - preparedPaymentProofRequest = null Logger.info("Lightning payment pending", context = TAG) pendingPaymentRepo.track(it.paymentHash) preserveContactPaymentContext(it.paymentHash) @@ -3458,7 +3475,7 @@ class AppViewModel @Inject constructor( return@onFailure } paykitPaymentProofRepo.failLightningPayment(paymentHash) - cancelPaymentProofPreparation(preparedPaymentProofRequest) + cancelPaymentProofPreparation(preparedPaymentProof) // Delete pre-activity metadata on failure if (createdMetadataPaymentId != null) { preActivityMetadataRepo.deletePreActivityMetadata(createdMetadataPaymentId) @@ -3500,14 +3517,14 @@ class AppViewModel @Inject constructor( contactPaymentContext != null && synchronized(contactPaymentContextLock) { preparedContactPaymentContext == contactPaymentContext } - private suspend fun preparePaymentProof(request: PaykitPaymentRequest?): Result { + private suspend fun preparePaymentProof(request: PaykitPaymentRequest?): Result { if (request == null) return Result.success(null) val preparation = paymentProofPreparation() return paykitPaymentProofRepo.prepare( request = request, paymentEndpointIdentifier = preparation.endpointIdentifier, kind = preparation.kind, - ).map { request } + ).map { PreparedPaymentProof(it) } } private suspend fun associateLightningPaymentProof( @@ -3522,6 +3539,31 @@ class AppViewModel @Inject constructor( } ?: Result.success(Unit) + private suspend fun associateOnchainPaymentProof( + request: PaykitPaymentRequest?, + txid: String, + preparationId: String?, + ): Result = request?.let { + paykitPaymentProofRepo.associateOnchainPayment( + request = it, + txid = txid, + paymentEndpointIdentifier = paymentProofPreparation().endpointIdentifier, + preparationId = preparationId, + ) + } + ?: Result.success(Unit) + + private fun showPendingOnchainBroadcast(txid: String) { + toast( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.wallet__send_broadcast_unknown__title), + description = context.getString(R.string.wallet__send_broadcast_unknown__description) + .replace("{txid}", txid), + autoHide = false, + testTag = "OnchainBroadcastPendingToast", + ) + } + private fun completeOnchainPaymentProofInBackground(request: PaykitPaymentRequest?, txId: String) { val paymentRequest = request ?: return val endpointIdentifier = paymentProofPreparation().endpointIdentifier @@ -3534,8 +3576,8 @@ class AppViewModel @Inject constructor( } } - private suspend fun cancelPaymentProofPreparation(request: PaykitPaymentRequest?) { - request?.let { paykitPaymentProofRepo.cancelPreparation(it) } + private suspend fun cancelPaymentProofPreparation(preparation: PreparedPaymentProof?) { + preparation?.let { paykitPaymentProofRepo.cancelPreparation(it.id) } } private fun paymentProofPreparation(): PaymentProofPreparation { @@ -4342,9 +4384,9 @@ class AppViewModel @Inject constructor( if (isPreparedContactPayment(contactPaymentContext)) return true val incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest - val preparedPaymentProofRequest = preparePaymentProof(incomingPaymentRequest).getOrNull() + val preparedPaymentProof = preparePaymentProof(incomingPaymentRequest).getOrNull() if (!prepareContactPayment(contactPaymentContext)) { - cancelPaymentProofPreparation(preparedPaymentProofRequest) + cancelPaymentProofPreparation(preparedPaymentProof) return false } return true @@ -4866,6 +4908,10 @@ private data class PaymentProofPreparation( val kind: PaykitPaymentProofKind, ) +private data class PreparedPaymentProof( + val id: String, +) + private data class PaykitContactSyncState( val publicKey: String?, val contactKeys: Set, diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index a993616619..848f59eaa8 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -71,6 +71,7 @@ import to.bitkit.services.BoltzService import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.AppError import to.bitkit.utils.Logger +import to.bitkit.utils.asPendingOnchainBroadcast import javax.inject.Inject import kotlin.math.min import kotlin.math.roundToLong @@ -398,7 +399,21 @@ class TransferViewModel @Inject constructor( ) } } - .onFailure { ToastEventBus.send(it) } + .onFailure { + val pendingBroadcast = it.asPendingOnchainBroadcast() + if (pendingBroadcast == null) { + ToastEventBus.send(it) + return@onFailure + } + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.wallet__send_broadcast_unknown__title), + description = context.getString(R.string.wallet__send_broadcast_unknown__description) + .replace("{txid}", pendingBroadcast.txid), + autoHide = false, + testTag = "OnchainBroadcastPendingToast", + ) + } .isSuccess } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ed794ef4ec..6506cc309c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1297,6 +1297,8 @@ Available (savings) Available (spending) Send Bitcoin + Bitkit cannot confirm whether transaction {txid} was accepted. Do not send this payment again. + Payment Status Unknown Please copy an address or an invoice. Clipboard Empty Confirming in diff --git a/app/src/test/java/to/bitkit/ext/PaymentFailureReasonExtTest.kt b/app/src/test/java/to/bitkit/ext/PaymentFailureReasonExtTest.kt index 2fe3a8c264..5fafab6dcd 100644 --- a/app/src/test/java/to/bitkit/ext/PaymentFailureReasonExtTest.kt +++ b/app/src/test/java/to/bitkit/ext/PaymentFailureReasonExtTest.kt @@ -43,7 +43,7 @@ class PaymentFailureReasonExtTest { assertEquals(message, Exception(" ").toSendFailureMessage(context)) assertEquals( message, - LdkError(NodeException.DuplicatePayment("Duplicate payment.")).toSendFailureMessage(context), + LdkError(NodeException.DuplicatePayment()).toSendFailureMessage(context), ) } @@ -56,11 +56,11 @@ class PaymentFailureReasonExtTest { fun `compact failure types use android ldk error classes`() { assertEquals( "DuplicatePayment", - LdkError(NodeException.DuplicatePayment("Duplicate payment.")).toCompactFailureType(), + LdkError(NodeException.DuplicatePayment()).toCompactFailureType(), ) assertEquals( "InvalidCustomTlvs", - LdkError(NodeException.InvalidCustomTlvs("Invalid custom TLVs")).toCompactFailureType(), + LdkError(NodeException.InvalidCustomTlvs()).toCompactFailureType(), ) } diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 76c9c99ad0..d1cbb270aa 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -29,6 +29,7 @@ import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.Node +import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PeerDetails @@ -893,15 +894,16 @@ class LightningRepoTest : BaseUnitTest() { } @Test - fun `sendOnChain should cache activity meta data`() = test { + fun `accepted onchain send should create metadata and sent activity`() = test { val mockSettingsData = SettingsData( defaultTransactionSpeed = TransactionSpeed.Fast, coinSelectAuto = false // Disable auto coin selection to simplify the test ) + val activityService = mock() whenever(settingsStore.data).thenReturn(flowOf(mockSettingsData)) whenever(preActivityMetadataRepo.addPreActivityMetadata(any())).thenReturn(Result.success(Unit)) - whenever(coreService.activity).thenReturn(mock()) + whenever(coreService.activity).thenReturn(activityService) whenever( lightningService.send( @@ -937,6 +939,69 @@ class LightningRepoTest : BaseUnitTest() { verifyBlocking(preActivityMetadataRepo) { addPreActivityMetadata(any()) } + verify(activityService).createSentOnchainActivityFromSendResult( + txid = "testPaymentId", + address = "test_address", + amount = 1000uL, + fee = 0uL, + feeRate = 10uL, + isTransfer = true, + channelId = "test_channel_id", + ) + } + + @Test + fun `unsuccessful onchain send should not create metadata or sent activity`() = test { + val errors = listOf( + NodeException.OnchainTxBroadcastRejected("Broadcast rejected"), + NodeException.OnchainTxBroadcastNotDispatched("Broadcast not dispatched"), + NodeException.OnchainTxBroadcastFailed("Broadcast failed"), + NodeException.OnchainTxBroadcastTimeout("Broadcast timed out"), + ) + val activityService = mock() + whenever(settingsStore.data).thenReturn( + flowOf( + SettingsData( + defaultTransactionSpeed = TransactionSpeed.Fast, + coinSelectAuto = false, + ) + ) + ) + whenever(coreService.activity).thenReturn(activityService) + startNodeForTesting() + val spySut = spy(sut) + doReturn(Result.success(10uL)).whenever(spySut).getFeeRateForSpeed(any(), anyOrNull()) + + errors.forEach { error -> + whenever( + lightningService.send( + address = any(), + sats = any(), + satsPerVByte = any(), + utxosToSpend = anyOrNull(), + isMaxAmount = any(), + ) + ).thenAnswer { throw error } + + val result = spySut.sendOnChain( + address = "test_address", + sats = 1000uL, + speed = TransactionSpeed.Fast, + ) + + assertEquals(error, result.exceptionOrNull()) + } + verifyBlocking(preActivityMetadataRepo, never()) { addPreActivityMetadata(any()) } + verify(activityService, never()).createSentOnchainActivityFromSendResult( + txid = any(), + address = any(), + amount = any(), + fee = any(), + feeRate = any(), + isTransfer = any(), + channelId = anyOrNull(), + walletId = any(), + ) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt index f061139780..6361126fb1 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentProofRepoTest.kt @@ -12,6 +12,8 @@ import com.synonym.paykit.PrivateJsonObject import kotlinx.coroutines.test.StandardTestDispatcher import org.junit.Before import org.junit.Test +import org.lightningdevkit.ldknode.BroadcastOutcome +import org.lightningdevkit.ldknode.BroadcastOutcomeStatus import org.lightningdevkit.ldknode.PaymentDetails import org.lightningdevkit.ldknode.PaymentDirection import org.lightningdevkit.ldknode.PaymentKind @@ -59,6 +61,8 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { whenever(store.hasPendingProofs()).thenReturn(true) whenever(paykitSdkService.identityStatus()).thenReturn(IdentityStatus(LOCAL_IDENTITY, true)) whenever(paykitSdkService.processPendingPrivateMessages()).thenReturn(emptyList()) + whenever(lightningRepo.getOnchainBroadcastOutcome(any())).thenReturn(Result.success(null)) + whenever(lightningRepo.acknowledgeOnchainBroadcastOutcome(any())).thenReturn(Result.success(Unit)) whenever(store.load()).thenAnswer { if (shouldFailNextLoad) { shouldFailNextLoad = false @@ -316,6 +320,195 @@ class PaykitPaymentProofRepoTest : BaseUnitTest(StandardTestDispatcher()) { assertTrue(storedProofs.isEmpty()) } + @Test + fun `associated onchain proof remains unsubmitted after repository restart`() = test { + val txid = "ab".repeat(32) + val endpoint = MethodId.P2wpkh.rawValue + val request = paymentRequest(endpoint) + val firstRepo = paymentProofRepo() + + val preparationId = firstRepo + .prepare(request, endpoint, PaykitPaymentProofKind.Onchain) + .getOrThrow() + firstRepo.associateOnchainPayment(request, txid, endpoint, preparationId).getOrThrow() + + assertEquals(txid, storedProofs.single().paymentIdentifier) + assertNull(storedProofs.single().proofData) + assertEquals(listOf(txid), storedProofs.single().broadcastLineage) + assertTrue(storedProofs.single().requiresBroadcastOutcome) + + paymentProofRepo().reconcile() + + assertEquals(txid, storedProofs.single().paymentIdentifier) + assertNull(storedProofs.single().proofData) + verify(lightningRepo, never()).getPayments() + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any()) + verify(lightningRepo, never()).acknowledgeOnchainBroadcastOutcome(any()) + } + + @Test + fun `pending onchain outcome persists canonical replacement lineage without delivery`() = test { + val txid = "ab".repeat(32) + val replacementTxid = "cd".repeat(32) + val endpoint = MethodId.P2wpkh.rawValue + val request = paymentRequest(endpoint) + val repo = paymentProofRepo() + val preparationId = repo.prepare(request, endpoint, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.associateOnchainPayment(request, txid, endpoint, preparationId).getOrThrow() + whenever(lightningRepo.getOnchainBroadcastOutcome(txid)).thenReturn( + Result.success( + BroadcastOutcome( + status = BroadcastOutcomeStatus.PENDING, + txid = replacementTxid, + lineage = listOf(txid, replacementTxid), + ), + ), + ) + + repo.reconcile() + + assertEquals(replacementTxid, storedProofs.single().paymentIdentifier) + assertEquals(listOf(txid, replacementTxid), storedProofs.single().broadcastLineage) + assertNull(storedProofs.single().proofData) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any()) + verify(lightningRepo, never()).acknowledgeOnchainBroadcastOutcome(any()) + } + + @Test + fun `onchain outcome query failure preserves the correlated proof`() = test { + val txid = "ab".repeat(32) + val endpoint = MethodId.P2wpkh.rawValue + val request = paymentRequest(endpoint) + val repo = paymentProofRepo() + val preparationId = repo.prepare(request, endpoint, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.associateOnchainPayment(request, txid, endpoint, preparationId).getOrThrow() + val expectedProof = storedProofs.single() + whenever(lightningRepo.getOnchainBroadcastOutcome(txid)) + .thenReturn(Result.failure(IllegalStateException("temporary outcome failure"))) + + repo.reconcile() + + assertEquals(expectedProof, storedProofs.single()) + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any()) + verify(lightningRepo, never()).acknowledgeOnchainBroadcastOutcome(any()) + } + + @Test + fun `accepted onchain outcome delivers canonical proof before acknowledgment`() = test { + val txid = "ab".repeat(32) + val acceptedTxid = "cd".repeat(32) + val endpoint = MethodId.P2wpkh.rawValue + val request = paymentRequest(endpoint) + val record = paymentRequestRecord() + val repo = paymentProofRepo() + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any())).thenReturn(record) + val preparationId = repo.prepare(request, endpoint, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.associateOnchainPayment(request, txid, endpoint, preparationId).getOrThrow() + whenever(lightningRepo.getOnchainBroadcastOutcome(txid)).thenReturn( + Result.success( + BroadcastOutcome( + status = BroadcastOutcomeStatus.ACCEPTED, + txid = acceptedTxid, + lineage = listOf(txid, acceptedTxid), + ), + ), + ) + + repo.reconcile() + + val proofCaptor = argumentCaptor() + verify(paykitSdkService).submitPaymentProof( + counterparty = any(), + counterpartyReceiverPath = any(), + paymentRequestId = any(), + paymentEndpointIdentifier = eq(endpoint), + proofJson = proofCaptor.capture(), + ) + assertEquals( + """{"data":"$acceptedTxid","type":"${PaykitPaymentProofKind.Onchain.type}"}""", + proofCaptor.firstValue, + ) + verify(lightningRepo).acknowledgeOnchainBroadcastOutcome(acceptedTxid) + assertTrue(storedProofs.isEmpty()) + } + + @Test + fun `abandoned onchain outcome removes proof before acknowledgment`() = test { + val txid = "ab".repeat(32) + val abandonedTxid = "cd".repeat(32) + val endpoint = MethodId.P2wpkh.rawValue + val request = paymentRequest(endpoint) + val repo = paymentProofRepo() + val preparationId = repo.prepare(request, endpoint, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.associateOnchainPayment(request, txid, endpoint, preparationId).getOrThrow() + whenever(lightningRepo.getOnchainBroadcastOutcome(txid)).thenReturn( + Result.success( + BroadcastOutcome( + status = BroadcastOutcomeStatus.ABANDONED, + txid = abandonedTxid, + lineage = listOf(txid, abandonedTxid), + ), + ), + ) + + repo.reconcile() + + verify(paykitSdkService, never()).submitPaymentProof(any(), any(), any(), any(), any()) + verify(lightningRepo).acknowledgeOnchainBroadcastOutcome(abandonedTxid) + assertTrue(storedProofs.isEmpty()) + } + + @Test + fun `terminal acknowledgment retries without duplicate proof delivery`() = test { + val txid = "ab".repeat(32) + val acceptedTxid = "cd".repeat(32) + val endpoint = MethodId.P2wpkh.rawValue + val request = paymentRequest(endpoint) + val record = paymentRequestRecord() + val repo = paymentProofRepo() + whenever(paykitSdkService.paymentRequests()).thenReturn(listOf(record)) + whenever(paykitSdkService.submitPaymentProof(any(), any(), any(), any(), any())).thenReturn(record) + whenever(lightningRepo.acknowledgeOnchainBroadcastOutcome(acceptedTxid)) + .thenReturn(Result.failure(IllegalStateException("temporary acknowledgment failure"))) + .thenReturn(Result.success(Unit)) + val preparationId = repo.prepare(request, endpoint, PaykitPaymentProofKind.Onchain).getOrThrow() + repo.associateOnchainPayment(request, txid, endpoint, preparationId).getOrThrow() + whenever(lightningRepo.getOnchainBroadcastOutcome(txid)).thenReturn( + Result.success( + BroadcastOutcome( + status = BroadcastOutcomeStatus.ACCEPTED, + txid = acceptedTxid, + lineage = listOf(txid, acceptedTxid), + ), + ), + ) + + repo.reconcile() + + assertTrue(storedProofs.single().broadcastOutcomeHandled) + assertNull(storedProofs.single().proofData) + + paymentProofRepo().reconcile() + + verify(paykitSdkService, times(1)).submitPaymentProof(any(), any(), any(), any(), any()) + verify(lightningRepo, times(2)).acknowledgeOnchainBroadcastOutcome(acceptedTxid) + assertTrue(storedProofs.isEmpty()) + } + + @Test + fun `cancel preparation preserves an earlier unassociated proof`() = test { + val endpoint = MethodId.P2wpkh.rawValue + val request = paymentRequest(endpoint) + val repo = paymentProofRepo() + val earlierPreparationId = repo.prepare(request, endpoint, PaykitPaymentProofKind.Onchain).getOrThrow() + val currentPreparationId = repo.prepare(request, endpoint, PaykitPaymentProofKind.Onchain).getOrThrow() + + repo.cancelPreparation(currentPreparationId) + + assertEquals(listOf(earlierPreparationId), storedProofs.map { it.preparationId }) + } + @Test fun `lightning retry preserves earlier payment correlation`() = test { val record = paymentRequestRecord() diff --git a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt index e004753765..23156ca831 100644 --- a/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/QuickPayRepoTest.kt @@ -291,7 +291,7 @@ class QuickPayRepoTest : BaseUnitTest() { @Test fun `acknowledge during delivery clears the unacked failure`() = test { val (bolt11, _) = testInvoice() - stubPayInvoiceFailure(NodeException.InvalidInvoice("bad")) + stubPayInvoiceFailure(NodeException.InvalidInvoice()) val session = QuickPaySession() val flushed = mutableListOf() val flushJob = launch { sut.unhandledFailures.collect { flushed += it } } @@ -507,7 +507,7 @@ class QuickPayRepoTest : BaseUnitTest() { @Test fun `lookup throw on duplicate still emits pending`() = test { val (bolt11, _) = testInvoice() - stubPayInvoiceFailure(NodeException.DuplicatePayment("dup")) + stubPayInvoiceFailure(NodeException.DuplicatePayment()) whenever { lightningRepo.listPaymentsOrNull() }.thenAnswer { error("uniffi") } val session = QuickPaySession() sut.attach(session).test { @@ -561,27 +561,27 @@ class QuickPayRepoTest : BaseUnitTest() { fun `classifies wrapped and unwrapped ldk errors`() { assertEquals( QuickPayDispatchClass.PRE_DISPATCH_REJECTION, - classifyDispatchError(NodeException.InvalidInvoice("bad")), + classifyDispatchError(NodeException.InvalidInvoice()), ) assertEquals( QuickPayDispatchClass.PRE_DISPATCH_REJECTION, - classifyDispatchError(LdkError(NodeException.InvalidInvoice("bad"))), + classifyDispatchError(LdkError(NodeException.InvalidInvoice())), ) assertEquals( QuickPayDispatchClass.DUPLICATE_PAYMENT, - classifyDispatchError(NodeException.DuplicatePayment("dup")), + classifyDispatchError(NodeException.DuplicatePayment()), ) assertEquals( QuickPayDispatchClass.DUPLICATE_PAYMENT, - classifyDispatchError(LdkError(NodeException.DuplicatePayment("dup"))), + classifyDispatchError(LdkError(NodeException.DuplicatePayment())), ) assertEquals( QuickPayDispatchClass.AMBIGUOUS, - classifyDispatchError(NodeException.PersistenceFailed("io")), + classifyDispatchError(NodeException.PersistenceFailed()), ) assertEquals( QuickPayDispatchClass.AMBIGUOUS, - classifyDispatchError(LdkError(NodeException.PaymentSendingFailed("send"))), + classifyDispatchError(LdkError(NodeException.PaymentSendingFailed())), ) } @@ -597,7 +597,7 @@ class QuickPayRepoTest : BaseUnitTest() { @Test fun `duplicate payment with pending ldk does not refund`() = test { val (bolt11, hash) = testInvoice() - stubPayInvoiceFailure(NodeException.DuplicatePayment("dup")) + stubPayInvoiceFailure(NodeException.DuplicatePayment()) paymentRows = listOf(pendingRow(hash)) val session = QuickPaySession() @@ -613,7 +613,7 @@ class QuickPayRepoTest : BaseUnitTest() { @Test fun `duplicate payment with succeeded ldk refunds a fresh reserve and emits already paid`() = test { val (bolt11, hash) = testInvoice() - stubPayInvoiceFailure(NodeException.DuplicatePayment("dup")) + stubPayInvoiceFailure(NodeException.DuplicatePayment()) paymentRows = listOf(succeededRow(hash)) val session = QuickPaySession() @@ -633,7 +633,7 @@ class QuickPayRepoTest : BaseUnitTest() { assertNotNull(sut.reserveBound(hash, 500u).getOrThrow()) sut.signalCompletion(paymentId = null, paymentHash = hash, success = true) assertEquals(250L, spentCents()) - stubPayInvoiceFailure(NodeException.DuplicatePayment("dup")) + stubPayInvoiceFailure(NodeException.DuplicatePayment()) paymentRows = listOf(succeededRow(hash)) val session = QuickPaySession() @@ -649,7 +649,7 @@ class QuickPayRepoTest : BaseUnitTest() { @Test fun `ambiguous pending emits pending and keeps spend`() = test { val (bolt11, hash) = testInvoice() - stubPayInvoiceFailure(NodeException.PaymentSendingFailed("send")) + stubPayInvoiceFailure(NodeException.PaymentSendingFailed()) paymentRows = listOf(pendingRow(hash)) val session = QuickPaySession() @@ -665,7 +665,7 @@ class QuickPayRepoTest : BaseUnitTest() { @Test fun `sync dispatch failure with failed ldk row refunds immediately`() = test { val (bolt11, hash) = testInvoice() - stubPayInvoiceFailure(NodeException.PaymentSendingFailed("send")) + stubPayInvoiceFailure(NodeException.PaymentSendingFailed()) paymentRows = listOf(failedRow(hash)) val session = QuickPaySession() @@ -720,7 +720,7 @@ class QuickPayRepoTest : BaseUnitTest() { @Test fun `rescan of a pending hash replays pending to a new session`() = test { val (bolt11, hash) = testInvoice() - stubPayInvoiceFailure(NodeException.DuplicatePayment("dup")) + stubPayInvoiceFailure(NodeException.DuplicatePayment()) paymentRows = listOf(pendingRow(hash)) val first = QuickPaySession() val second = QuickPaySession() @@ -741,7 +741,7 @@ class QuickPayRepoTest : BaseUnitTest() { @Test fun `rescan pending then success settles once`() = test { val (bolt11, hash) = testInvoice() - stubPayInvoiceFailure(NodeException.DuplicatePayment("dup")) + stubPayInvoiceFailure(NodeException.DuplicatePayment()) paymentRows = listOf(pendingRow(hash)) val first = QuickPaySession() val second = QuickPaySession() @@ -797,7 +797,7 @@ class QuickPayRepoTest : BaseUnitTest() { val first = QuickPaySession() sut.attach(first) sut.detachAll() - stubPayInvoiceFailure(NodeException.InvalidInvoice("bad")) + stubPayInvoiceFailure(NodeException.InvalidInvoice()) val second = QuickPaySession() sut.attach(second).test { sut.payNow(second, QuickPayPayRequest.Bolt11(bolt11 = testInvoice().first, amountSats = 500u)) @@ -809,7 +809,7 @@ class QuickPayRepoTest : BaseUnitTest() { fun `hasOpen is true for a live op or recovered row`() = test { val (bolt11, hash) = testInvoice() assertFalse(sut.hasOpen(hash)) - stubPayInvoiceFailure(NodeException.DuplicatePayment("dup")) + stubPayInvoiceFailure(NodeException.DuplicatePayment()) paymentRows = listOf(pendingRow(hash)) val session = QuickPaySession() sut.attach(session) @@ -887,7 +887,7 @@ class QuickPayRepoTest : BaseUnitTest() { assertEquals(250L, spentCents()) assertEquals(1, cacheStore.data.first().quickPayLedger!!.records.size) if (!onBeforeSend()) return@doSuspendableAnswer Result.failure(PaymentAbortedBeforeSend()) - Result.failure(LdkError(NodeException.InvalidInvoice("done"))) + Result.failure(LdkError(NodeException.InvalidInvoice())) } val session = QuickPaySession() sut.attach(session) @@ -958,7 +958,7 @@ class QuickPayRepoTest : BaseUnitTest() { @Test fun `pre-dispatch rejection refunds after dispatch`() = test { val (bolt11, _) = testInvoice() - stubPayInvoiceFailure(NodeException.InvalidInvoice("bad")) + stubPayInvoiceFailure(NodeException.InvalidInvoice()) val session = QuickPaySession() sut.attach(session).test { sut.payNow(session, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) @@ -971,7 +971,7 @@ class QuickPayRepoTest : BaseUnitTest() { @Test fun `null payment rows mutate nothing on duplicate`() = test { val (bolt11, _) = testInvoice() - stubPayInvoiceFailure(NodeException.DuplicatePayment("dup")) + stubPayInvoiceFailure(NodeException.DuplicatePayment()) val session = QuickPaySession() sut.attach(session).test { sut.payNow(session, QuickPayPayRequest.Bolt11(bolt11 = bolt11, amountSats = 500u)) diff --git a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt index d4285c2efd..837bc5ec70 100644 --- a/app/src/test/java/to/bitkit/services/LightningServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/LightningServiceTest.kt @@ -4,6 +4,8 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay @@ -22,6 +24,9 @@ import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.OnchainPayment import org.lightningdevkit.ldknode.OnchainWalletAccount +import org.lightningdevkit.ldknode.PendingBroadcastInfo +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock @@ -43,11 +48,14 @@ import to.bitkit.models.WatchOnlyAccountRecord import to.bitkit.models.WatchOnlyAccountSetupState import to.bitkit.test.BaseUnitTest import to.bitkit.utils.LoggerLdk +import to.bitkit.utils.PendingOnchainBroadcast import to.bitkit.utils.ServiceError +import to.bitkit.utils.asPendingOnchainBroadcast import java.util.concurrent.CountDownLatch import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Duration.Companion.milliseconds @@ -108,6 +116,80 @@ class LightningServiceTest : BaseUnitTest() { assertTrue(sut.canReceive()) } + @Test + fun `send blocks while a broadcast is pending`() = test { + val txid = "ab".repeat(32) + val onchainPayment = mock() + whenever(node.onchainPayment()).thenReturn(onchainPayment) + whenever(onchainPayment.listPendingBroadcasts()) + .thenReturn(listOf(PendingBroadcastInfo(txid, listOf(txid)))) + + val result = runCatching { sut.send("bcrt1qpending", 1_000uL, 1uL) } + + val pending = assertIs( + result.exceptionOrNull()?.asPendingOnchainBroadcast(), + ) + assertEquals(txid, pending.txid) + verify(onchainPayment, never()).sendToAddress(any(), any(), anyOrNull(), anyOrNull()) + } + + @Test + fun `send blocks when pending broadcast lookup fails`() = test { + val onchainPayment = mock() + whenever(node.onchainPayment()).thenReturn(onchainPayment) + whenever(onchainPayment.listPendingBroadcasts()).thenThrow(NodeException.PersistenceFailed()) + + val result = runCatching { sut.send("bcrt1qlookupfailure", 1_000uL, 1uL) } + + assertTrue(result.isFailure) + verify(onchainPayment, never()).sendToAddress(any(), any(), anyOrNull(), anyOrNull()) + } + + @Test + fun `concurrent sends create only one transaction after an unknown broadcast`() = test { + val txid = "ab".repeat(32) + val pending = AtomicBoolean(false) + val onchainPayment = mock() + whenever(node.onchainPayment()).thenReturn(onchainPayment) + whenever(onchainPayment.listPendingBroadcasts()).thenAnswer { + if (pending.get()) listOf(PendingBroadcastInfo(txid, listOf(txid))) else emptyList() + } + whenever(onchainPayment.sendToAddress(any(), any(), anyOrNull(), anyOrNull())).thenAnswer { + pending.set(true) + throw NodeException.OnchainTxBroadcastTimeout(txid) + } + + val results = listOf( + async { runCatching { sut.send("bcrt1qfirst", 1_000uL, 1uL) } }, + async { runCatching { sut.send("bcrt1qsecond", 2_000uL, 1uL) } }, + ).awaitAll() + + assertEquals( + setOf(PendingOnchainBroadcast.Current(txid), PendingOnchainBroadcast.Existing(txid)), + results.mapNotNull { it.exceptionOrNull()?.asPendingOnchainBroadcast() }.toSet(), + ) + verify(onchainPayment, times(1)).sendToAddress(any(), any(), anyOrNull(), anyOrNull()) + } + + @Test + fun `conclusive broadcast failure allows a later send`() = test { + val rejectedTxid = "ab".repeat(32) + val acceptedTxid = "cd".repeat(32) + val onchainPayment = mock() + whenever(node.onchainPayment()).thenReturn(onchainPayment) + whenever(onchainPayment.listPendingBroadcasts()).thenReturn(emptyList()) + whenever(onchainPayment.sendToAddress(any(), any(), anyOrNull(), anyOrNull())) + .thenThrow(NodeException.OnchainTxBroadcastNotDispatched(rejectedTxid)) + .thenReturn(acceptedTxid) + + val first = runCatching { sut.send("bcrt1qnotdispatched", 1_000uL, 1uL) } + val second = sut.send("bcrt1qaccepted", 1_000uL, 1uL) + + assertTrue(first.isFailure) + assertEquals(acceptedTxid, second) + verify(onchainPayment, times(2)).sendToAddress(any(), any(), anyOrNull(), anyOrNull()) + } + @Test fun `stop destroys the node handle and clears it`() = test { sut.stop() @@ -119,7 +201,7 @@ class LightningServiceTest : BaseUnitTest() { @Test fun `stop destroys the node handle when it is already not running`() = test { - whenever(node.stop()).thenThrow(NodeException.NotRunning("not running")) + whenever(node.stop()).thenThrow(NodeException.NotRunning()) sut.stop() @@ -403,7 +485,7 @@ class LightningServiceTest : BaseUnitTest() { // Regression: a failing node stop must still release the handle instead of rethrowing and leaking it @Test fun `stop destroys the node handle when node stop throws`() = test { - whenever(node.stop()).thenThrow(NodeException.ConnectionFailed("boom")) + whenever(node.stop()).thenThrow(NodeException.ConnectionFailed()) sut.stop() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 75eb224dda..6d6a765aed 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -41,6 +41,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.lightningdevkit.ldknode.Event +import org.lightningdevkit.ldknode.NodeException import org.lightningdevkit.ldknode.PaymentFailureReason import org.lightningdevkit.ldknode.SpendableUtxo import org.lightningdevkit.ldknode.TransactionDetails @@ -143,6 +144,7 @@ import to.bitkit.ui.utils.ScreenDeepLinks import to.bitkit.usecases.FormatMoneyValue import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase import to.bitkit.utils.AppError +import to.bitkit.utils.PendingOnchainBroadcastError import to.bitkit.utils.timedsheets.TimedSheetManager import java.math.BigDecimal import java.net.URLEncoder @@ -303,7 +305,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } whenever(paykitPaymentRequestRepo.isPending(any())).thenReturn(true) whenever(paykitPaymentRequestRepo.isProcessing(any())).thenReturn(false) - whenever { paykitPaymentProofRepo.prepare(any(), any(), any()) }.thenReturn(Result.success(Unit)) + whenever { paykitPaymentProofRepo.prepare(any(), any(), any()) }.thenReturn(Result.success(PREPARATION_ID)) whenever { paykitPaymentProofRepo.associateLightningPayment( any(), @@ -311,6 +313,14 @@ class AppViewModelSendFlowTest : BaseUnitTest() { any(), ) }.thenReturn(Result.success(Unit)) + whenever { + paykitPaymentProofRepo.associateOnchainPayment( + any(), + any(), + any(), + anyOrNull(), + ) + }.thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.initialLinkBurstStarted).thenReturn(MutableSharedFlow()) whenever { privatePaykitRepo.prepareSavedContacts(any>(), any()) } .thenReturn(Result.success(Unit)) @@ -4122,7 +4132,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentProofRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning)) .doSuspendableAnswer { setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) - Result.success(Unit) + Result.success(PREPARATION_ID) } whenever { paykitPaymentProofRepo.associateLightningPayment(any(), any(), any()) } .thenReturn(Result.failure(IllegalStateException("proof unavailable"))) @@ -4145,7 +4155,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentProofRepo).prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning) verify(paykitPaymentProofRepo).associateLightningPayment(request, paymentHash, MethodId.Bolt11.rawValue) - verify(paykitPaymentProofRepo).cancelPreparation(request) + verify(paykitPaymentProofRepo).cancelPreparation(PREPARATION_ID) verify(lightningRepo).payInvoice(bolt11 = bolt11, sats = null) } @@ -4159,7 +4169,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentProofRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning)) .doSuspendableAnswer { setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) - Result.success(Unit) + Result.success(PREPARATION_ID) } whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -4204,7 +4214,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentProofRepo.prepare(request, MethodId.Bolt11.rawValue, PaykitPaymentProofKind.Lightning)) .doSuspendableAnswer { setSendState(sut.sendUiState.value.copy(decodedInvoice = lightningInvoice(bolt11, request.amountSats))) - Result.success(Unit) + Result.success(PREPARATION_ID) } whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) @@ -4235,7 +4245,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) verify(lightningRepo).payInvoice(bolt11 = bolt11, sats = null) verify(paykitPaymentProofRepo).failLightningPayment(invoicePaymentHash) - verify(paykitPaymentProofRepo).cancelPreparation(request) + verify(paykitPaymentProofRepo).cancelPreparation(PREPARATION_ID) } } @@ -4516,6 +4526,170 @@ class AppViewModelSendFlowTest : BaseUnitTest() { confirmCurrentPayment() } + @Test + fun `accepted onchain send presents success`() = test { + val address = "bcrt1qaccepted" + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever { + lightningRepo.sendOnChain( + address = address, + sats = 1000u, + speed = TransactionSpeed.Medium, + utxosToSpend = null, + isMaxAmount = false, + tags = emptyList(), + ) + }.thenReturn(Result.success("accepted-txid")) + whenever(lightningRepo.sync()).thenReturn(Result.success(Unit)) + whenever(activityRepo.syncActivities()).thenReturn(Result.success(Unit)) + setSendState( + SendUiState( + address = address, + amount = 1000u, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + ), + ) + + sut.sendEffect.test { + confirmCurrentPayment() + + assertEquals(SendEffect.PaymentSuccess, awaitItem()) + } + assertEquals("accepted-txid", sut.successSendUiState.value.paymentHashOrTxId) + } + + @Test + fun `rejected onchain send does not present success`() = test { + val address = "bcrt1qrejected" + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever { + lightningRepo.sendOnChain( + address = address, + sats = 1000u, + speed = TransactionSpeed.Medium, + utxosToSpend = null, + isMaxAmount = false, + tags = emptyList(), + ) + }.thenReturn(Result.failure(AppError("Broadcast rejected"))) + setSendState( + SendUiState( + address = address, + amount = 1000u, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + ), + ) + + sut.sendEffect.test { + confirmCurrentPayment() + + expectNoEvents() + } + verify(toastManager).enqueue( + check { + assertEquals("OnchainSendFailedToast", it.testTag) + } + ) + assertNull(sut.successSendUiState.value.paymentHashOrTxId) + } + + @Test + fun `unknown onchain send associates its proof and remains user visible`() = test { + val address = "bcrt1qunknown" + val txid = "ab".repeat(32) + val request = paymentRequest() + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever { + lightningRepo.sendOnChain( + address = address, + sats = request.amountSats, + speed = TransactionSpeed.Medium, + utxosToSpend = null, + isMaxAmount = false, + tags = emptyList(), + ) + }.thenReturn(Result.failure(AppError(NodeException.OnchainTxBroadcastTimeout(txid)))) + whenever(context.getString(R.string.wallet__send_broadcast_unknown__title)) + .thenReturn("Payment Status Unknown") + whenever(context.getString(R.string.wallet__send_broadcast_unknown__description)) + .thenReturn("Transaction {txid} is unknown. Do not send again.") + setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = request) + setSendState( + SendUiState( + address = address, + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + ), + ) + + sut.sendEffect.test { + confirmCurrentPayment() + + expectNoEvents() + } + + verify(paykitPaymentProofRepo).associateOnchainPayment( + request, + txid, + MethodId.P2wpkh.rawValue, + PREPARATION_ID, + ) + verify(paykitPaymentProofRepo, never()).cancelPreparation(any()) + verify(toastManager).enqueue( + check { + assertEquals(Toast.ToastType.WARNING, it.type) + assertEquals("Payment Status Unknown", it.title) + assertEquals("Transaction $txid is unknown. Do not send again.", it.description) + assertFalse(it.autoHide) + assertEquals("OnchainBroadcastPendingToast", it.testTag) + } + ) + assertNull(sut.successSendUiState.value.paymentHashOrTxId) + } + + @Test + fun `existing pending broadcast cancels only the new proof preparation`() = test { + val address = "bcrt1qblocked" + val txid = "ab".repeat(32) + val request = paymentRequest() + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever { + lightningRepo.sendOnChain( + address = address, + sats = request.amountSats, + speed = TransactionSpeed.Medium, + utxosToSpend = null, + isMaxAmount = false, + tags = emptyList(), + ) + }.thenReturn(Result.failure(AppError(PendingOnchainBroadcastError(txid)))) + setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = request) + setSendState( + SendUiState( + address = address, + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + ), + ) + + confirmCurrentPayment() + + verify(paykitPaymentProofRepo).cancelPreparation(PREPARATION_ID) + verify(paykitPaymentProofRepo, never()).associateOnchainPayment(any(), any(), any(), anyOrNull()) + verify(toastManager).enqueue( + check { assertEquals("OnchainBroadcastPendingToast", it.testTag) } + ) + assertNull(sut.successSendUiState.value.paymentHashOrTxId) + } + @Test fun `private lightning contact payment consumes private list before send`() = test { val bolt11 = "lnbcrt1privatecontact" @@ -5195,4 +5369,5 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private const val SAMROCK_SETUP_URL = "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret" private const val HARDWARE_WALLET_ID = "trezor:wallet" +private const val PREPARATION_ID = "preparation-id" private const val REGTEST_ADDRESS = "bcrt1qs04g2ka4pr9s3mv73nu32tvfy7r3cxd27wkyu8" diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index e0e0214aeb..16563b2c5d 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -80,6 +80,7 @@ import to.bitkit.test.BaseUnitTest import to.bitkit.ui.screens.transfer.previewBtOrder import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.AppError +import to.bitkit.utils.PendingOnchainBroadcastError import kotlin.math.roundToLong import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -1173,6 +1174,53 @@ class TransferViewModelTest : BaseUnitTest() { verify(cacheStore, never()).addPaidOrder(any(), any()) } + @Test + fun `onTransferToSpendingConfirm keeps an unknown broadcast warning visible`() = test { + val txid = "ab".repeat(32) + val order = previewBtOrder(feeSat = 98_000uL) + val selected = listOf(stubUtxo(100_000u)) + stubSpendableBalances(spendable = 100_000u) + whenever(lightningRepo.estimateSendAllFee(any(), any(), anyOrNull())) + .thenReturn(Result.success(1_000uL)) + whenever { + lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) + }.thenReturn(Result.success(selected)) + whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(1_000uL)) + whenever( + lightningRepo.sendOnChain( + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + any(), + anyOrNull(), + any(), + any(), + ), + ).thenReturn(Result.failure(AppError(PendingOnchainBroadcastError(txid)))) + whenever(context.getString(R.string.wallet__send_broadcast_unknown__title)) + .thenReturn("Payment Status Unknown") + whenever(context.getString(R.string.wallet__send_broadcast_unknown__description)) + .thenReturn("Transaction {txid} is unknown. Do not send again.") + val toasts = mutableListOf() + val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } + + sut.onTransferToSpendingConfirm(order) + advanceUntilIdle() + toastJob.cancel() + + assertFalse(sut.spendingUiState.value.isConfirmPaying) + assertEquals(1, toasts.size) + assertEquals(Toast.ToastType.WARNING, toasts.single().type) + assertEquals("Payment Status Unknown", toasts.single().title) + assertEquals("Transaction $txid is unknown. Do not send again.", toasts.single().description) + assertFalse(toasts.single().autoHide) + assertEquals("OnchainBroadcastPendingToast", toasts.single().testTag) + verify(cacheStore, never()).addPaidOrder(any(), any()) + } + @Test fun `onTransferToSpendingHwConfirm signs the funding send and records the paid order`() = test { val order = previewBtOrder() diff --git a/changelog.d/next/1225.fixed.md b/changelog.d/next/1225.fixed.md new file mode 100644 index 0000000000..73b232e154 --- /dev/null +++ b/changelog.d/next/1225.fixed.md @@ -0,0 +1 @@ +Prevented rejected or uncertain on-chain broadcasts from appearing as successful sends or allowing a duplicate payment. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 499e78316e..5e78639446 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -65,7 +65,7 @@ ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "k ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.66" } +ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.67" } lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } diff --git a/journeys/onchain-send/README.md b/journeys/onchain-send/README.md new file mode 100644 index 0000000000..8afadda4c0 --- /dev/null +++ b/journeys/onchain-send/README.md @@ -0,0 +1,27 @@ +# On-chain Send Journeys + +These journeys verify the user-visible boundary introduced by synonymdev/ldk-node#112: + +- `broadcast-accepted.xml` requires explicit backend acceptance before `SendSuccess`. +- `broadcast-rejected.xml` requires a backend rejection to show `OnchainSendFailedToast` without `SendSuccess`. + +Run both against an Android build consuming the Maven Local artifact from the exact LDK Node #112 +head under validation. The accepted fixture uses a clean funded regtest wallet connected directly to +the local Electrum backend at `tcp://10.0.2.2:60001`. + +For the rejected fixture, run: + +```sh +node scripts/reject-electrum-broadcast.js +``` + +The proxy listens on host port `61001`, forwards normal Electrum traffic to host port `60001`, and +returns a deterministic RPC `-26 non-final` rejection for every transaction broadcast. Configure +Bitkit to use `tcp://10.0.2.2:61001` before running `broadcast-rejected.xml`. + +The app result is necessary but not sufficient evidence. After each journey, record the transaction +id when present and query the active backend. The accepted transaction must be present in its mempool +or chain. The rejected transaction must be absent, and Bitkit must not create a sent activity for it. + +Test tags: `Send`, `RecipientManual`, `RecipientInput`, `AddressContinue`, `send_amount_screen`, +`N1`, `N000`, `ContinueAmount`, `GRAB`, `SendSuccess`, and `OnchainSendFailedToast`. diff --git a/journeys/onchain-send/broadcast-accepted.xml b/journeys/onchain-send/broadcast-accepted.xml new file mode 100644 index 0000000000..50b18a01ce --- /dev/null +++ b/journeys/onchain-send/broadcast-accepted.xml @@ -0,0 +1,23 @@ + + + Verifies that a normal on-chain send reaches Bitcoin Sent only after the configured regtest + backend accepts the transaction. Requires a funded wallet, a valid destination address, camera + permission already resolved, and the LDK Node broadcast-result contract from + synonymdev/ldk-node#112. + + + Verify the Bitkit wallet home screen is visible + Tap the Send button (testTag "Send") + Tap Enter Manually (testTag "RecipientManual") + Type the valid regtest destination address into the recipient field (testTag "RecipientInput") + Tap Continue (testTag "AddressContinue") + Verify the amount screen is visible (testTag "send_amount_screen") + Tap the 1 key (testTag "N1"), then the triple-zero key (testTag "N000"), to enter 1,000 sats + Tap Continue (testTag "ContinueAmount") + Verify the send review screen is visible + Swipe the confirm handle (testTag "GRAB") from left to right + Verify the accepted transaction success screen is visible (testTag "SendSuccess") + Close the send sheet and open All Activity + Verify the accepted transaction appears as a pending sent on-chain activity + + diff --git a/journeys/onchain-send/broadcast-rejected.xml b/journeys/onchain-send/broadcast-rejected.xml new file mode 100644 index 0000000000..a2d0d29d51 --- /dev/null +++ b/journeys/onchain-send/broadcast-rejected.xml @@ -0,0 +1,24 @@ + + + Verifies that a deterministic backend rejection never reaches Bitcoin Sent. Requires a funded + wallet, a valid destination address, camera permission already resolved, and the LDK Node + broadcast-result contract from synonymdev/ldk-node#112. Run scripts/reject-electrum-broadcast.js + on the host and configure Bitkit to use tcp://10.0.2.2:61001 before starting the send. + + + Verify the Bitkit wallet home screen is visible + Tap the Send button (testTag "Send") + Tap Enter Manually (testTag "RecipientManual") + Type the valid regtest destination address into the recipient field (testTag "RecipientInput") + Tap Continue (testTag "AddressContinue") + Verify the amount screen is visible (testTag "send_amount_screen") + Tap the 1 key (testTag "N1"), then the triple-zero key (testTag "N000"), to enter 1,000 sats + Tap Continue (testTag "ContinueAmount") + Verify the send review screen is visible + Swipe the confirm handle (testTag "GRAB") from left to right + Verify the transaction failure feedback is visible (testTag "OnchainSendFailedToast") + Verify the success screen is absent (testTag "SendSuccess") + Verify the send sheet dismisses automatically, then open All Activity + Verify the rejected transaction is not listed as a sent on-chain activity + + diff --git a/scripts/reject-electrum-broadcast.js b/scripts/reject-electrum-broadcast.js new file mode 100755 index 0000000000..f8366a4978 --- /dev/null +++ b/scripts/reject-electrum-broadcast.js @@ -0,0 +1,71 @@ +#!/usr/bin/env node + +const net = require("node:net") + +function option(name, fallback) { + const index = process.argv.indexOf(`--${name}`) + return index >= 0 ? process.argv[index + 1] : fallback +} + +const listenHost = option("listen-host", "127.0.0.1") +const listenPort = Number(option("listen-port", "61001")) +const upstreamHost = option("upstream-host", "127.0.0.1") +const upstreamPort = Number(option("upstream-port", "60001")) +const rejectionMessage = option("message", "non-final") + +function rejection(request) { + return { + jsonrpc: request.jsonrpc ?? "2.0", + id: request.id, + error: { code: -26, message: rejectionMessage }, + } +} + +function forwardClientLines(client, upstream) { + let buffered = "" + + client.on("data", chunk => { + buffered += chunk.toString("utf8") + const lines = buffered.split("\n") + buffered = lines.pop() ?? "" + + for (const line of lines) { + if (line.length === 0) continue + + let request + try { + request = JSON.parse(line) + } catch { + upstream.write(`${line}\n`) + continue + } + + if (!Array.isArray(request) && request.method === "blockchain.transaction.broadcast") { + client.write(`${JSON.stringify(rejection(request))}\n`) + } else { + upstream.write(`${line}\n`) + } + } + }) +} + +const server = net.createServer(client => { + const upstream = net.createConnection({ host: upstreamHost, port: upstreamPort }) + + forwardClientLines(client, upstream) + upstream.pipe(client) + + client.on("error", () => upstream.destroy()) + upstream.on("error", error => client.destroy(error)) + client.on("close", () => upstream.destroy()) + upstream.on("close", () => client.destroy()) +}) + +server.listen(listenPort, listenHost, () => { + process.stdout.write( + `Electrum rejection proxy listening on ${listenHost}:${listenPort}, forwarding to ${upstreamHost}:${upstreamPort}\n` + ) +}) + +process.on("SIGINT", () => server.close()) +process.on("SIGTERM", () => server.close())