Skip to content

Commit 4b062fe

Browse files
authored
Merge pull request #509 from synonymdev/refactor/lsp-calcs
refactor: use bitkit-core for lsp liquidity calculations
2 parents df5b385 + 258319a commit 4b062fe

File tree

6 files changed

+65
-171
lines changed

6 files changed

+65
-171
lines changed

app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
package to.bitkit.repositories
22

33
import com.synonym.bitkitcore.BtOrderState2
4+
import com.synonym.bitkitcore.ChannelLiquidityOptions
5+
import com.synonym.bitkitcore.ChannelLiquidityParams
46
import com.synonym.bitkitcore.CreateCjitOptions
57
import com.synonym.bitkitcore.CreateOrderOptions
8+
import com.synonym.bitkitcore.DefaultLspBalanceParams
69
import com.synonym.bitkitcore.IBtEstimateFeeResponse2
710
import com.synonym.bitkitcore.IBtInfo
811
import com.synonym.bitkitcore.IBtOrder
912
import com.synonym.bitkitcore.IcJitEntry
13+
import com.synonym.bitkitcore.calculateChannelLiquidityOptions
14+
import com.synonym.bitkitcore.getDefaultLspBalance
1015
import com.synonym.bitkitcore.giftOrder
1116
import com.synonym.bitkitcore.giftPay
1217
import kotlinx.coroutines.CoroutineDispatcher
@@ -46,7 +51,6 @@ import javax.inject.Inject
4651
import javax.inject.Named
4752
import javax.inject.Singleton
4853
import kotlin.math.ceil
49-
import kotlin.math.min
5054
import kotlin.time.Duration
5155
import kotlin.time.Duration.Companion.seconds
5256

@@ -345,38 +349,49 @@ class BlocktankRepo @Inject constructor(
345349
refreshInfo()
346350
}
347351

348-
val maxLspBalance = _blocktankState.value.info?.options?.maxChannelSizeSat ?: 0uL
352+
val satsPerEur = getSatsPerEur()
353+
?: throw ServiceError.CurrencyRateUnavailable
349354

