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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions app/src/main/java/to/bitkit/models/CjitQuoteValidator.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package to.bitkit.models

import to.bitkit.utils.ServiceError

object CjitQuoteValidator {
fun validate(
invoiceSat: ULong,
feeSat: ULong,
channelSizeSat: ULong,
): Result<Unit> {
if (feeSat >= invoiceSat) {
return Result.failure(ServiceError.CjitQuoteInvalid())
}

val netReceiveSat = invoiceSat - feeSat
if (channelSizeSat < netReceiveSat) {
return Result.failure(ServiceError.CjitQuoteInvalid())
}

return Result.success(Unit)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ data class ReceiveAdditionalLiquidityParams(

object ReceiveLiquidityDecision {
fun canCreateLightningInvoice(
hasUsableChannels: Boolean,
hasReadyChannels: Boolean,
inboundCapacitySats: ULong?,
invoiceAmountSats: ULong?,
): Boolean {
if (!hasUsableChannels || inboundCapacitySats == null) return false
if (!hasReadyChannels || inboundCapacitySats == null) return false

if (invoiceAmountSats == null || invoiceAmountSats == 0uL) {
return inboundCapacitySats > 0uL
Expand Down
26 changes: 20 additions & 6 deletions app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import to.bitkit.ext.calculateRemoteBalance
import to.bitkit.ext.nowTimestamp
import to.bitkit.ext.runSuspendCatching
import to.bitkit.models.BlocktankBackupV1
import to.bitkit.models.CjitQuoteValidator
import to.bitkit.models.EUR
import to.bitkit.models.msatCeilOf
import to.bitkit.models.safe
Expand Down Expand Up @@ -279,6 +280,11 @@ class BlocktankRepo @Inject constructor(
channelExpiryWeeks = DEFAULT_CHANNEL_EXPIRY_WEEKS,
options = CreateCjitOptions(source = DEFAULT_SOURCE, discountCode = null)
)
CjitQuoteValidator.validate(
invoiceSat = amountSats,
feeSat = cjitEntry.feeSat,
channelSizeSat = cjitEntry.channelSizeSat,
).getOrThrow()

repoScope.launch { refreshOrders() }

Expand Down Expand Up @@ -728,22 +734,30 @@ class BlocktankRepo @Inject constructor(
}

internal fun Throwable.toCjitError(): Throwable {
if (this is ServiceError.ChannelSizeExceedsMaximum) return this
if (this is ServiceError.ChannelSizeExceedsMaximum ||
this is ServiceError.CjitQuoteInvalid ||
this is ServiceError.NodeCapacityUnavailable
) {
return this
}

return if (isMaxChannelSizeError()) {
ServiceError.ChannelSizeExceedsMaximum()
} else {
this
return when {
isNodeCapacityError() -> ServiceError.NodeCapacityUnavailable()
isMaxChannelSizeError() -> ServiceError.ChannelSizeExceedsMaximum()
else -> this
}
}

private fun Throwable.isNodeCapacityError(): Boolean {
return toString().contains("capacity is above our capacity limit", ignoreCase = true)
}

private fun Throwable.isMaxChannelSizeError(): Boolean {
val description = toString()
val maximumErrors = listOf(
"Channel size is too big",
"channelSizeExceedsMaximum",
"maxChannelSizeSat",
"capacity is above our capacity limit",
)
return maximumErrors.any { description.contains(it, ignoreCase = true) }
}
Expand Down
25 changes: 19 additions & 6 deletions app/src/main/java/to/bitkit/repositories/WalletRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,19 @@ class WalletRepo @Inject constructor(

fun setBip21AmountSats(amount: ULong?) = _walletState.update { it.copy(bip21AmountSats = amount) }

suspend fun updateOnchainBip21Amount(amountSats: ULong?): Result<Unit> = withContext(bgDispatcher) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low, latent: this moves bip21AmountSats but leaves walletState.bolt11 at the old amount.

updateBip21Invoice clears bolt11 when the amount can't be covered (:755-756); this rebuilds bip21 without lightning= but never clears bolt11. So after a CreateCjit edit the wallet holds bolt11 at 20k and bip21AmountSats at 100k.

It's masked today: CreateCjit only fires when the amount exceeds ready inbound, so canCreateLightningInvoice is false and getInvoiceForTab hides the stale bolt11, and a ChannelReady regenerates it. It would surface only if ready inbound grew without a ChannelReady (say, after an outbound payment) while the QR sits with cjitInvoice == null — i.e. after the Confirm→Back above — at which point Spending would show a payable 20k bolt11 under a 100k Savings request.

A setBolt11("") in here matches updateBip21Invoice's cannot-cover branch, and the existing test's never().createInvoice assert still holds.

runSuspendCatching {
val normalizedAmount = amountSats?.takeIf { it > 0uL }
setBip21AmountSats(normalizedAmount)
val newBip21 = buildBip21Url(
bitcoinAddress = getOnchainAddress(),
amountSats = normalizedAmount,
message = walletState.value.bip21Description,
)
setBip21(newBip21)
}
}

fun setBip21Description(description: String) = _walletState.update { it.copy(bip21Description = description) }

fun clearBip21State(clearTags: Boolean = true) {
Expand Down Expand Up @@ -756,14 +769,14 @@ class WalletRepo @Inject constructor(
}

suspend fun inboundLiquiditySats(): ULong = withContext(bgDispatcher) {
return@withContext currentUsableChannels().calculateRemoteBalance()
return@withContext currentReadyChannels().calculateRemoteBalance()
}

private fun canCreateLightningInvoice(amountSats: ULong?): Boolean {
val usableChannels = currentUsableChannels()
val readyChannels = currentReadyChannels()
return ReceiveLiquidityDecision.canCreateLightningInvoice(
hasUsableChannels = usableChannels.isNotEmpty(),
inboundCapacitySats = usableChannels.calculateRemoteBalance(),
hasReadyChannels = readyChannels.isNotEmpty(),
inboundCapacitySats = readyChannels.calculateRemoteBalance(),
invoiceAmountSats = amountSats,
)
}
Expand All @@ -772,8 +785,8 @@ class WalletRepo @Inject constructor(
return lightningRepo.getChannels() ?: lightningRepo.lightningState.value.channels
}

private fun currentUsableChannels(): List<ChannelDetails> {
return currentChannels().filter { it.isUsable }
private fun currentReadyChannels(): List<ChannelDetails> {
return currentChannels().filter { it.isChannelReady }
}

private suspend fun Scanner.OnChain.extractLightningHash(): String? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
Expand Down Expand Up @@ -103,6 +104,7 @@ fun EditInvoiceScreen(
editInvoiceVM: EditInvoiceVM = hiltViewModel(),
) {
val app = appViewModel ?: return
val context = LocalContext.current
val blocktankVM = blocktankViewModel ?: return
var keyboardVisible by remember { mutableStateOf(false) }
var isSoftKeyboardVisible by keyboardAsState()
Expand All @@ -128,20 +130,16 @@ fun EditInvoiceScreen(
}
is ReceiveAdditionalLiquidityAction.CreateCjit -> {
isCreatingCjit = true
runSuspendCatching { blocktankVM.createCjit(action.amountSats) }.onSuccess { entry ->
navigateReceiveConfirm(
CjitEntryDetails(
networkFeeSat = entry.networkFeeSat.toLong(),
serviceFeeSat = entry.serviceFeeSat.toLong(),
channelSizeSat = entry.channelSizeSat.toLong(),
feeSat = entry.feeSat.toLong(),
receiveAmountSats = action.amountSats.toLong(),
invoice = entry.invoice.request,
)
)
runSuspendCatching {
val entry = blocktankVM.createCjit(action.amountSats)
CjitEntryDetails.from(entry, action.amountSats).getOrThrow()
}.onSuccess {
navigateReceiveConfirm(it)
}.onFailure {
Logger.error("Failed to create CJIT invoice", it, context = "EditInvoiceScreen")
if (it !is ServiceError.ChannelSizeExceedsMaximum) {
if (!app.toastReceiveCjitError(context, it) &&
it !is ServiceError.ChannelSizeExceedsMaximum
) {
app.toast(it)
}
navigateCjitAmount()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,23 +143,17 @@ fun ReceiveAmountScreen(
}

val entry = blocktank.createCjit(amountSats = sats.toULong())
onCjitCreated(
CjitEntryDetails(
networkFeeSat = entry.networkFeeSat.toLong(),
serviceFeeSat = entry.serviceFeeSat.toLong(),
channelSizeSat = entry.channelSizeSat.toLong(),
feeSat = entry.feeSat.toLong(),
receiveAmountSats = sats,
invoice = entry.invoice.request,
)
)
onCjitCreated(CjitEntryDetails.from(entry, sats.toULong()).getOrThrow())
}.onFailure { e ->
Logger.error("Failed to create CJIT", e)
if (e is ServiceError.ChannelSizeExceedsMaximum) {
maxCjitAmountSats = runSuspendCatching { blocktank.maxCjitAmountSats() }.getOrNull()
maxCjitAmountSats?.let { showMaxExceededToast(it) } ?: app.toast(e)
} else {
app.toast(e)
when {
e is ServiceError.ChannelSizeExceedsMaximum -> {
maxCjitAmountSats = runSuspendCatching { blocktank.maxCjitAmountSats() }.getOrNull()
maxCjitAmountSats?.let { showMaxExceededToast(it) } ?: app.toast(e)
}
!app.toastReceiveCjitError(context, e) -> {
app.toast(e)
}
}
}
isCreatingInvoice = false
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package to.bitkit.ui.screens.wallets.receive

import android.content.Context
import to.bitkit.R
import to.bitkit.models.Toast
import to.bitkit.utils.ServiceError
import to.bitkit.viewmodels.AppViewModel

internal fun AppViewModel.toastReceiveCjitError(
context: Context,
error: Throwable,
): Boolean {
val title: String
val description: String
when (error) {
is ServiceError.CjitQuoteInvalid -> {
title = context.getString(R.string.wallet__receive_cjit_error_invalid__title)
description = context.getString(R.string.wallet__receive_cjit_error_invalid__description)
}
is ServiceError.NodeCapacityUnavailable -> {
title = context.getString(R.string.wallet__receive_cjit_error_node_capacity__title)
description = context.getString(R.string.wallet__receive_cjit_error_node_capacity__description)
}
else -> return false
}

toast(
type = Toast.ToastType.ERROR,
title = title,
description = description,
)
return true
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.synonym.bitkitcore.IcJitEntry
import kotlinx.serialization.Serializable
import to.bitkit.R
import to.bitkit.models.CjitQuoteValidator
import to.bitkit.models.PrimaryDisplay
import to.bitkit.ui.LocalCurrencies
import to.bitkit.ui.components.BalanceHeaderView
Expand Down Expand Up @@ -201,7 +203,26 @@ data class CjitEntryDetails(
val feeSat: Long,
val receiveAmountSats: Long,
val invoice: String,
)
) {
companion object {
fun from(entry: IcJitEntry, receiveAmountSats: ULong): Result<CjitEntryDetails> {
return CjitQuoteValidator.validate(
invoiceSat = receiveAmountSats,
feeSat = entry.feeSat,
channelSizeSat = entry.channelSizeSat,
).map {
CjitEntryDetails(
networkFeeSat = entry.networkFeeSat.toLong(),
serviceFeeSat = entry.serviceFeeSat.toLong(),
channelSizeSat = entry.channelSizeSat.toLong(),
feeSat = entry.feeSat.toLong(),
receiveAmountSats = receiveAmountSats.toLong(),
invoice = entry.invoice.request,
)
}
}
}
}

@Preview(showSystemUi = true)
@Composable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,18 +110,18 @@ fun ReceiveQrScreen(
SetMaxBrightness()

val haptic = LocalHapticFeedback.current
val hasUsableChannels = lightningState.channels.any { it.isUsable }
val usableInboundLiquiditySats = remember(lightningState.channels) {
lightningState.channels.filter { it.isUsable }.calculateRemoteBalance()
val hasReadyChannels = lightningState.channels.any { it.isChannelReady }
val readyInboundLiquiditySats = remember(lightningState.channels) {
lightningState.channels.filter { it.isChannelReady }.calculateRemoteBalance()
}
val canCreateLightningInvoice = remember(
hasUsableChannels,
usableInboundLiquiditySats,
hasReadyChannels,
readyInboundLiquiditySats,
walletState.bip21AmountSats,
) {
ReceiveLiquidityDecision.canCreateLightningInvoice(
hasUsableChannels = hasUsableChannels,
inboundCapacitySats = usableInboundLiquiditySats,
hasReadyChannels = hasReadyChannels,
inboundCapacitySats = readyInboundLiquiditySats,
invoiceAmountSats = walletState.bip21AmountSats,
)
}
Expand Down Expand Up @@ -383,10 +383,8 @@ fun ReceiveQrScreen(
qrLogoPainter = painterResource(getQrLogoResource(tab)),
onClickEditInvoice = if (tab == ReceiveTab.TREZOR) {
onClickHardwareEditInvoice
} else if (cjitInvoice.isNullOrEmpty()) {
{ onClickEditInvoice(tab) }
} else {
onClickReceiveCjit
{ onClickEditInvoice(tab) }
},
tab = tab,
modifier = Modifier.fillMaxWidth()
Expand Down
Loading
Loading