From 3a7573369df96d7abc22a927f68332b3ba6e0567 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Wed, 22 Jul 2026 15:05:36 -0400 Subject: [PATCH 1/2] feat: pay onchain address from spending balance --- .../java/to/bitkit/repositories/SwapRepo.kt | 246 ++++++++++++++ .../screens/wallets/send/SendAmountScreen.kt | 9 +- .../screens/wallets/send/SendConfirmScreen.kt | 136 +++++++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 165 +++++++++- .../to/bitkit/repositories/SwapRepoTest.kt | 304 ++++++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 144 +++++++++ 6 files changed, 982 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/to/bitkit/repositories/SwapRepo.kt create mode 100644 app/src/test/java/to/bitkit/repositories/SwapRepoTest.kt diff --git a/app/src/main/java/to/bitkit/repositories/SwapRepo.kt b/app/src/main/java/to/bitkit/repositories/SwapRepo.kt new file mode 100644 index 0000000000..d2f3d9d26d --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/SwapRepo.kt @@ -0,0 +1,246 @@ +package to.bitkit.repositories + +import androidx.compose.runtime.Immutable +import com.synonym.bitkitcore.BoltzPairInfo +import com.synonym.bitkitcore.BoltzSwapEvent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import to.bitkit.di.IoDispatcher +import to.bitkit.ext.nowMillis +import to.bitkit.ext.runSuspendCatching +import to.bitkit.services.BoltzService +import to.bitkit.utils.AppError +import to.bitkit.utils.Logger +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.ceil +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime + +/** + * Pays an on-chain address out of the Lightning balance with a Boltz reverse swap, so a send can + * go through when savings are short but spending is not. + * + * A reverse swap claims to any address, so the recipient's address is used as the claim address and + * Boltz's payout lands directly on them. Boltz prices a reverse swap from the Lightning invoice + * amount, while a send starts from the amount the recipient must receive, so the quote here is the + * inverse of the forward pricing the transfer-to-savings flow does. + */ +@OptIn(ExperimentalTime::class) +@Singleton +class SwapRepo @Inject constructor( + private val boltzService: BoltzService, + private val lightningRepo: LightningRepo, + private val walletRepo: WalletRepo, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) { + @Volatile + private var cachedLimits: BoltzPairInfo? = null + + @Volatile + private var cachedLimitsAt: Long = 0L + + /** + * Price a swap that delivers exactly [recipientSat] on-chain. Fails when swaps are off, Boltz is + * unreachable, or the amount falls outside the swap limits or the spendable Lightning balance, + * so callers can simply not offer the swap. + */ + suspend fun quoteForRecipientAmount(recipientSat: ULong): Result = + withContext(ioDispatcher) { + runSuspendCatching { + if (recipientSat == 0uL) throw SwapUnavailableError("Amount is zero") + val limits = limits() + val quote = buildQuote(recipientSat, limits) + + if (quote.invoiceSat < limits.minimalSat || quote.invoiceSat > limits.maximalSat) { + throw SwapUnavailableError( + "Amount '${quote.invoiceSat}' outside swap limits " + + "'${limits.minimalSat}'..'${limits.maximalSat}'" + ) + } + if (quote.invoiceSat > sendableLightningSats()) { + throw SwapUnavailableError("Amount '${quote.invoiceSat}' over spendable balance") + } + if (!lightningRepo.canSend(quote.invoiceSat)) { + throw SwapUnavailableError("Cannot route '${quote.invoiceSat}' over Lightning") + } + quote + } + } + + /** + * Largest amount a swap can deliver on-chain right now, so the amount screen can cap its input + * without pricing every keystroke against Boltz. + */ + suspend fun maxRecipientSats(): Result = withContext(ioDispatcher) { + runSuspendCatching { + val limits = limits() + val ceiling = minOf(limits.maximalSat, sendableLightningSats()).toLong() + receivedFor(ceiling, limits.feePercentage / PERCENT, limits.minerFeesSat.toLong()) + .coerceAtLeast(0) + .toULong() + } + } + + /** + * Pair terms, cached briefly: the send flow re-prices on every keystroke and the terms only + * move with Boltz's fee schedule. + */ + private suspend fun limits(): BoltzPairInfo { + if (!boltzService.isSwapEnabled()) throw SwapUnavailableError("Swaps are disabled") + + val cached = cachedLimits + if (cached != null && nowMillis() - cachedLimitsAt < LIMITS_TTL.inWholeMilliseconds) return cached + + // Bounded so a hanging Boltz request cannot leave the send flow stuck loading. + val fresh = withTimeoutOrNull(QUOTE_TIMEOUT) { boltzService.reverseLimits() } + ?: throw SwapUnavailableError("Timed out fetching reverse swap limits") + cachedLimits = fresh + cachedLimitsAt = nowMillis() + return fresh + } + + /** + * Create the swap, pay its hold invoice over Lightning and wait for the on-chain claim. + * + * Boltz reports the amount it will lock before anything is paid, so a swap that would short the + * recipient is abandoned while it is still free to abandon. Once the invoice is paid the claim + * is broadcast by the updates stream, so a timeout waiting for it leaves + * [SendSwapReceipt.claimTxId] null rather than failing the send. Callers must run this in a + * scope that outlives the send sheet. + */ + suspend fun payToAddress(address: String, recipientSat: ULong): Result = + withContext(ioDispatcher) { + runSuspendCatching { + val quote = quoteForRecipientAmount(recipientSat).getOrThrow() + + val swap = boltzService.createReverseSwap( + amountSat = quote.invoiceSat, + claimAddress = address, + ) + Logger.info("Created send swap '${swap.id}' for '$recipientSat' sat", context = TAG) + + // Boltz reports what it will lock before anything is paid. The quote already holds + // back Boltz's own claim fee estimate, so a lockup below the recipient amount means + // Boltz priced on different terms than we quoted and the send is abandoned for free. + if (swap.onchainAmountSat < recipientSat) { + throw SwapQuoteExpiredError( + "Boltz locks '${swap.onchainAmountSat}' sat, short of '$recipientSat' sat" + ) + } + + coroutineScope { + // UNDISPATCHED so the collector subscribes before we pay: the events flow has no + // replay, so a claim settling faster than the payment call returns would be missed. + val claim = async(start = CoroutineStart.UNDISPATCHED) { awaitClaim(swap.id) } + + // Pay the hold invoice (amount is encoded). It stays pending until Boltz locks + // funds on-chain and we claim them to the recipient, the expected happy path. + val paymentHash = lightningRepo.payInvoice(bolt11 = swap.invoice).getOrThrow() + + SendSwapReceipt(paymentHash = paymentHash, claimTxId = claim.await()) + } + } + } + + private suspend fun awaitClaim(swapId: String): String? { + val event = withTimeoutOrNull(CLAIM_TIMEOUT) { + boltzService.events.first { + (it is BoltzSwapEvent.Claimed && it.swapId == swapId) || + (it is BoltzSwapEvent.Error && it.swapId == swapId) + } + } + return when (event) { + is BoltzSwapEvent.Claimed -> event.txid + is BoltzSwapEvent.Error -> throw SwapClaimError(event.message) + else -> null + } + } + + /** + * Solve for the Lightning invoice amount that leaves [recipientSat] on-chain. Seeded from the + * closed form, then corrected against the integer forward formula, which is what Boltz applies: + * it charges `ceil(percentage% * invoice)` and takes the miner fees on top. + */ + private fun buildQuote(recipientSat: ULong, limits: BoltzPairInfo): SendSwapQuote { + val target = recipientSat.toLong() + val minerFee = limits.minerFeesSat.toLong() + val rate = limits.feePercentage / PERCENT + + var invoice = ceil((target + minerFee) / (1.0 - rate)).toLong().coerceAtLeast(target) + while (receivedFor(invoice, rate, minerFee) < target) invoice++ + while (invoice > 0 && receivedFor(invoice - 1, rate, minerFee) >= target) invoice-- + + return SendSwapQuote( + recipientSat = recipientSat, + invoiceSat = invoice.toULong(), + serviceFeeSat = (invoice - target).coerceAtLeast(0).toULong(), + networkFeeSat = limits.minerFeesSat, + ) + } + + private fun receivedFor(invoiceSat: Long, rate: Double, minerFeeSat: Long): Long = + invoiceSat - ceil(rate * invoiceSat).toLong() - minerFeeSat + + /** + * Lightning outbound minus a routing reserve. Paying an invoice for 100% of outbound capacity + * leaves nothing for fees and fails with RouteNotFound. + */ + private fun sendableLightningSats(): ULong { + val spendable = walletRepo.balanceState.value.maxSendLightningSats.toLong() + val routingReserve = (spendable / LN_ROUTING_FEE_RESERVE_DIVISOR) + .coerceAtLeast(MIN_LN_ROUTING_FEE_RESERVE_SATS) + return (spendable - routingReserve).coerceAtLeast(0).toULong() + } + + companion object { + private const val TAG = "SwapRepo" + + private const val PERCENT = 100.0 + + /** Upper bound for fetching swap limits before the send flow gives up on a quote. */ + private val QUOTE_TIMEOUT = 15.seconds + + /** How long cached pair terms are reused before Boltz is asked again. */ + private val LIMITS_TTL = 60.seconds + + /** How long the send waits for the on-chain claim before backgrounding it. */ + private val CLAIM_TIMEOUT = 30.seconds + + /** Minimum sats held back from a swap to cover Lightning routing fees. */ + private const val MIN_LN_ROUTING_FEE_RESERVE_SATS = 10L + + /** Holds ~1% of outbound capacity back for Lightning routing fees. */ + private const val LN_ROUTING_FEE_RESERVE_DIVISOR = 100L + } +} + +/** Cost of delivering [recipientSat] on-chain from the Lightning balance. */ +@Immutable +data class SendSwapQuote( + /** What the recipient receives on-chain. */ + val recipientSat: ULong, + /** What the Lightning invoice pays, i.e. what leaves the spending balance. */ + val invoiceSat: ULong, + /** Everything the swap costs on top of [recipientSat]: Boltz's cut plus miner fees. */ + val serviceFeeSat: ULong, + /** Boltz's lockup and claim miner fee estimate, already included in [serviceFeeSat]. */ + val networkFeeSat: ULong, +) + +/** What a completed swap send leaves behind. The Lightning leg is settled either way. */ +data class SendSwapReceipt( + /** Hash of the hold invoice payment, i.e. the activity the send produces. */ + val paymentHash: String, + /** Transaction paying the recipient; null while the updates stream is still broadcasting it. */ + val claimTxId: String?, +) + +class SwapUnavailableError(message: String) : AppError(message) +class SwapQuoteExpiredError(message: String) : AppError(message) +class SwapClaimError(message: String) : AppError(message) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt index fed94b10a2..187f6bed38 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt @@ -231,6 +231,8 @@ private fun SendAmountNodeRunning( val availableAmount = when { isLnurlWithdraw -> uiState.lnurl.data.maxWithdrawableSat().toLong() uiState.payMethod == SendMethod.ONCHAIN -> balances.maxSendOnchainSats.toLong() + // A swap send is capped by what Boltz can deliver onchain, not by the raw LN balance. + uiState.payMethod == SendMethod.SWAP -> uiState.swapMaxSendSats.toLong() else -> { val maxLightning = balances.maxSendLightningSats val routingFee = uiState.estimatedRoutingFee @@ -265,8 +267,7 @@ private fun SendAmountNodeRunning( uiState.lnurl is LnurlParams.LnurlWithdraw -> R.string.wallet__lnurl_w_max uiState.isUnified -> R.string.wallet__send_available uiState.payMethod == SendMethod.ONCHAIN -> R.string.wallet__send_available_savings - uiState.payMethod == SendMethod.LIGHTNING -> R.string.wallet__send_available_spending - else -> R.string.wallet__send_available + else -> R.string.wallet__send_available_spending } Row( @@ -350,11 +351,11 @@ private fun PaymentMethodButton( NumberPadActionButton( text = when (uiState.payMethod) { SendMethod.ONCHAIN -> stringResource(R.string.wallet__savings__title) - SendMethod.LIGHTNING -> stringResource(R.string.wallet__spending__title) + SendMethod.LIGHTNING, SendMethod.SWAP -> stringResource(R.string.wallet__spending__title) }, color = when (uiState.payMethod) { SendMethod.ONCHAIN -> Colors.Brand - SendMethod.LIGHTNING -> Colors.Purple + SendMethod.LIGHTNING, SendMethod.SWAP -> Colors.Purple }, icon = if (uiState.isUnified) R.drawable.ic_transfer else null, onClick = onClick, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt index 8dd6a71402..f5dacbd4d2 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt @@ -65,6 +65,7 @@ import to.bitkit.ext.formatInvoiceExpiryRelative import to.bitkit.models.FeeRate import to.bitkit.models.PubkyProfile import to.bitkit.models.TransactionSpeed +import to.bitkit.repositories.SendSwapQuote import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.BiometricsView import to.bitkit.ui.components.BodySSB @@ -72,6 +73,7 @@ import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.MoneySSB import to.bitkit.ui.components.NumberPadActionButton import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.PubkyContactAvatar @@ -283,7 +285,7 @@ private fun ContentRunning( val accentColor = when (uiState.payMethod) { SendMethod.ONCHAIN -> Colors.Brand - SendMethod.LIGHTNING -> Colors.Purple + SendMethod.LIGHTNING, SendMethod.SWAP -> Colors.Purple } Column( @@ -314,6 +316,12 @@ private fun ContentRunning( TagsSection(uiState, onClickTag, onClickAddTag) } + SendMethod.SWAP -> { + SwapDetails(uiState = uiState, onEvent = onEvent) + VerticalSpacer(16.dp) + TagsSection(uiState, onClickTag, onClickAddTag) + } + SendMethod.LIGHTNING -> { LightningDetails( uiState = uiState, @@ -353,7 +361,7 @@ private fun ContentRunning( } else { when (uiState.payMethod) { SendMethod.ONCHAIN -> R.drawable.ic_speed_normal - SendMethod.LIGHTNING -> R.drawable.ic_lightning + SendMethod.LIGHTNING, SendMethod.SWAP -> R.drawable.ic_lightning } } ), @@ -590,6 +598,100 @@ private fun OnChainDetails( } } +/** + * Review rows for a send that pays an onchain address out of the spending balance through a swap. + * Mirrors [OnChainDetails], except the fee is Boltz's rather than ours, so it is not editable, and + * the confirmation estimate gives way to the swap's service fee. + */ +@Composable +private fun SwapDetails( + uiState: SendUiState, + onEvent: (SendEvent) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier.fillMaxWidth() + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.height(IntrinsicSize.Min) + ) { + SendCell( + caption = stringResource(R.string.wallet__send_from), + modifier = Modifier.weight(1f) + ) { + NumberPadActionButton( + text = stringResource(R.string.wallet__spending__title), + color = Colors.Purple, + enabled = uiState.canSwitchWallet, + icon = R.drawable.ic_transfer.takeIf { uiState.canSwitchWallet }, + onClick = { onEvent(SendEvent.PaymentMethodSwitch) }, + modifier = Modifier.testTag("SendConfirmAssetButton") + ) + } + SendCell( + caption = stringResource(R.string.wallet__send_to), + modifier = Modifier.weight(1f) + ) { + if (uiState.contactPaymentProfile != null) { + ContactRecipient(profile = uiState.contactPaymentProfile) + } else { + BodySSB( + text = uiState.address, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier + .height(28.dp) + .wrapContentHeight(Alignment.CenterVertically) + .clickableAlpha { onEvent(SendEvent.NavToAddress) } + .testTag("ReviewUri") + ) + } + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.height(IntrinsicSize.Min) + ) { + SendCell( + caption = stringResource(R.string.wallet__send_fee_and_speed), + modifier = Modifier.weight(1f) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + painterResource(R.drawable.ic_speed_normal), + contentDescription = null, + tint = Colors.Brand, + modifier = Modifier.size(16.dp) + ) + (uiState.fee as? SendFee.Swap)?.value + ?.takeIf { it > 0 } + ?.let { MoneySSB(sats = it) } + ?: CircularProgressIndicator(Modifier.size(14.dp), Colors.White64, 2.dp) + } + } + SendCell( + caption = stringResource(R.string.lightning__savings_confirm__service_fee), + modifier = Modifier.weight(1f) + ) { + uiState.swapQuote + ?.let { + MoneySSB( + sats = it.serviceFeeSat.toLong(), + modifier = Modifier.testTag("SendConfirmServiceFee") + ) + } + ?: CircularProgressIndicator(Modifier.size(14.dp), Colors.White64, 2.dp) + } + } + } +} + @Suppress("CyclomaticComplexMethod") @Composable private fun LightningDetails( @@ -904,6 +1006,36 @@ private fun PreviewLightningDetails() { } } +@Suppress("MagicNumber") +@Preview(showSystemUi = true, group = "swap details") +@Composable +private fun PreviewSwapDetails() { + AppThemeSurface { + BottomSheetPreview { + Content( + uiState = sendUiState().copy( + amount = 50_000u, + payMethod = SendMethod.SWAP, + canSwitchWallet = true, + selectedTags = persistentListOf("rent"), + fee = SendFee.Swap(320), + swapQuote = SendSwapQuote( + recipientSat = 50_000u, + invoiceSat = 50_420u, + serviceFeeSat = 420u, + networkFeeSat = 320u, + ), + ), + isNodeRunning = true, + isLoading = false, + showBiometrics = false, + initialShowDetails = true, + modifier = Modifier.sheetHeight() + ) + } + } +} + @Suppress("MagicNumber") @Preview(showSystemUi = true, group = "onchain", device = Devices.NEXUS_5) @Composable diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 3449a58acf..d993378cfc 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -139,6 +139,8 @@ import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.SamRockRepo +import to.bitkit.repositories.SendSwapQuote +import to.bitkit.repositories.SwapRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WidgetsRepo @@ -210,6 +212,7 @@ class AppViewModel @Inject constructor( private val privatePaykitRepo: PrivatePaykitRepo, private val refreshContactPaykitReceivers: RefreshContactPaykitReceiversUseCase, private val samRockRepo: SamRockRepo, + private val swapRepo: SwapRepo, private val appUpdateSheet: AppUpdateTimedSheet, private val backupSheet: BackupTimedSheet, private val notificationsSheet: NotificationsTimedSheet, @@ -1286,7 +1289,8 @@ class AppViewModel @Inject constructor( val maxSendOnchain = walletRepo.balanceState.value.maxSendOnchainSats - if (maxSendOnchain == 0uL) { + // Savings falling short is not a dead end while a swap can pay the address from spending. + if (maxSendOnchain == 0uL && !trySwitchToSwapSend(invoice.amountSatoshis)) { showAddressValidationError( titleRes = R.string.other__pay_insufficient_savings, descriptionRes = R.string.other__pay_insufficient_savings_description, @@ -1295,7 +1299,11 @@ class AppViewModel @Inject constructor( return } - if (invoice.amountSatoshis > 0uL && invoice.amountSatoshis > maxSendOnchain) { + val exceedsSavings = invoice.amountSatoshis > 0uL && + invoice.amountSatoshis > maxSendOnchain && + _sendUiState.value.payMethod == SendMethod.ONCHAIN + + if (exceedsSavings && !trySwitchToSwapSend(invoice.amountSatoshis)) { val shortfall = invoice.amountSatoshis - maxSendOnchain showAddressValidationError( titleRes = R.string.other__pay_insufficient_savings, @@ -1399,6 +1407,7 @@ class AppViewModel @Inject constructor( } private suspend fun onAmountChange(amount: ULong) { + resolveSwapSendMethod(amount) _sendUiState.update { it.copy( amount = amount, @@ -1458,12 +1467,18 @@ class AppViewModel @Inject constructor( private fun updateCanSwitchWallet() { val state = _sendUiState.value + val amount = state.amount + val balance = walletRepo.balanceState.value if (!state.isUnified) { - _sendUiState.update { it.copy(canSwitchWallet = false) } + // A plain onchain address gains a toggle only once a swap has been priced for it, so + // an ordinary send never probes Boltz just to decide whether to light the button up. + val canSwap = state.swapMaxSendSats > 0uL && + amount > Defaults.dustLimit.toULong() && + amount <= balance.maxSendOnchainSats && + amount <= state.swapMaxSendSats + _sendUiState.update { it.copy(canSwitchWallet = canSwap) } return } - val amount = state.amount - val balance = walletRepo.balanceState.value val canSwitch = amount > Defaults.dustLimit.toULong() && amount <= balance.maxSendOnchainSats && amount <= balance.maxSendLightningSats @@ -1472,11 +1487,12 @@ class AppViewModel @Inject constructor( private suspend fun onPaymentMethodSwitch() { val current = _sendUiState.value - if (!current.isUnified) return + if (!current.isUnified && current.swapMaxSendSats == 0uL) return val nextMethod = when (current.payMethod) { - SendMethod.ONCHAIN -> SendMethod.LIGHTNING + SendMethod.ONCHAIN -> if (current.isUnified) SendMethod.LIGHTNING else SendMethod.SWAP SendMethod.LIGHTNING -> SendMethod.ONCHAIN + SendMethod.SWAP -> SendMethod.ONCHAIN } _sendUiState.update { it.copy( @@ -1488,13 +1504,73 @@ class AppViewModel @Inject constructor( when (nextMethod) { SendMethod.ONCHAIN -> { val defaultSpeed = settingsStore.data.first().defaultTransactionSpeed - _sendUiState.update { it.copy(speed = defaultSpeed) } + _sendUiState.update { it.copy(speed = defaultSpeed, swapQuote = null) } refreshFeeEstimates() } SendMethod.LIGHTNING -> { _sendUiState.update { it.copy(fee = SendFee.Lightning(0)) } estimateLightningRoutingFeesIfNeeded() } + SendMethod.SWAP -> refreshSwapQuote(current.amount) + } + } + + /** + * Switch a send to the spending balance, paying the onchain address through a Boltz reverse + * swap. Returns false when no swap can deliver [amount], leaving the send untouched so the + * caller keeps the pre-swap behaviour. An [amount] of zero is not priceable yet, so only the + * swap ceiling is established and the amount screen prices the rest. + */ + private suspend fun trySwitchToSwapSend(amount: ULong): Boolean { + val maxSwapSat = swapRepo.maxRecipientSats().getOrNull()?.takeIf { it > 0uL } ?: return false + val quote = amount.takeIf { it > 0uL }?.let { + swapRepo.quoteForRecipientAmount(it).getOrNull() ?: return false + } + Logger.info("Offering swap send for '$amount' sat, max '$maxSwapSat' sat", context = TAG) + _sendUiState.update { + it.copy( + payMethod = SendMethod.SWAP, + swapMaxSendSats = maxSwapSat, + swapQuote = quote, + fee = SendFee.Swap(quote?.networkFeeSat?.toLong() ?: 0L), + ) + } + return true + } + + /** Re-price the swap for [amount] and publish it, clearing the quote when it no longer holds. */ + private suspend fun refreshSwapQuote(amount: ULong): SendSwapQuote? { + val quote = swapRepo.quoteForRecipientAmount(amount).getOrNull() + _sendUiState.update { + it.copy( + swapQuote = quote, + fee = SendFee.Swap(quote?.networkFeeSat?.toLong() ?: 0L), + ) + } + return quote + } + + /** + * Keep a plain onchain send on the rail that can pay it: savings while the amount fits there, + * spending via a swap once it does not. A method picked by hand on the review screen is not + * revisited, since this only runs while the amount is being edited. + */ + private suspend fun resolveSwapSendMethod(amount: ULong) { + val state = _sendUiState.value + if (state.isUnified || state.decodedInvoice != null || state.lnurl != null) return + + val maxSendOnchain = walletRepo.balanceState.value.maxSendOnchainSats + when (state.payMethod) { + SendMethod.ONCHAIN if amount > maxSendOnchain -> trySwitchToSwapSend(amount) + SendMethod.SWAP if amount in 1uL..maxSendOnchain -> { + val defaultSpeed = settingsStore.data.first().defaultTransactionSpeed + _sendUiState.update { + it.copy(payMethod = SendMethod.ONCHAIN, speed = defaultSpeed, swapQuote = null) + } + refreshFeeEstimates() + } + SendMethod.SWAP -> refreshSwapQuote(amount) + else -> Unit } } @@ -1519,7 +1595,8 @@ class AppViewModel @Inject constructor( ) } - if (_sendUiState.value.payMethod != SendMethod.LIGHTNING && !settingsStore.data.first().coinSelectAuto) { + // A swap send spends no utxos, so manual coin selection has nothing to offer it. + if (_sendUiState.value.payMethod == SendMethod.ONCHAIN && !settingsStore.data.first().coinSelectAuto) { setSendEffect(SendEffect.NavigateToCoinSelection) return } @@ -1546,6 +1623,8 @@ class AppViewModel @Inject constructor( _sendUiState.update { it.copy(isLoading = true) } refreshOnchainSendIfNeeded() estimateLightningRoutingFeesIfNeeded() + // Price the swap before navigating so the confirm screen never renders a half-priced send. + if (_sendUiState.value.payMethod == SendMethod.SWAP) refreshSwapQuote(_sendUiState.value.amount) _sendUiState.update { it.copy(isLoading = false) } updateCanSwitchWallet() @@ -1583,6 +1662,12 @@ class AppViewModel @Inject constructor( val maxSendable = walletRepo.balanceState.value.maxSendOnchainSats amount > Defaults.dustLimit.toULong() && amount <= maxSendable } + + SendMethod.SWAP -> { + amount > Defaults.dustLimit.toULong() && + amount <= _sendUiState.value.swapMaxSendSats && + swapRepo.quoteForRecipientAmount(amount).isSuccess + } } } @@ -1877,8 +1962,12 @@ class AppViewModel @Inject constructor( return } - // Check on-chain balance before proceeding to amount screen - if (maxSendOnchain == 0uL && _sendUiState.value.payMethod == SendMethod.ONCHAIN) { + // Check on-chain balance before proceeding to amount screen, falling back to a swap out of + // the spending balance when savings cannot cover the send. + if (maxSendOnchain == 0uL && + _sendUiState.value.payMethod == SendMethod.ONCHAIN && + !trySwitchToSwapSend(invoice.amountSatoshis) + ) { toast( type = Toast.ToastType.ERROR, title = context.getString(R.string.other__pay_insufficient_savings), @@ -1890,11 +1979,11 @@ class AppViewModel @Inject constructor( } // Check if on-chain invoice amount exceeds available balance - if ( - invoice.amountSatoshis > 0uL && + val exceedsSavings = invoice.amountSatoshis > 0uL && invoice.amountSatoshis > maxSendOnchain && _sendUiState.value.payMethod == SendMethod.ONCHAIN - ) { + + if (exceedsSavings && !trySwitchToSwapSend(invoice.amountSatoshis)) { val shortfall = invoice.amountSatoshis - maxSendOnchain toast( type = Toast.ToastType.ERROR, @@ -2224,7 +2313,7 @@ class AppViewModel @Inject constructor( val settings = settingsStore.data.first() val balanceToCheck = when (_sendUiState.value.payMethod) { SendMethod.ONCHAIN -> walletRepo.balanceState.value.maxSendOnchainSats - SendMethod.LIGHTNING -> walletRepo.balanceState.value.maxSendLightningSats + SendMethod.LIGHTNING, SendMethod.SWAP -> walletRepo.balanceState.value.maxSendLightningSats } if ( amountSats > BigDecimal.valueOf(balanceToCheck.toLong()) @@ -2346,6 +2435,8 @@ class AppViewModel @Inject constructor( } } + SendMethod.SWAP -> paySwap(amount) + SendMethod.LIGHTNING -> { val decodedInvoice = requireNotNull(_sendUiState.value.decodedInvoice) val bolt11 = decodedInvoice.bolt11 @@ -2536,6 +2627,42 @@ class AppViewModel @Inject constructor( ) } + /** + * Pay an onchain address out of the spending balance through a Boltz reverse swap. + * + * The claim that pays the recipient is broadcast by the swap updates stream, which + * [WalletViewModel] starts when the node comes up, so a claim we stop waiting for still lands. + * That makes a pending outcome a success here: the invoice is paid and the payout is on its way. + */ + private suspend fun paySwap(amount: ULong) { + val address = _sendUiState.value.address + val contactPublicKey = activeContactPaymentPublicKey() + + swapRepo.payToAddress(address = address, recipientSat = amount) + .onSuccess { receipt -> + discardContactOnchainEndpoint(contactPublicKey, address) + Logger.info("Swap send claim txid: '${receipt.claimTxId}'", context = TAG) + onSendSuccess( + NewTransactionSheetDetails( + type = NewTransactionSheetType.LIGHTNING, + direction = NewTransactionSheetDirection.SENT, + paymentHashOrTxId = receipt.paymentHash, + sats = amount.toLong(), + ) + ) + lightningRepo.sync() + activityRepo.syncActivities() + }.onFailure { e -> + Logger.error("Error sending swap 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) + ) + hideSheet() + } + } + private suspend fun sendLightning( bolt11: String, amount: ULong? = null, @@ -3350,6 +3477,8 @@ data class SendUiState( val estimatedRoutingFee: ULong = 0uL, val lastLightningFee: Long = 0L, val contactPaymentProfile: PubkyProfile? = null, + val swapQuote: SendSwapQuote? = null, + val swapMaxSendSats: ULong = 0uL, ) enum class SanityWarning(@StringRes val message: Int, val testTag: String) { @@ -3363,9 +3492,13 @@ enum class SanityWarning(@StringRes val message: Int, val testTag: String) { sealed class SendFee(open val value: Long) { data class OnChain(override val value: Long) : SendFee(value) data class Lightning(override val value: Long) : SendFee(value) + + /** Boltz's miner fee estimate for a swap send; the service fee is shown in its own cell. */ + data class Swap(override val value: Long) : SendFee(value) } -enum class SendMethod { ONCHAIN, LIGHTNING } +/** How a send is funded. [SWAP] pays an onchain address out of the spending balance via Boltz. */ +enum class SendMethod { ONCHAIN, LIGHTNING, SWAP } data class ContactPaymentContext(val publicKey: String) diff --git a/app/src/test/java/to/bitkit/repositories/SwapRepoTest.kt b/app/src/test/java/to/bitkit/repositories/SwapRepoTest.kt new file mode 100644 index 0000000000..1e41c8a02f --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/SwapRepoTest.kt @@ -0,0 +1,304 @@ +package to.bitkit.repositories + +import com.synonym.bitkitcore.BoltzPairInfo +import com.synonym.bitkitcore.BoltzSwapEvent +import com.synonym.bitkitcore.ReverseSwapResponse +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.models.BalanceState +import to.bitkit.services.BoltzService +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError +import kotlin.math.ceil +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class SwapRepoTest : BaseUnitTest() { + private val boltzService = mock() + private val lightningRepo = mock() + private val walletRepo = mock() + + private val boltzEvents = MutableSharedFlow(extraBufferCapacity = 8) + private val balanceState = MutableStateFlow(BalanceState(maxSendLightningSats = SPENDABLE_LN)) + + private lateinit var sut: SwapRepo + + @Before + fun setUp() { + whenever(boltzService.events).thenReturn(boltzEvents) + whenever { boltzService.isSwapEnabled() }.thenReturn(true) + whenever { boltzService.reverseLimits(anyOrNull()) }.thenReturn(reverseLimits()) + whenever(walletRepo.balanceState).thenReturn(balanceState) + whenever(lightningRepo.canSend(any())).thenReturn(true) + + sut = SwapRepo( + boltzService = boltzService, + lightningRepo = lightningRepo, + walletRepo = walletRepo, + ioDispatcher = testDispatcher, + ) + } + + // region quoteForRecipientAmount + + @Test + fun `quote grosses fees up so the recipient receives the requested amount`() = test { + val quote = assertNotNull(sut.quoteForRecipientAmount(RECIPIENT_SAT).getOrNull()) + + assertEquals(RECIPIENT_SAT, quote.recipientSat) + assertEquals(SWAP_MINER_FEE, quote.networkFeeSat) + assertEquals(quote.invoiceSat - RECIPIENT_SAT, quote.serviceFeeSat) + assertTrue(quote.invoiceSat > RECIPIENT_SAT) + assertEquals(RECIPIENT_SAT.toLong(), received(quote.invoiceSat.toLong())) + } + + @Test + fun `quote is the smallest invoice amount that still covers the recipient`() = test { + val quote = assertNotNull(sut.quoteForRecipientAmount(RECIPIENT_SAT).getOrNull()) + + // Boltz rounds its percentage fee up, so one sat less must fall short. + assertTrue(received(quote.invoiceSat.toLong() - 1) < RECIPIENT_SAT.toLong()) + } + + @Test + fun `quote covers the recipient across a range of amounts`() = test { + for (recipient in listOf(60_000uL, 60_001uL, 99_999uL, 123_457uL)) { + val quote = assertNotNull(sut.quoteForRecipientAmount(recipient).getOrNull()) + assertTrue( + received(quote.invoiceSat.toLong()) >= recipient.toLong(), + "recipient '$recipient' shorted by invoice '${quote.invoiceSat}'", + ) + } + } + + @Test + fun `quote fails below the swap minimum`() = test { + assertTrue(sut.quoteForRecipientAmount(SWAP_MIN - 10_000uL).isFailure) + } + + @Test + fun `quote fails above the swap maximum`() = test { + assertTrue(sut.quoteForRecipientAmount(SWAP_MAX + 1uL).isFailure) + } + + @Test + fun `quote fails when the spending balance cannot cover the grossed up amount`() = test { + balanceState.value = BalanceState(maxSendLightningSats = RECIPIENT_SAT) + + assertTrue(sut.quoteForRecipientAmount(RECIPIENT_SAT).isFailure) + } + + @Test + fun `quote fails when Lightning cannot route the payment`() = test { + whenever(lightningRepo.canSend(any())).thenReturn(false) + + assertTrue(sut.quoteForRecipientAmount(RECIPIENT_SAT).isFailure) + } + + @Test + fun `quote skips the network when swaps are disabled`() = test { + whenever(boltzService.isSwapEnabled()).thenReturn(false) + + assertTrue(sut.quoteForRecipientAmount(RECIPIENT_SAT).isFailure) + verify(boltzService, never()).reverseLimits(anyOrNull()) + } + + @Test + fun `quote fails when the limits fetch fails`() = test { + whenever(boltzService.reverseLimits(anyOrNull())).thenAnswer { throw AppError(BOLTZ_ERROR) } + + assertTrue(sut.quoteForRecipientAmount(RECIPIENT_SAT).isFailure) + } + + @Test + fun `pair terms are fetched once and reused for later quotes`() = test { + sut.quoteForRecipientAmount(RECIPIENT_SAT) + sut.quoteForRecipientAmount(RECIPIENT_SAT + 1uL) + + verify(boltzService).reverseLimits(anyOrNull()) + } + + // endregion + + // region maxRecipientSats + + @Test + fun `maxRecipientSats is what the spendable balance delivers after fees`() = test { + val spendable = 200_000uL + balanceState.value = BalanceState(maxSendLightningSats = spendable) + + val max = assertNotNull(sut.maxRecipientSats().getOrNull()) + + val sendable = spendable - spendable / 100uL // 1% routing fee reserve + assertEquals(received(sendable.toLong()).toULong(), max) + } + + @Test + fun `maxRecipientSats is capped by the swap maximum`() = test { + balanceState.value = BalanceState(maxSendLightningSats = SWAP_MAX * 10uL) + + val max = assertNotNull(sut.maxRecipientSats().getOrNull()) + + assertEquals(received(SWAP_MAX.toLong()).toULong(), max) + } + + @Test + fun `maxRecipientSats fails when swaps are disabled`() = test { + whenever(boltzService.isSwapEnabled()).thenReturn(false) + + assertTrue(sut.maxRecipientSats().isFailure) + } + + // endregion + + // region payToAddress + + @Test + fun `payToAddress claims to the recipient address and reports the claim txid`() = test { + stubSwapHappyPath() + + var result: Result? = null + backgroundScope.launch { result = sut.payToAddress(ADDRESS, RECIPIENT_SAT) } + runCurrent() + boltzEvents.emit(BoltzSwapEvent.Claimed(swapId = SWAP_ID, txid = CLAIM_TXID)) + advanceUntilIdle() + + assertEquals(SendSwapReceipt(paymentHash = PAYMENT_ID, claimTxId = CLAIM_TXID), result?.getOrNull()) + verify(boltzService).createReverseSwap(any(), any(), anyOrNull(), anyOrNull()) + } + + @Test + fun `payToAddress pays an invoice grossed up to cover the recipient amount`() = test { + stubSwapHappyPath() + + backgroundScope.launch { sut.payToAddress(ADDRESS, RECIPIENT_SAT) } + runCurrent() + boltzEvents.emit(BoltzSwapEvent.Claimed(swapId = SWAP_ID, txid = CLAIM_TXID)) + advanceUntilIdle() + + val expectedInvoice = assertNotNull(sut.quoteForRecipientAmount(RECIPIENT_SAT).getOrNull()).invoiceSat + verify(boltzService).createReverseSwap(eq(expectedInvoice), eq(ADDRESS), anyOrNull(), anyOrNull()) + } + + @Test + fun `payToAddress still reports the paid invoice when the claim does not land in time`() = test { + stubSwapHappyPath() + + var result: Result? = null + backgroundScope.launch { result = sut.payToAddress(ADDRESS, RECIPIENT_SAT) } + runCurrent() + advanceTimeBy(31.seconds) + advanceUntilIdle() + + assertEquals(SendSwapReceipt(paymentHash = PAYMENT_ID, claimTxId = null), result?.getOrNull()) + } + + @Test + fun `payToAddress fails without paying when Boltz would lock less than the recipient needs`() = test { + stubSwapHappyPath(onchainAmountSat = RECIPIENT_SAT - 1uL) + + val result = sut.payToAddress(ADDRESS, RECIPIENT_SAT) + advanceUntilIdle() + + assertTrue(result.isFailure) + verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) + } + + @Test + fun `payToAddress fails without creating a swap when no quote covers the amount`() = test { + whenever(boltzService.isSwapEnabled()).thenReturn(false) + + val result = sut.payToAddress(ADDRESS, RECIPIENT_SAT) + advanceUntilIdle() + + assertTrue(result.isFailure) + verify(boltzService, never()).createReverseSwap(any(), any(), anyOrNull(), anyOrNull()) + } + + @Test + fun `payToAddress fails when the swap reports an error`() = test { + stubSwapHappyPath() + + var result: Result? = null + backgroundScope.launch { result = sut.payToAddress(ADDRESS, RECIPIENT_SAT) } + runCurrent() + boltzEvents.emit(BoltzSwapEvent.Error(swapId = SWAP_ID, message = BOLTZ_ERROR)) + advanceUntilIdle() + + assertEquals(BOLTZ_ERROR, result?.exceptionOrNull()?.message) + } + + @Test + fun `payToAddress fails when the invoice payment fails`() = test { + stubSwapHappyPath() + whenever(lightningRepo.payInvoice(any(), anyOrNull())).thenReturn(Result.failure(AppError(PAY_ERROR))) + + val result = sut.payToAddress(ADDRESS, RECIPIENT_SAT) + advanceUntilIdle() + + assertEquals(PAY_ERROR, result.exceptionOrNull()?.message) + } + + // endregion + + private suspend fun stubSwapHappyPath(onchainAmountSat: ULong = RECIPIENT_SAT + SWAP_MINER_FEE) { + whenever(boltzService.createReverseSwap(any(), any(), anyOrNull(), anyOrNull())) + .thenReturn(reverseSwapResponse(onchainAmountSat)) + whenever(lightningRepo.payInvoice(any(), anyOrNull())).thenReturn(Result.success(PAYMENT_ID)) + } + + /** Mirrors the forward pricing Boltz applies: a ceiled percentage fee plus the miner fees. */ + private fun received(invoiceSat: Long): Long = + invoiceSat - ceil(SWAP_FEE_PERCENT / 100.0 * invoiceSat).toLong() - SWAP_MINER_FEE.toLong() + + private fun reverseLimits() = BoltzPairInfo( + hash = "hash", + rate = 1.0, + minimalSat = SWAP_MIN, + maximalSat = SWAP_MAX, + feePercentage = SWAP_FEE_PERCENT, + minerFeesSat = SWAP_MINER_FEE, + ) + + private fun reverseSwapResponse(onchainAmountSat: ULong) = ReverseSwapResponse( + id = SWAP_ID, + invoice = INVOICE, + lockupAddress = "bc1qlockup", + onchainAmountSat = onchainAmountSat, + timeoutBlockHeight = 1uL, + ) + + private companion object { + const val ADDRESS = "bc1qrecipient" + const val INVOICE = "lnbc1invoice" + const val SWAP_ID = "swap-1" + const val CLAIM_TXID = "claim-txid" + const val PAYMENT_ID = "payment-id" + const val BOLTZ_ERROR = "boltz down" + const val PAY_ERROR = "route not found" + + const val RECIPIENT_SAT = 100_000uL + const val SPENDABLE_LN = 500_000uL + const val SWAP_MIN = 50_000uL + const val SWAP_MAX = 250_000uL + const val SWAP_FEE_PERCENT = 0.25 + const val SWAP_MINER_FEE = 320uL + } +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 20067b502f..3d62b1d3c8 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -65,6 +65,9 @@ import to.bitkit.repositories.PrivatePaykitRepo import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitRepo import to.bitkit.repositories.SamRockRepo +import to.bitkit.repositories.SendSwapQuote +import to.bitkit.repositories.SendSwapReceipt +import to.bitkit.repositories.SwapRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WalletState @@ -126,6 +129,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val publicPaykitRepo = mock() private val privatePaykitRepo = mock() private val samRockRepo = mock() + private val swapRepo = mock() private val widgetsRepo = mock() private val formatMoneyValue = mock() private val refreshContactPaykitReceivers = mock() @@ -219,6 +223,30 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .thenReturn(Result.success(2u)) whenever(lightningRepo.canSend(any())).thenReturn(true) whenever(toastManager.currentToast).thenReturn(MutableStateFlow(null)) + // Swaps off by default so the pre-swap send behaviour is what every other test exercises. + whenever { swapRepo.maxRecipientSats() }.thenReturn(Result.failure(AppError(SWAPS_OFF))) + whenever { swapRepo.quoteForRecipientAmount(any()) }.thenReturn(Result.failure(AppError(SWAPS_OFF))) + } + + /** + * Turn the swap fallback on for exactly [quotableSat]; every other amount keeps the default + * "no swap covers this" stub, which is how the flow sees an amount outside the swap range. + */ + private suspend fun stubSwapAvailable( + maxRecipientSat: ULong = SWAP_MAX_RECIPIENT, + quotableSat: ULong = SWAP_AMOUNT, + ) { + whenever(swapRepo.maxRecipientSats()).thenReturn(Result.success(maxRecipientSat)) + whenever(swapRepo.quoteForRecipientAmount(quotableSat)).thenReturn( + Result.success( + SendSwapQuote( + recipientSat = quotableSat, + invoiceSat = quotableSat + SWAP_SERVICE_FEE, + serviceFeeSat = SWAP_SERVICE_FEE, + networkFeeSat = SWAP_NETWORK_FEE, + ) + ) + ) } private fun stubSettingsStore() { @@ -261,6 +289,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { privatePaykitRepo = privatePaykitRepo, refreshContactPaykitReceivers = refreshContactPaykitReceivers, samRockRepo = samRockRepo, + swapRepo = swapRepo, appUpdateSheet = mock(), backupSheet = mock(), notificationsSheet = mock(), @@ -869,6 +898,114 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(before, sut.sendUiState.value.payMethod) } + // region swap send + + @Test + fun `amount above savings switches an onchain send to a swap`() = test { + stubSwapAvailable() + balanceState.value = BalanceState(maxSendOnchainSats = 10_000u, maxSendLightningSats = 500_000u) + setSendState(SendUiState(address = ONCHAIN_ADDRESS, speed = TransactionSpeed.Medium)) + + sut.setSendEvent(SendEvent.AmountChange(SWAP_AMOUNT)) + advanceUntilIdle() + + val state = sut.sendUiState.value + assertEquals(SendMethod.SWAP, state.payMethod) + assertTrue(state.isAmountInputValid) + assertEquals(SWAP_SERVICE_FEE, state.swapQuote?.serviceFeeSat) + assertEquals(SendFee.Swap(SWAP_NETWORK_FEE.toLong()), state.fee) + } + + @Test + fun `amount back within savings returns a swap send to onchain`() = test { + stubSwapAvailable() + balanceState.value = BalanceState(maxSendOnchainSats = 10_000u, maxSendLightningSats = 500_000u) + setSendState(SendUiState(address = ONCHAIN_ADDRESS, speed = TransactionSpeed.Medium)) + sut.setSendEvent(SendEvent.AmountChange(SWAP_AMOUNT)) + advanceUntilIdle() + + sut.setSendEvent(SendEvent.AmountChange(5_000u)) + advanceUntilIdle() + + val state = sut.sendUiState.value + assertEquals(SendMethod.ONCHAIN, state.payMethod) + assertNull(state.swapQuote) + } + + @Test + fun `amount above savings stays onchain when no swap covers it`() = test { + balanceState.value = BalanceState(maxSendOnchainSats = 10_000u, maxSendLightningSats = 500_000u) + setSendState(SendUiState(address = ONCHAIN_ADDRESS, speed = TransactionSpeed.Medium)) + + sut.setSendEvent(SendEvent.AmountChange(SWAP_AMOUNT)) + advanceUntilIdle() + + val state = sut.sendUiState.value + assertEquals(SendMethod.ONCHAIN, state.payMethod) + assertFalse(state.isAmountInputValid) + assertNull(state.swapQuote) + } + + @Test + fun `amount above what a swap can deliver is rejected`() = test { + stubSwapAvailable(maxRecipientSat = SWAP_AMOUNT, quotableSat = SWAP_AMOUNT) + balanceState.value = BalanceState(maxSendOnchainSats = 10_000u, maxSendLightningSats = 500_000u) + setSendState(SendUiState(address = ONCHAIN_ADDRESS, speed = TransactionSpeed.Medium)) + + sut.setSendEvent(SendEvent.AmountChange(SWAP_AMOUNT + 1u)) + advanceUntilIdle() + + assertFalse(sut.sendUiState.value.isAmountInputValid) + } + + @Test + fun `a swap send can be switched back to savings and forward again`() = test { + stubSwapAvailable() + balanceState.value = BalanceState(maxSendOnchainSats = 500_000u, maxSendLightningSats = 500_000u) + setSendState( + SendUiState( + address = ONCHAIN_ADDRESS, + amount = SWAP_AMOUNT, + payMethod = SendMethod.SWAP, + swapMaxSendSats = SWAP_MAX_RECIPIENT, + speed = TransactionSpeed.Medium, + ), + ) + + sut.setSendEvent(SendEvent.PaymentMethodSwitch) + advanceUntilIdle() + assertEquals(SendMethod.ONCHAIN, sut.sendUiState.value.payMethod) + assertNull(sut.sendUiState.value.swapQuote) + + sut.setSendEvent(SendEvent.PaymentMethodSwitch) + advanceUntilIdle() + assertEquals(SendMethod.SWAP, sut.sendUiState.value.payMethod) + assertEquals(SWAP_SERVICE_FEE, sut.sendUiState.value.swapQuote?.serviceFeeSat) + } + + @Test + fun `confirming a swap send pays the recipient address through the swap repo`() = test { + stubSwapAvailable() + whenever(swapRepo.payToAddress(any(), any())) + .thenReturn(Result.success(SendSwapReceipt(paymentHash = "hash", claimTxId = "claim-txid"))) + balanceState.value = BalanceState(maxSendLightningSats = 500_000u) + setSendState( + SendUiState( + address = ONCHAIN_ADDRESS, + amount = SWAP_AMOUNT, + payMethod = SendMethod.SWAP, + swapMaxSendSats = SWAP_MAX_RECIPIENT, + speed = TransactionSpeed.Medium, + ), + ) + + confirmCurrentPayment() + + verify(swapRepo).payToAddress(ONCHAIN_ADDRESS, SWAP_AMOUNT) + } + + // endregion + @Test fun `pending contact lightning success tags activity`() = test { val contactKey = "pubkycontact" @@ -1538,3 +1675,10 @@ 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 SWAPS_OFF = "swaps unavailable" +private const val ONCHAIN_ADDRESS = "bcrt1qswaprecipient" +private const val SWAP_AMOUNT = 100_000uL +private const val SWAP_MAX_RECIPIENT = 250_000uL +private const val SWAP_SERVICE_FEE = 570uL +private const val SWAP_NETWORK_FEE = 320uL From 13be9446526c60b24b4f369caee70d61a0126096 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Wed, 22 Jul 2026 15:06:14 -0400 Subject: [PATCH 2/2] docs: add changelog fragment --- changelog.d/next/1101.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1101.added.md diff --git a/changelog.d/next/1101.added.md b/changelog.d/next/1101.added.md new file mode 100644 index 0000000000..f8806b07bd --- /dev/null +++ b/changelog.d/next/1101.added.md @@ -0,0 +1 @@ +Sending to a Bitcoin address with insufficient savings can now be paid from your spending balance through a swap.