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
7 changes: 7 additions & 0 deletions app/src/main/java/to/bitkit/ext/ChannelDetails.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ fun List<ChannelDetails>?.totalNextOutboundHtlcLimitSats(): ULong {
?: 0u
}

/** Calculates the total remote balance (inbound capacity) from open channels. */
fun List<ChannelDetails>.calculateRemoteBalance(): ULong {
return this
.filterOpen()
.sumOf { it.inboundCapacityMsat / 1000u }
}

fun createChannelDetails(): ChannelDetails {
return ChannelDetails(
channelId = "channelId",
Expand Down
82 changes: 82 additions & 0 deletions app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import com.synonym.bitkitcore.IBtEstimateFeeResponse2
import com.synonym.bitkitcore.IBtInfo
import com.synonym.bitkitcore.IBtOrder
import com.synonym.bitkitcore.IcJitEntry
import com.synonym.bitkitcore.giftOrder
import com.synonym.bitkitcore.giftPay
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
Expand All @@ -27,9 +29,11 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.lightningdevkit.ldknode.ChannelDetails
import to.bitkit.async.ServiceQueue
import to.bitkit.data.CacheStore
import to.bitkit.di.BgDispatcher
import to.bitkit.env.Env
import to.bitkit.ext.calculateRemoteBalance
import to.bitkit.ext.nowTimestamp
import to.bitkit.models.BlocktankBackupV1
import to.bitkit.models.EUR_CURRENCY
Expand All @@ -43,15 +47,19 @@ import javax.inject.Named
import javax.inject.Singleton
import kotlin.math.ceil
import kotlin.math.min
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds

@Singleton
@Suppress("LongParameterList")
class BlocktankRepo @Inject constructor(
@BgDispatcher private val bgDispatcher: CoroutineDispatcher,
private val coreService: CoreService,
private val lightningService: LightningService,
private val currencyRepo: CurrencyRepo,
private val cacheStore: CacheStore,
@Named("enablePolling") private val enablePolling: Boolean,
private val lightningRepo: LightningRepo,
) {
private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob())

Expand Down Expand Up @@ -399,10 +407,74 @@ class BlocktankRepo @Inject constructor(
}
}

suspend fun claimGiftCode(
code: String,
amount: ULong,
waitTimeout: Duration = TIMEOUT_GIFT_CODE,
): Result<GiftClaimResult> = withContext(bgDispatcher) {
runCatching {
require(code.isNotBlank()) { "Gift code cannot be blank" }
require(amount > 0u) { "Gift amount must be positive" }

lightningRepo.executeWhenNodeRunning(
operationName = "claimGiftCode",
waitTimeout = waitTimeout,
) {
delay(PEER_CONNECTION_DELAY_MS)

val channels = lightningRepo.getChannelsAsync().getOrThrow()
val maxInboundCapacity = channels.calculateRemoteBalance()

if (maxInboundCapacity >= amount) {
Result.success(claimGiftCodeWithLiquidity(code))
} else {
Result.success(claimGiftCodeWithoutLiquidity(code, amount))
}
}.getOrThrow()
}.onFailure { e ->
Logger.error("Failed to claim gift code", e, context = TAG)
}
}

private suspend fun claimGiftCodeWithLiquidity(code: String): GiftClaimResult {
val invoice = lightningRepo.createInvoice(
amountSats = null,
description = "blocktank-gift-code:$code",
expirySeconds = 3600u,
).getOrThrow()

ServiceQueue.CORE.background {
giftPay(invoice = invoice)
}

return GiftClaimResult.SuccessWithLiquidity
}

private suspend fun claimGiftCodeWithoutLiquidity(code: String, amount: ULong): GiftClaimResult {
val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted

val order = ServiceQueue.CORE.background {
giftOrder(clientNodeId = nodeId, code = "blocktank-gift-code:$code")
}

val orderId = checkNotNull(order.orderId) { "Order ID is null" }

val openedOrder = openChannel(orderId).getOrThrow()

return GiftClaimResult.SuccessWithoutLiquidity(
paymentHashOrTxId = openedOrder.channel?.fundingTx?.id ?: orderId,
sats = amount.toLong(),
invoice = openedOrder.payment?.bolt11Invoice?.request ?: "",
code = code,
)
}

companion object {
private const val TAG = "BlocktankRepo"
private const val DEFAULT_CHANNEL_EXPIRY_WEEKS = 6u
private const val DEFAULT_SOURCE = "bitkit-android"
private const val PEER_CONNECTION_DELAY_MS = 2_000L
private val TIMEOUT_GIFT_CODE = 30.seconds
}
}

Expand All @@ -413,3 +485,13 @@ data class BlocktankState(
val info: IBtInfo? = null,
val minCjitSats: Int? = null,
)

