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

Filter by extension

Filter by extension

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

enum class ReceiveLiquiditySource {
SAVINGS,
AUTO,
SPENDING,
}

sealed interface ReceiveAdditionalLiquidityAction {
data object None : ReceiveAdditionalLiquidityAction
data object ChooseAmount : ReceiveAdditionalLiquidityAction
data class CreateCjit(val amountSats: ULong) : ReceiveAdditionalLiquidityAction
data object GeoBlocked : ReceiveAdditionalLiquidityAction
}

data class ReceiveAdditionalLiquidityParams(
val source: ReceiveLiquiditySource,
val invoiceAmountSats: ULong,
val inboundCapacitySats: ULong?,
val minCjitSats: ULong?,
val maxCjitAmountSats: ULong?,
val isGeoBlocked: Boolean,
)

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

if (invoiceAmountSats == null || invoiceAmountSats == 0uL) {
return inboundCapacitySats > 0uL
}

return invoiceAmountSats <= inboundCapacitySats
}

fun additionalLiquidityAction(params: ReceiveAdditionalLiquidityParams): ReceiveAdditionalLiquidityAction {
return when {
params.source != ReceiveLiquiditySource.SPENDING -> ReceiveAdditionalLiquidityAction.None
!needsInboundLiquidity(params.invoiceAmountSats, params.inboundCapacitySats) ->
ReceiveAdditionalLiquidityAction.None
(params.inboundCapacitySats ?: 0uL) == 0uL -> ReceiveAdditionalLiquidityAction.None
params.isGeoBlocked -> ReceiveAdditionalLiquidityAction.GeoBlocked
shouldChooseAmount(params) -> ReceiveAdditionalLiquidityAction.ChooseAmount
else -> ReceiveAdditionalLiquidityAction.CreateCjit(params.invoiceAmountSats)
}
}

fun needsCjitLimitsForAdditionalLiquidity(
source: ReceiveLiquiditySource,
invoiceAmountSats: ULong,
inboundCapacitySats: ULong?,
isGeoBlocked: Boolean,
): Boolean {
if (source != ReceiveLiquiditySource.SPENDING) return false
if (!needsInboundLiquidity(invoiceAmountSats, inboundCapacitySats)) return false
if ((inboundCapacitySats ?: 0uL) == 0uL) return false

return !isGeoBlocked
}

fun needsInboundLiquidity(
invoiceAmountSats: ULong,
inboundCapacitySats: ULong?,
): Boolean {
val inbound = inboundCapacitySats ?: 0uL

if (invoiceAmountSats == 0uL) {
return inbound == 0uL
}

return invoiceAmountSats > inbound
}