350-
// Calculate thresholds in sats
351-
val threshold1 = currencyRepo.convertFiatToSats(BigDecimal(225), EUR_CURRENCY).getOrNull()
352-
val threshold2 = currencyRepo.convertFiatToSats(BigDecimal(495), EUR_CURRENCY).getOrNull()
353-
val defaultLspBalanceSats = currencyRepo.convertFiatToSats(BigDecimal(450), EUR_CURRENCY).getOrNull()
355+
val params = DefaultLspBalanceParams(
356+
clientBalanceSat = clientBalance,
357+
maxChannelSizeSat = _blocktankState.value.info?.options?.maxChannelSizeSat ?: 0uL,
358+
satsPerEur = satsPerEur
359+
)
360+
361+
return@withContext getDefaultLspBalance(params)
362+
}
363+
364+
fun calculateLiquidityOptions(clientBalanceSat: ULong): Result<ChannelLiquidityOptions> {
365+
val blocktankInfo = blocktankState.value.info
366+
?: return Result.failure(ServiceError.BlocktankInfoUnavailable)
354367

355-
Logger.debug("getDefaultLspBalance - clientBalance: $clientBalance", context = TAG)
356-
Logger.debug("getDefaultLspBalance - maxLspBalance: $maxLspBalance", context = TAG)
357-
Logger.debug(
358-
"getDefaultLspBalance - defaultLspBalance: $defaultLspBalanceSats",
359-
context = TAG
368+
val satsPerEur = getSatsPerEur()
369+
?: return Result.failure(ServiceError.CurrencyRateUnavailable)
370+
371+
val existingChannelsTotalSat = totalBtChannelsValueSats(blocktankInfo)
372+
373+
val params = ChannelLiquidityParams(
374+
clientBalanceSat = clientBalanceSat,
375+
existingChannelsTotalSat = existingChannelsTotalSat,
376+
minChannelSizeSat = blocktankInfo.options.minChannelSizeSat,
377+
maxChannelSizeSat = blocktankInfo.options.maxChannelSizeSat,
378+
satsPerEur = satsPerEur
360379
)
361380

362-
if (threshold1 == null || threshold2 == null || defaultLspBalanceSats == null) {
363-
Logger.error("Failed to get rates for lspBalance calculation", context = TAG)
364-
throw ServiceError.CurrencyRateUnavailable
365-
}
381+
return Result.success(calculateChannelLiquidityOptions(params))
382+
}
366383

367-
// Safely calculate lspBalance to avoid arithmetic overflow
368-
var lspBalance: ULong = 0u
369-
if (defaultLspBalanceSats > clientBalance) {
370-
lspBalance = defaultLspBalanceSats - clientBalance
371-
}
372-
if (clientBalance > threshold1) {
373-
lspBalance = clientBalance
374-
}
375-
if (clientBalance > threshold2) {
376-
lspBalance = maxLspBalance
377-
}
384+
private fun getSatsPerEur(): ULong? {
385+
return currencyRepo.convertFiatToSats(BigDecimal(1), EUR_CURRENCY).getOrNull()
386+
}
387+
388+
private fun totalBtChannelsValueSats(info: IBtInfo?): ULong {
389+
val channels = lightningRepo.getChannels() ?: return 0u
390+
val btNodeIds = info?.nodes?.map { it.pubkey } ?: return 0u
391+
392+
val btChannels = channels.filter { btNodeIds.contains(it.counterpartyNodeId) }
378393

379-
return@withContext min(lspBalance, maxLspBalance)
394+
return btChannels.sumOf { it.channelValueSats }
380395
}
381396

382397
suspend fun resetState() = withContext(bgDispatcher) {

app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.requiredHeight
1010
import androidx.compose.material3.HorizontalDivider
1111
import androidx.compose.runtime.Composable
1212
import androidx.compose.runtime.LaunchedEffect
13-
import androidx.compose.runtime.collectAsState
1413
import androidx.compose.runtime.getValue
1514
import androidx.compose.runtime.mutableStateOf
1615
import androidx.compose.runtime.remember
@@ -67,10 +66,11 @@ fun SpendingAdvancedScreen(
6766
val app = appViewModel ?: return
6867
val state by viewModel.spendingUiState.collectAsStateWithLifecycle()
6968
val order = state.order ?: return
70-
val transferValues by viewModel.transferValues.collectAsState()
7169
val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle()
7270
var isLoading by remember { mutableStateOf(false) }
7371

72+
val transferValues by viewModel.transferValues.collectAsStateWithLifecycle()
73+
7474
LaunchedEffect(order.clientBalanceSat) {
7575
viewModel.updateTransferValues(order.clientBalanceSat)
7676
}
@@ -102,7 +102,7 @@ fun SpendingAdvancedScreen(
102102

103103
val isValid = transferValues.let {
104104
val amount = amountUiState.sats.toULong()
105-
amount > 0u && amount in it.minLspBalance..it.maxLspBalance
105+
amount > 0u && it.maxLspBalance > 0u && amount in it.minLspBalance..it.maxLspBalance
106106
}
107107

108108
Content(

app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ private fun SpendingAmountNodeRunning(
230230
PrimaryButton(
231231
text = stringResource(R.string.common__continue),
232232
onClick = onConfirmAmount,
233-
enabled = amountUiState.sats != 0L && amountUiState.sats <= uiState.maxAllowedToSend,
233+
enabled = !uiState.isLoading && amountUiState.sats <= uiState.maxAllowedToSend,
234234
isLoading = uiState.isLoading,
235235
modifier = Modifier.testTag("SpendingAmountContinue")
236236
)

app/src/main/java/to/bitkit/utils/Errors.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ sealed class ServiceError(message: String) : AppError(message) {
3030
data object NodeStillRunning : ServiceError("Node is still running")
3131
data object InvalidNodeSigningMessage : ServiceError("Invalid node signing message")
3232
data object CurrencyRateUnavailable : ServiceError("Currency rate unavailable")
33+
data object BlocktankInfoUnavailable : ServiceError("Blocktank info not available")
3334
data object GeoBlocked : ServiceError("Geo blocked user")
3435
}
3536

app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt

Lines changed: 17 additions & 139 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import android.content.Context
44
import androidx.lifecycle.ViewModel
55
import androidx.lifecycle.viewModelScope
66
import com.synonym.bitkitcore.BtOrderState2
7-
import com.synonym.bitkitcore.IBtInfo
87
import com.synonym.bitkitcore.IBtOrder
98
import dagger.hilt.android.lifecycle.HiltViewModel
109
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -32,22 +31,17 @@ import to.bitkit.data.CacheStore
3231
import to.bitkit.data.SettingsStore
3332
import to.bitkit.env.Env
3433
import to.bitkit.ext.amountOnClose
35-
import to.bitkit.models.EUR_CURRENCY
3634
import to.bitkit.models.Toast
3735
import to.bitkit.models.TransactionSpeed
3836
import to.bitkit.models.TransferType
3937
import to.bitkit.models.safe
4038
import to.bitkit.repositories.BlocktankRepo
41-
import to.bitkit.repositories.CurrencyRepo
4239
import to.bitkit.repositories.LightningRepo
4340
import to.bitkit.repositories.TransferRepo
4441
import to.bitkit.repositories.WalletRepo
4542
import to.bitkit.ui.shared.toast.ToastEventBus
4643
import to.bitkit.utils.Logger
47-
import to.bitkit.utils.ServiceError
48-
import java.math.BigDecimal
4944
import javax.inject.Inject
50-
import kotlin.math.max
5145
import kotlin.math.min
5246
import kotlin.math.roundToLong
5347
import kotlin.time.Duration.Companion.minutes
@@ -63,7 +57,6 @@ class TransferViewModel @Inject constructor(
6357
private val lightningRepo: LightningRepo,
6458
private val blocktankRepo: BlocktankRepo,
6559
private val walletRepo: WalletRepo,
66-
private val currencyRepo: CurrencyRepo,
6760
private val settingsStore: SettingsStore,
6861
private val cacheStore: CacheStore,
6962
private val transferRepo: TransferRepo,
@@ -94,7 +87,8 @@ class TransferViewModel @Inject constructor(
9487
// region Spending
9588

9689
fun onConfirmAmount(satsAmount: Long) {
97-
if (_transferValues.value.maxLspBalance == 0uL) {
90+
val values = blocktankRepo.calculateLiquidityOptions(satsAmount.toULong()).getOrNull()
91+
if (values == null || values.maxLspBalanceSat == 0uL) {
9892
setTransferEffect(
9993
TransferEffect.ToastError(
10094
title = context.getString(R.string.lightning__spending_amount__error_max__title),
@@ -105,28 +99,20 @@ class TransferViewModel @Inject constructor(
10599
)
106100
return
107101
}
102+
103+
val lspBalance = maxOf(values.defaultLspBalanceSat, values.minLspBalanceSat)
104+
108105
viewModelScope.launch {
109106
_spendingUiState.update { it.copy(isLoading = true) }
110107

111-
val minAmount = getMinAmountToPurchase(satsAmount)
112-
if (satsAmount < minAmount) {
113-
setTransferEffect(
114-
TransferEffect.ToastError(
115-
title = context.getString(R.string.lightning__spending_amount__error_min__title),
116-
description = context.getString(
117-
R.string.lightning__spending_amount__error_min__description
118-
).replace("{amount}", "$minAmount"),
119-
)
120-
)
121-
_spendingUiState.update { it.copy(isLoading = false) }
122-
return@launch
123-
}
124-
125108
withTimeoutOrNull(1.minutes) {
126109
isNodeRunning.first { it }
127110
}
128111

129-
blocktankRepo.createOrder(satsAmount.toULong())
112+
blocktankRepo.createOrder(
113+
spendingBalanceSats = satsAmount.toULong(),
114+
receivingBalanceSats = lspBalance,
115+
)
130116
.onSuccess { order ->
131117
settingsStore.update { it.copy(lightningSetupStep = 0) }
132118
onOrderCreated(order)
@@ -275,11 +261,6 @@ class TransferViewModel @Inject constructor(
275261
}
276262
}
277263

278-
private suspend fun getMinAmountToPurchase(satsAmount: Long = 0L): Long {
279-
val fee = lightningRepo.calculateTotalFee(satsAmount.toULong()).getOrNull() ?: 0u
280-
return max((fee + maxLspFee).toLong(), Env.TransactionDefaults.dustLimit.toLong())
281-
}
282-
283264
private suspend fun onOrderCreated(order: IBtOrder) {
284265
settingsStore.update { it.copy(lightningSetupStep = 0) }
285266
_spendingUiState.update { it.copy(order = order, isAdvanced = false, defaultOrder = null) }
@@ -361,120 +342,17 @@ class TransferViewModel @Inject constructor(
361342
// region Balance Calc
362343

363344
fun updateTransferValues(clientBalanceSat: ULong) {
364-
viewModelScope.launch {
365-
_transferValues.value = calculateTransferValues(clientBalanceSat)
366-
}
367-
}
368-
369-
fun calculateTransferValues(clientBalanceSat: ULong): TransferValues {
370-
val blocktankInfo = blocktankRepo.blocktankState.value.info
371-
if (blocktankInfo == null) return TransferValues()
372-
373-
// Calculate the total value of existing Blocktank channels
374-
val channelsSize = totalBtChannelsValueSats(blocktankInfo)
375-
376-
val minChannelSizeSat = blocktankInfo.options.minChannelSizeSat
377-
val maxChannelSizeSat = blocktankInfo.options.maxChannelSizeSat
378-
379-
// Because LSP limits constantly change depending on network fees
380-
// Add a 2% buffer to avoid fluctuations while making the order
381-
val maxChannelSize1 = (maxChannelSizeSat.toDouble() * 0.98).roundToLong().toULong()
382-
383-
// The maximum channel size the user can open including existing channels
384-
val maxChannelSize2 = maxChannelSize1.safe() - channelsSize.safe()
385-
val maxChannelSizeAvailableToIncrease = min(maxChannelSize1, maxChannelSize2)
386-
387-
val minLspBalance = getMinLspBalance(clientBalanceSat, minChannelSizeSat)
388-
val maxLspBalance = maxChannelSizeAvailableToIncrease.safe() - clientBalanceSat.safe()
389-
val defaultLspBalance = getDefaultLspBalance(clientBalanceSat, maxLspBalance)
390-
val maxClientBalance = getMaxClientBalance(maxChannelSizeAvailableToIncrease)
391-
392-
if (maxChannelSizeAvailableToIncrease < clientBalanceSat) {
393-
Logger.warn(
394-
"Amount clientBalanceSat:$clientBalanceSat too large, max possible: $maxChannelSizeAvailableToIncrease",
395-
context = TAG
396-
)
397-
}
398-
399-
if (defaultLspBalance !in minLspBalance..maxLspBalance) {
400-
Logger.warn(
401-
"Invalid defaultLspBalance:$defaultLspBalance " +
402-
"min possible:$maxLspBalance, " +
403-
"max possible: $minLspBalance",
404-
context = TAG
345+
val options = blocktankRepo.calculateLiquidityOptions(clientBalanceSat).getOrNull()
346+
_transferValues.value = if (options != null) {
347+
TransferValues(
348+
defaultLspBalance = options.defaultLspBalanceSat,
349+
minLspBalance = options.minLspBalanceSat,
350+
maxLspBalance = options.maxLspBalanceSat,
351+
maxClientBalance = options.maxClientBalanceSat,
405352
)
406-
}
407-
408-
if (maxChannelSizeAvailableToIncrease <= 0u) {
409-
Logger.warn("Max channel size reached. current size: $channelsSize sats", context = TAG)
410-
}
411-
412-
if (maxClientBalance <= 0u) {
413-
Logger.warn("No liquidity available to purchase $maxClientBalance", context = TAG)
414-
}
415-
416-
return TransferValues(
417-
defaultLspBalance = defaultLspBalance,
418-
minLspBalance = minLspBalance,
419-
maxLspBalance = maxLspBalance,
420-
maxClientBalance = maxClientBalance,
421-
)
422-
}
423-
424-
private fun getDefaultLspBalance(clientBalanceSat: ULong, maxLspBalance: ULong): ULong {
425-
// Calculate thresholds in sats
426-
val threshold1 = currencyRepo.convertFiatToSats(BigDecimal(225), EUR_CURRENCY).getOrNull()
427-
val threshold2 = currencyRepo.convertFiatToSats(BigDecimal(495), EUR_CURRENCY).getOrNull()
428-
val defaultLspBalanceSats = currencyRepo.convertFiatToSats(BigDecimal(450), EUR_CURRENCY).getOrNull()
429-
430-
Logger.debug("getDefaultLspBalance - clientBalanceSat: $clientBalanceSat")
431-
Logger.debug("getDefaultLspBalance - maxLspBalance: $maxLspBalance")
432-
Logger.debug("getDefaultLspBalance - defaultLspBalanceSats: $defaultLspBalanceSats")
433-
434-
if (threshold1 == null || threshold2 == null || defaultLspBalanceSats == null) {
435-
Logger.error("Failed to get rates for lspBalance calculation", context = TAG)
436-
throw ServiceError.CurrencyRateUnavailable
437-
}
438-
439-
val lspBalance = if (clientBalanceSat < threshold1) { // 0-225€: LSP balance = 450€ - client balance
440-
defaultLspBalanceSats.safe() - clientBalanceSat.safe()
441-
} else if (clientBalanceSat < threshold2) { // 225-495€: LSP balance = client balance
442-
clientBalanceSat
443-
} else if (clientBalanceSat < maxLspBalance) { // 495-950€: LSP balance = max - client balance
444-
maxLspBalance.safe() - clientBalanceSat.safe()
445353
} else {
446-
maxLspBalance
354+
TransferValues()
447355
}
448-
449-
return min(lspBalance, maxLspBalance)
450-
}
451-
452-
private fun getMinLspBalance(clientBalance: ULong, minChannelSize: ULong): ULong {
453-
// LSP balance must be at least 2.5% of the channel size for LDK to accept (reserve balance)
454-
val ldkMinimum = (clientBalance.toDouble() * 0.025).toULong()
455-
// Channel size must be at least minChannelSize
456-
val lspMinimum = minChannelSize.safe() - clientBalance.safe()
457-
458-
return max(ldkMinimum, lspMinimum)
459-
}
460-
461-
private fun getMaxClientBalance(maxChannelSize: ULong): ULong {
462-
// Remote balance must be at least 2.5% of the channel size for LDK to accept (reserve balance)
463-
val minRemoteBalance = (maxChannelSize.toDouble() * 0.025).toULong()
464-
465-
return maxChannelSize.safe() - minRemoteBalance.safe()
466-
}
467-
468-
/** Calculates the total value of channels connected to Blocktank nodes */
469-
private fun totalBtChannelsValueSats(info: IBtInfo?): ULong {
470-
val channels = lightningRepo.getChannels() ?: return 0u
471-
val btNodeIds = info?.nodes?.map { it.pubkey } ?: return 0u
472-
473-
val btChannels = channels.filter { btNodeIds.contains(it.counterpartyNodeId) }
474-
475-
val totalValue = btChannels.sumOf { it.channelValueSats }
476-
477-
return totalValue
478356
}
479357

480358
// endregion

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version.ref
4444
appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
4545
barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "barcodeScanning" }
4646
biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" }
47-
bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.30" }
47+
bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.31" }
4848
bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncyCastle" }
4949
camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" }
5050
camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" }

0 commit comments

Comments
 (0)