sealed class GiftClaimResult {
object SuccessWithLiquidity : GiftClaimResult()
data class SuccessWithoutLiquidity(
val paymentHashOrTxId: String,
val sats: Long,
val invoice: String,
val code: String,
) : GiftClaimResult()
}
6 changes: 5 additions & 1 deletion app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ class LightningRepo @Inject constructor(
* @param operation Lambda to execute when the node is running
* @return Result of the operation, or failure if node isn't running or operation fails
*/
private suspend fun <T> executeWhenNodeRunning(
suspend fun <T> executeWhenNodeRunning(
operationName: String,
waitTimeout: Duration = 1.minutes,
operation: suspend () -> Result<T>,
Expand Down Expand Up @@ -823,6 +823,10 @@ class LightningRepo @Inject constructor(
Result.success(checkNotNull(lightningService.balances))
}

suspend fun getChannelsAsync(): Result<List<ChannelDetails>> = executeWhenNodeRunning("getChannelsAsync") {
Result.success(checkNotNull(lightningService.channels))
}

fun getStatus(): NodeStatus? =
if (_lightningState.value.nodeLifecycleState.isRunning()) lightningService.status else null

Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ import to.bitkit.ui.sheets.BackgroundPaymentsIntroSheet
import to.bitkit.ui.sheets.BackupRoute
import to.bitkit.ui.sheets.BackupSheet
import to.bitkit.ui.sheets.ForceTransferSheet
import to.bitkit.ui.sheets.GiftSheet
import to.bitkit.ui.sheets.HighBalanceWarningSheet
import to.bitkit.ui.sheets.LnurlAuthSheet
import to.bitkit.ui.sheets.PinSheet
Expand Down Expand Up @@ -363,6 +364,7 @@ fun ContentView(
is Sheet.Backup -> BackupSheet(sheet, onDismiss = { appViewModel.hideSheet() })
is Sheet.LnurlAuth -> LnurlAuthSheet(sheet, appViewModel)
Sheet.ForceTransfer -> ForceTransferSheet(appViewModel, transferViewModel)
is Sheet.Gift -> GiftSheet(sheet, appViewModel)
is Sheet.TimedSheet -> {
when (sheet.type) {
TimedSheetType.APP_UPDATE -> {
Expand Down
3 changes: 3 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 @@ -17,6 +17,7 @@ import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
Expand All @@ -30,6 +31,7 @@ import to.bitkit.ui.theme.Colors

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

@Stable
sealed interface Sheet {
data class Send(val route: SendRoute = SendRoute.Recipient) : Sheet
data object Receive : Sheet
Expand All @@ -39,6 +41,7 @@ sealed interface Sheet {
data object ActivityTagSelector : Sheet
data class LnurlAuth(val domain: String, val lnurl: String, val k1: String) : Sheet
data object ForceTransfer : Sheet
data class Gift(val code: String, val amount: ULong) : Sheet

data class TimedSheet(val type: TimedSheetType) : Sheet
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import org.lightningdevkit.ldknode.OutPoint
import to.bitkit.R
import to.bitkit.di.BgDispatcher
import to.bitkit.ext.amountOnClose
import to.bitkit.ext.calculateRemoteBalance
import to.bitkit.ext.createChannelDetails
import to.bitkit.ext.filterOpen
import to.bitkit.ext.filterPending
Expand Down Expand Up @@ -113,7 +114,7 @@ class LightningConnectionsViewModel @Inject constructor(
.map { it.mapToUiModel() },
failedOrders = getFailedOrdersAsChannels(blocktankState.paidOrders).map { it.mapToUiModel() },
localBalance = calculateLocalBalance(channels),
remoteBalance = calculateRemoteBalance(channels),
remoteBalance = channels.calculateRemoteBalance(),
)
}.collect { newState ->
_uiState.update { newState }
Expand Down Expand Up @@ -329,12 +330,6 @@ class LightningConnectionsViewModel @Inject constructor(
.sumOf { it.amountOnClose }
}

private fun calculateRemoteBalance(channels: List<ChannelDetails>): ULong {
return channels
.filterOpen()
.sumOf { it.inboundCapacityMsat / 1000u }
}

fun zipLogsForSharing(onReady: (Uri) -> Unit) {
viewModelScope.launch {
logsRepo.zipLogsForSharing()
Expand Down
88 changes: 88 additions & 0 deletions app/src/main/java/to/bitkit/ui/sheets/GiftErrorSheet.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package to.bitkit.ui.sheets

import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import to.bitkit.R
import to.bitkit.ui.components.BodyM
import to.bitkit.ui.components.FillHeight
import to.bitkit.ui.components.PrimaryButton
import to.bitkit.ui.components.SheetSize
import to.bitkit.ui.components.VerticalSpacer
import to.bitkit.ui.scaffold.SheetTopBar
import to.bitkit.ui.shared.modifiers.sheetHeight
import to.bitkit.ui.shared.util.gradientBackground
import to.bitkit.ui.theme.Colors

@Composable
fun GiftErrorSheet(
@StringRes titleRes: Int,
@StringRes textRes: Int,
testTag: String,
onDismiss: () -> Unit,
) {
Content(
titleRes = titleRes,
textRes = textRes,
testTag = testTag,
onDismiss = onDismiss,
)
}

@Composable
private fun Content(
@StringRes titleRes: Int,
@StringRes textRes: Int,
testTag: String,
modifier: Modifier = Modifier,
onDismiss: () -> Unit = {},
) {
Column(
modifier = modifier
.sheetHeight(SheetSize.LARGE)
.gradientBackground()
.navigationBarsPadding()
.padding(horizontal = 16.dp)
) {
SheetTopBar(titleText = stringResource(titleRes))
VerticalSpacer(16.dp)

BodyM(
text = stringResource(textRes),
color = Colors.White64,
)

FillHeight()

Image(
painter = painterResource(R.drawable.exclamation_mark),
contentDescription = null,
modifier = Modifier
.fillMaxWidth(IMAGE_WIDTH_FRACTION)
.aspectRatio(1.0f)
.align(Alignment.CenterHorizontally)
)

FillHeight()

PrimaryButton(
text = stringResource(R.string.common__ok),
onClick = onDismiss,
modifier = Modifier
.fillMaxWidth()
.testTag(testTag),
)
VerticalSpacer(16.dp)
}
}
Loading
Loading