Skip to content

Commit b68efb6

Browse files
authored
Merge pull request #473 from synonymdev/feat/gift-codes
Add gift codes support
2 parents c93b451 + 7072c31 commit b68efb6

File tree

15 files changed

+621
-9
lines changed

15 files changed

+621
-9
lines changed

app/src/main/java/to/bitkit/ext/ChannelDetails.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ fun List<ChannelDetails>?.totalNextOutboundHtlcLimitSats(): ULong {
3939
?: 0u
4040
}
4141

42+
/** Calculates the total remote balance (inbound capacity) from open channels. */
43+
fun List<ChannelDetails>.calculateRemoteBalance(): ULong {
44+
return this
45+
.filterOpen()
46+
.sumOf { it.inboundCapacityMsat / 1000u }
47+
}
48+
4249
fun createChannelDetails(): ChannelDetails {
4350
return ChannelDetails(
4451
channelId = "channelId",

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

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import com.synonym.bitkitcore.IBtEstimateFeeResponse2
77
import com.synonym.bitkitcore.IBtInfo
88
import com.synonym.bitkitcore.IBtOrder
99
import com.synonym.bitkitcore.IcJitEntry
10+
import com.synonym.bitkitcore.giftOrder
11+
import com.synonym.bitkitcore.giftPay
1012
import kotlinx.coroutines.CoroutineDispatcher
1113
import kotlinx.coroutines.CoroutineScope
1214
import kotlinx.coroutines.SupervisorJob
@@ -27,9 +29,11 @@ import kotlinx.coroutines.isActive
2729
import kotlinx.coroutines.launch
2830
import kotlinx.coroutines.withContext
2931
import org.lightningdevkit.ldknode.ChannelDetails
32+
import to.bitkit.async.ServiceQueue
3033
import to.bitkit.data.CacheStore
3134
import to.bitkit.di.BgDispatcher
3235
import to.bitkit.env.Env
36+
import to.bitkit.ext.calculateRemoteBalance
3337
import to.bitkit.ext.nowTimestamp
3438
import to.bitkit.models.BlocktankBackupV1
3539
import to.bitkit.models.EUR_CURRENCY
@@ -43,15 +47,19 @@ import javax.inject.Named
4347
import javax.inject.Singleton
4448
import kotlin.math.ceil
4549
import kotlin.math.min
50+
import kotlin.time.Duration
51+
import kotlin.time.Duration.Companion.seconds
4652

4753
@Singleton
54+
@Suppress("LongParameterList")
4855
class BlocktankRepo @Inject constructor(
4956
@BgDispatcher private val bgDispatcher: CoroutineDispatcher,
5057
private val coreService: CoreService,
5158
private val lightningService: LightningService,
5259
private val currencyRepo: CurrencyRepo,
5360
private val cacheStore: CacheStore,
5461
@Named("enablePolling") private val enablePolling: Boolean,
62+
private val lightningRepo: LightningRepo,
5563
) {
5664
private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob())
5765

@@ -399,10 +407,74 @@ class BlocktankRepo @Inject constructor(
399407
}
400408
}
401409

410+
suspend fun claimGiftCode(
411+
code: String,
412+
amount: ULong,
413+
waitTimeout: Duration = TIMEOUT_GIFT_CODE,
414+
): Result<GiftClaimResult> = withContext(bgDispatcher) {
415+
runCatching {
416+
require(code.isNotBlank()) { "Gift code cannot be blank" }
417+
require(amount > 0u) { "Gift amount must be positive" }
418+
419+
lightningRepo.executeWhenNodeRunning(
420+
operationName = "claimGiftCode",
421+
waitTimeout = waitTimeout,
422+
) {
423+
delay(PEER_CONNECTION_DELAY_MS)
424+
425+
val channels = lightningRepo.getChannelsAsync().getOrThrow()
426+
val maxInboundCapacity = channels.calculateRemoteBalance()
427+
428+
if (maxInboundCapacity >= amount) {
429+
Result.success(claimGiftCodeWithLiquidity(code))
430+
} else {
431+
Result.success(claimGiftCodeWithoutLiquidity(code, amount))
432+
}
433+
}.getOrThrow()
434+
}.onFailure { e ->
435+
Logger.error("Failed to claim gift code", e, context = TAG)
436+
}
437+
}
438+
439+
private suspend fun claimGiftCodeWithLiquidity(code: String): GiftClaimResult {
440+
val invoice = lightningRepo.createInvoice(
441+
amountSats = null,
442+
description = "blocktank-gift-code:$code",
443+
expirySeconds = 3600u,
444+
).getOrThrow()
445+
446+
ServiceQueue.CORE.background {
447+
giftPay(invoice = invoice)
448+
}
449+
450+
return GiftClaimResult.SuccessWithLiquidity
451+
}
452+
453+
private suspend fun claimGiftCodeWithoutLiquidity(code: String, amount: ULong): GiftClaimResult {
454+
val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted
455+
456+
val order = ServiceQueue.CORE.background {
457+
giftOrder(clientNodeId = nodeId, code = "blocktank-gift-code:$code")
458+
}
459+
460+
val orderId = checkNotNull(order.orderId) { "Order ID is null" }
461+
462+
val openedOrder = openChannel(orderId).getOrThrow()
463+
464+
return GiftClaimResult.SuccessWithoutLiquidity(
465+
paymentHashOrTxId = openedOrder.channel?.fundingTx?.id ?: orderId,
466+
sats = amount.toLong(),
467+
invoice = openedOrder.payment?.bolt11Invoice?.request ?: "",
468+
code = code,
469+
)
470+
}
471+
402472
companion object {
403473
private const val TAG = "BlocktankRepo"
404474
private const val DEFAULT_CHANNEL_EXPIRY_WEEKS = 6u
405475
private const val DEFAULT_SOURCE = "bitkit-android"
476+
private const val PEER_CONNECTION_DELAY_MS = 2_000L
477+
private val TIMEOUT_GIFT_CODE = 30.seconds
406478
}
407479
}
408480