private fun shouldChooseAmount(params: ReceiveAdditionalLiquidityParams): Boolean {
val min = params.minCjitSats ?: 0uL
val max = params.maxCjitAmountSats?.takeIf { it > 0uL } ?: return true
if (params.invoiceAmountSats == 0uL || min == 0uL) return true

return params.invoiceAmountSats < min || params.invoiceAmountSats > max
}
}
88 changes: 84 additions & 4 deletions app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,11 @@ import to.bitkit.di.BgDispatcher
import to.bitkit.env.Env
import to.bitkit.ext.calculateRemoteBalance
import to.bitkit.ext.nowTimestamp
import to.bitkit.ext.runSuspendCatching
import to.bitkit.models.BlocktankBackupV1
import to.bitkit.models.EUR
import to.bitkit.models.msatCeilOf
import to.bitkit.models.safe
import to.bitkit.services.CoreService
import to.bitkit.services.LightningService
import to.bitkit.utils.Logger
Expand Down Expand Up @@ -249,11 +251,18 @@ class BlocktankRepo @Inject constructor(
amountSats: ULong,
description: String = "",
): Result<IcJitEntry> = withContext(bgDispatcher) {
runCatching {
runSuspendCatching {
if (coreService.isGeoBlocked()) throw ServiceError.GeoBlocked()
val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted()
val maxChannelSizeSat = freshMaxChannelSizeSat()
if (maxChannelSizeSat != null && amountSats > maxChannelSizeSat) {
throw ServiceError.ChannelSizeExceedsMaximum()
}
val lspBalance = getDefaultLspBalance(clientBalance = amountSats)
val channelSizeSat = amountSats + lspBalance
if (!canFitChannelSize(amountSats, lspBalance)) {
throw ServiceError.ChannelSizeExceedsMaximum()
}
val channelSizeSat = amountSats.safe() + lspBalance.safe()

val cjitEntry = coreService.blocktank.createCjit(
channelSizeSat = channelSizeSat,
Expand All @@ -266,12 +275,38 @@ class BlocktankRepo @Inject constructor(

repoScope.launch { refreshOrders() }

return@runCatching cjitEntry
}.onFailure {
return@runSuspendCatching cjitEntry
}.fold(
onSuccess = { Result.success(it) },
onFailure = { Result.failure(it.toCjitError()) },
).onFailure {
Logger.error("Failed to create CJIT", it, context = TAG)
}
}

suspend fun maxCjitAmountSats(): Result<ULong?> = withContext(bgDispatcher) {
runSuspendCatching {
val maxChannelSizeSat = freshMaxChannelSizeSat() ?: return@runSuspendCatching null
var lowerBound = 0uL
var upperBound = maxChannelSizeSat

while (lowerBound < upperBound) {
val distance = upperBound.safe() - lowerBound.safe()
val step = (distance.safe() + 1uL.safe()) / 2uL
val candidate = lowerBound.safe() + step.safe()
if (canCreateCjit(candidate, maxChannelSizeSat)) {
lowerBound = candidate
} else {
upperBound = candidate - 1uL
}
}

lowerBound
}.onFailure {
Logger.error("Failed to calculate max CJIT amount", it, context = TAG)
}
}

suspend fun createOrder(
spendingBalanceSats: ULong,
receivingBalanceSats: ULong = spendingBalanceSats * 2u,
Expand Down Expand Up @@ -409,6 +444,30 @@ class BlocktankRepo @Inject constructor(
return@withContext getDefaultLspBalance(params)
}

private suspend fun freshMaxChannelSizeSat(): ULong? {
refreshInfo()

return _blocktankState.value.info?.options?.maxChannelSizeSat?.takeIf { it > 0uL }
}

private suspend fun canCreateCjit(amountSats: ULong, maxChannelSizeSat: ULong): Boolean {
if (amountSats > maxChannelSizeSat) return false

val lspBalance = getDefaultLspBalance(clientBalance = amountSats)
val remainingCapacity = maxChannelSizeSat.safe() - amountSats.safe()
return lspBalance <= remainingCapacity
}

private fun canFitChannelSize(amountSats: ULong, lspBalance: ULong): Boolean {
val maxChannelSizeSat = _blocktankState.value.info?.options?.maxChannelSizeSat?.takeIf { it > 0uL }
?: return true

if (amountSats > maxChannelSizeSat) return false

val remainingCapacity = maxChannelSizeSat.safe() - amountSats.safe()
return lspBalance <= remainingCapacity
}

fun calculateLiquidityOptions(clientBalanceSat: ULong): Result<ChannelLiquidityOptions> {
val blocktankInfo = blocktankState.value.info
?: return Result.failure(ServiceError.BlocktankInfoUnavailable())
Expand Down Expand Up @@ -600,6 +659,27 @@ class BlocktankRepo @Inject constructor(
}
}

internal fun Throwable.toCjitError(): Throwable {
if (this is ServiceError.ChannelSizeExceedsMaximum) return this

return if (isMaxChannelSizeError()) {
ServiceError.ChannelSizeExceedsMaximum()
} else {
this
}
}

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) }
}

@Stable
data class BlocktankState(
val orders: ImmutableList<IBtOrder> = persistentListOf(),
Expand Down
45 changes: 29 additions & 16 deletions app/src/main/java/to/bitkit/repositories/WalletRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.lightningdevkit.ldknode.Bolt11Invoice
import org.lightningdevkit.ldknode.ChannelDetails
import org.lightningdevkit.ldknode.Event
import org.lightningdevkit.ldknode.WordCount
import to.bitkit.async.appScope
Expand All @@ -28,16 +29,16 @@ import to.bitkit.data.SettingsStore
import to.bitkit.data.keychain.Keychain
import to.bitkit.di.BgDispatcher
import to.bitkit.env.Env
import to.bitkit.ext.filterOpen
import to.bitkit.ext.calculateRemoteBalance
import to.bitkit.ext.nowTimestamp
import to.bitkit.ext.runSuspendCatching
import to.bitkit.ext.toHex
import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS
import to.bitkit.models.AddressModel
import to.bitkit.models.BalanceState
import to.bitkit.models.DEFAULT_ADDRESS_TYPE_STRING
import to.bitkit.models.ReceiveLiquidityDecision
import to.bitkit.models.WalletScope
import to.bitkit.models.msatFloorOf
import to.bitkit.models.toAccountDerivationPath
import to.bitkit.models.toBalance
import to.bitkit.models.toDerivationPath
Expand Down Expand Up @@ -318,11 +319,18 @@ class WalletRepo @Inject constructor(
settledReceiveInvoice: SettledReceiveInvoice? = null,
settledReceiveAddress: SettledReceiveAddress? = null,
) = withContext(bgDispatcher) {
when (event) {
is Event.ChannelReady,
is Event.ChannelClosed,
-> lightningRepo.syncState()
Comment thread
pwltr marked this conversation as resolved.
else -> Unit
}

when (event) {
is Event.ChannelReady -> {
// Only refresh bolt11 if we can now receive on lightning
Logger.debug("refreshBip21ForEvent: $event", context = TAG)
if (lightningRepo.canReceive()) {
if (canCreateLightningInvoice(_walletState.value.bip21AmountSats)) {
lightningRepo.createInvoice(
amountSats = _walletState.value.bip21AmountSats,
description = _walletState.value.bip21Description,
Expand All @@ -336,7 +344,7 @@ class WalletRepo @Inject constructor(
is Event.ChannelClosed -> {
// Clear bolt11 if we can no longer receive on lightning
Logger.debug("refreshBip21ForEvent: $event", context = TAG)
if (!lightningRepo.canReceive()) {
if (!canCreateLightningInvoice(_walletState.value.bip21AmountSats)) {
setBolt11("")
updateBip21Url()
}
Expand Down Expand Up @@ -727,8 +735,7 @@ class WalletRepo @Inject constructor(
setBip21AmountSats(amountSats)
setBip21Description(description)

val canReceive = lightningRepo.canReceive()
if (canReceive) {
if (canCreateLightningInvoice(amountSats)) {
lightningRepo.createInvoice(amountSats, description).onSuccess {
setBolt11(it)
}
Expand All @@ -748,19 +755,25 @@ class WalletRepo @Inject constructor(
}
}

suspend fun shouldRequestAdditionalLiquidity(): Result<Boolean> = withContext(bgDispatcher) {
runCatching {
if (coreService.isGeoBlocked()) return@runCatching false
suspend fun inboundLiquiditySats(): ULong = withContext(bgDispatcher) {
return@withContext currentUsableChannels().calculateRemoteBalance()
}

val channels = lightningRepo.lightningState.value.channels
if (channels.filterOpen().isEmpty()) return@runCatching false
private fun canCreateLightningInvoice(amountSats: ULong?): Boolean {
val usableChannels = currentUsableChannels()
return ReceiveLiquidityDecision.canCreateLightningInvoice(
hasUsableChannels = usableChannels.isNotEmpty(),
inboundCapacitySats = usableChannels.calculateRemoteBalance(),
invoiceAmountSats = amountSats,
)
}

val inboundBalanceSats = channels.sumOf { msatFloorOf(it.inboundCapacityMsat) }
private fun currentChannels(): List<ChannelDetails> {
return lightningRepo.getChannels() ?: lightningRepo.lightningState.value.channels
Comment thread
pwltr marked this conversation as resolved.
}

return@runCatching (_walletState.value.bip21AmountSats ?: 0uL) >= inboundBalanceSats
}.onFailure {
Logger.error("shouldRequestAdditionalLiquidity error", it, context = TAG)
}
private fun currentUsableChannels(): List<ChannelDetails> {
return currentChannels().filter { it.isUsable }
}

private suspend fun Scanner.OnChain.extractLightningHash(): String? {
Expand Down
28 changes: 17 additions & 11 deletions app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
Expand Down Expand Up @@ -509,17 +510,20 @@ fun ContentView(
is Sheet.Receive -> {
val walletState by walletViewModel.walletState.collectAsStateWithLifecycle()
val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle()
ReceiveSheet(
appViewModel = appViewModel,
startRoute = sheet.route,
hardwareWalletId = sheet.hardwareWalletId,
walletState = walletState,
isOffline = connectivityState != ConnectivityState.CONNECTED,
navigateToExternalConnection = {
navController.navigateTo(ExternalConnection())
appViewModel.hideSheet()
},
)

key(receiveSheetPresentationKey(sheet)) {
ReceiveSheet(
appViewModel = appViewModel,
startRoute = sheet.route,
hardwareWalletId = sheet.hardwareWalletId,
walletState = walletState,
isOffline = connectivityState != ConnectivityState.CONNECTED,
navigateToExternalConnection = {
navController.navigateTo(ExternalConnection())
appViewModel.hideSheet()
},
)
}
}

Sheet.PaymentRequests -> PaymentRequestsSheet(
Expand Down Expand Up @@ -1966,6 +1970,8 @@ fun NavController.navigateToTransferSpendingStart(
internal fun shouldDismissSheetForScreenLink(handled: Boolean, currentSheet: Sheet?): Boolean =
handled && currentSheet != null

internal fun receiveSheetPresentationKey(sheet: Sheet.Receive): String = sheet.presentationId

internal fun transferEffectDestination(effect: TransferEffect): Routes? = when (effect) {
TransferEffect.OnHwTxSigned -> Routes.SpendingHwSigned
TransferEffect.OnSpendingFundingPaid -> Routes.SettingUp
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/to/bitkit/ui/components/SheetHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import to.bitkit.ui.sheets.WidgetsRoute
import to.bitkit.ui.sheets.hardware.HardwareRoute
import to.bitkit.ui.theme.AppShapes
import to.bitkit.ui.theme.Colors
import java.util.UUID

enum class SheetSize { LARGE, MEDIUM, COMPACT, SMALL, CALENDAR; }

Expand All @@ -61,6 +62,7 @@ sealed interface Sheet {
data class Receive(
val route: ReceiveRoute = ReceiveRoute.QR,
val hardwareWalletId: String? = null,
val presentationId: String = UUID.randomUUID().toString(),
) : Sheet
data object PaymentRequests : Sheet
data class Pin(val route: PinRoute = PinRoute.Prompt()) : Sheet
Expand Down
Loading
Loading