@@ -413,3 +485,13 @@ data class BlocktankState(
413485
val info: IBtInfo? = null,
414486
val minCjitSats: Int? = null,
415487
)
488+
489+
sealed class GiftClaimResult {
490+
object SuccessWithLiquidity : GiftClaimResult()
491+
data class SuccessWithoutLiquidity(
492+
val paymentHashOrTxId: String,
493+
val sats: Long,
494+
val invoice: String,
495+
val code: String,
496+
) : GiftClaimResult()
497+
}

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ class LightningRepo @Inject constructor(
102102
* @param operation Lambda to execute when the node is running
103103
* @return Result of the operation, or failure if node isn't running or operation fails
104104
*/
105-
private suspend fun <T> executeWhenNodeRunning(
105+
suspend fun <T> executeWhenNodeRunning(
106106
operationName: String,
107107
waitTimeout: Duration = 1.minutes,
108108
operation: suspend () -> Result<T>,
@@ -823,6 +823,10 @@ class LightningRepo @Inject constructor(
823823
Result.success(checkNotNull(lightningService.balances))
824824
}
825825

826+
suspend fun getChannelsAsync(): Result<List<ChannelDetails>> = executeWhenNodeRunning("getChannelsAsync") {
827+
Result.success(checkNotNull(lightningService.channels))
828+
}
829+
826830
fun getStatus(): NodeStatus? =
827831
if (_lightningState.value.nodeLifecycleState.isRunning()) lightningService.status else null
828832

app/src/main/java/to/bitkit/ui/ContentView.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ import to.bitkit.ui.sheets.BackgroundPaymentsIntroSheet
147147
import to.bitkit.ui.sheets.BackupRoute
148148
import to.bitkit.ui.sheets.BackupSheet
149149
import to.bitkit.ui.sheets.ForceTransferSheet
150+
import to.bitkit.ui.sheets.GiftSheet
150151
import to.bitkit.ui.sheets.HighBalanceWarningSheet
151152
import to.bitkit.ui.sheets.LnurlAuthSheet
152153
import to.bitkit.ui.sheets.PinSheet
@@ -363,6 +364,7 @@ fun ContentView(
363364
is Sheet.Backup -> BackupSheet(sheet, onDismiss = { appViewModel.hideSheet() })
364365
is Sheet.LnurlAuth -> LnurlAuthSheet(sheet, appViewModel)
365366
Sheet.ForceTransfer -> ForceTransferSheet(appViewModel, transferViewModel)
367+
is Sheet.Gift -> GiftSheet(sheet, appViewModel)
366368
is Sheet.TimedSheet -> {
367369
when (sheet.type) {
368370
TimedSheetType.APP_UPDATE -> {

app/src/main/java/to/bitkit/ui/components/SheetHost.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import androidx.compose.material3.rememberBottomSheetScaffoldState
1717
import androidx.compose.material3.rememberModalBottomSheetState
1818
import androidx.compose.runtime.Composable
1919
import androidx.compose.runtime.LaunchedEffect
20+
import androidx.compose.runtime.Stable
2021
import androidx.compose.runtime.getValue
2122
import androidx.compose.runtime.rememberCoroutineScope
2223
import androidx.compose.ui.Modifier
@@ -30,6 +31,7 @@ import to.bitkit.ui.theme.Colors
3031

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

34+
@Stable
3335
sealed interface Sheet {
3436
data class Send(val route: SendRoute = SendRoute.Recipient) : Sheet
3537
data object Receive : Sheet
@@ -39,6 +41,7 @@ sealed interface Sheet {
3941
data object ActivityTagSelector : Sheet
4042
data class LnurlAuth(val domain: String, val lnurl: String, val k1: String) : Sheet
4143
data object ForceTransfer : Sheet
44+
data class Gift(val code: String, val amount: ULong) : Sheet
4245

4346
data class TimedSheet(val type: TimedSheetType) : Sheet
4447
}

app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import org.lightningdevkit.ldknode.OutPoint
2323
import to.bitkit.R
2424
import to.bitkit.di.BgDispatcher
2525
import to.bitkit.ext.amountOnClose
26+
import to.bitkit.ext.calculateRemoteBalance
2627
import to.bitkit.ext.createChannelDetails
2728
import to.bitkit.ext.filterOpen
2829
import to.bitkit.ext.filterPending
@@ -113,7 +114,7 @@ class LightningConnectionsViewModel @Inject constructor(
113114
.map { it.mapToUiModel() },
114115
failedOrders = getFailedOrdersAsChannels(blocktankState.paidOrders).map { it.mapToUiModel() },
115116
localBalance = calculateLocalBalance(channels),
116-
remoteBalance = calculateRemoteBalance(channels),
117+
remoteBalance = channels.calculateRemoteBalance(),
117118
)
118119
}.collect { newState ->
119120
_uiState.update { newState }
@@ -329,12 +330,6 @@ class LightningConnectionsViewModel @Inject constructor(
329330
.sumOf { it.amountOnClose }
330331
}
331332

332-
private fun calculateRemoteBalance(channels: List<ChannelDetails>): ULong {
333-
return channels
334-
.filterOpen()
335-
.sumOf { it.inboundCapacityMsat / 1000u }
336-
}
337-
338333
fun zipLogsForSharing(onReady: (Uri) -> Unit) {
339334
viewModelScope.launch {
340335
logsRepo.zipLogsForSharing()
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package to.bitkit.ui.sheets
2+
3+
import androidx.annotation.StringRes
4+
import androidx.compose.foundation.Image
5+
import androidx.compose.foundation.layout.Column
6+
import androidx.compose.foundation.layout.aspectRatio
7+
import androidx.compose.foundation.layout.fillMaxWidth
8+
import androidx.compose.foundation.layout.navigationBarsPadding
9+
import androidx.compose.foundation.layout.padding
10+
import androidx.compose.runtime.Composable
11+
import androidx.compose.ui.Alignment
12+
import androidx.compose.ui.Modifier
13+
import androidx.compose.ui.platform.testTag
14+
import androidx.compose.ui.res.painterResource
15+
import androidx.compose.ui.res.stringResource
16+
import androidx.compose.ui.unit.dp
17+
import to.bitkit.R
18+
import to.bitkit.ui.components.BodyM
19+
import to.bitkit.ui.components.FillHeight
20+
import to.bitkit.ui.components.PrimaryButton
21+
import to.bitkit.ui.components.SheetSize
22+
import to.bitkit.ui.components.VerticalSpacer
23+
import to.bitkit.ui.scaffold.SheetTopBar
24+
import to.bitkit.ui.shared.modifiers.sheetHeight
25+
import to.bitkit.ui.shared.util.gradientBackground
26+
import to.bitkit.ui.theme.Colors
27+
28+
@Composable
29+
fun GiftErrorSheet(
30+
@StringRes titleRes: Int,
31+
@StringRes textRes: Int,
32+
testTag: String,
33+
onDismiss: () -> Unit,
34+
) {
35+
Content(
36+
titleRes = titleRes,
37+
textRes = textRes,
38+
testTag = testTag,
39+
onDismiss = onDismiss,
40+
)
41+
}
42+
43+
@Composable
44+
private fun Content(
45+
@StringRes titleRes: Int,
46+
@StringRes textRes: Int,
47+
testTag: String,
48+
modifier: Modifier = Modifier,
49+
onDismiss: () -> Unit = {},
50+
) {
51+
Column(
52+
modifier = modifier
53+
.sheetHeight(SheetSize.LARGE)
54+
.gradientBackground()
55+
.navigationBarsPadding()
56+
.padding(horizontal = 16.dp)
57+
) {
58+
SheetTopBar(titleText = stringResource(titleRes))
59+
VerticalSpacer(16.dp)
60+
61+
BodyM(
62+
text = stringResource(textRes),
63+
color = Colors.White64,
64+
)
65+
66+
FillHeight()
67+
68+
Image(
69+
painter = painterResource(R.drawable.exclamation_mark),
70+
contentDescription = null,
71+
modifier = Modifier
72+
.fillMaxWidth(IMAGE_WIDTH_FRACTION)
73+
.aspectRatio(1.0f)
74+
.align(Alignment.CenterHorizontally)
75+
)
76+
77+
FillHeight()
78+
79+
PrimaryButton(
80+
text = stringResource(R.string.common__ok),
81+
onClick = onDismiss,
82+
modifier = Modifier
83+
.fillMaxWidth()
84+
.testTag(testTag),
85+
)
86+
VerticalSpacer(16.dp)
87+
}
88+
}

0 commit comments

Comments
 (0)