Skip to content
63 changes: 56 additions & 7 deletions app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,17 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
Expand All @@ -37,7 +41,10 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.lightningdevkit.ldknode.Bolt11Invoice
import org.lightningdevkit.ldknode.ChannelDetails
import org.lightningdevkit.ldknode.Event
import to.bitkit.async.ServiceQueue
import to.bitkit.data.CacheStore
import to.bitkit.di.BgDispatcher
Expand All @@ -47,6 +54,7 @@ import to.bitkit.ext.calculateRemoteBalance
import to.bitkit.ext.nowTimestamp
import to.bitkit.models.BlocktankBackupV1
import to.bitkit.models.EUR
import to.bitkit.models.msatCeilOf
import to.bitkit.services.CoreService
import to.bitkit.services.LightningService
import to.bitkit.utils.Logger
Expand Down Expand Up @@ -459,26 +467,61 @@ class BlocktankRepo @Inject constructor(
}
}

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

val expectedPaymentHash = Bolt11Invoice.fromStr(invoice).paymentHash()

Logger.debug("Created invoice for gift code, requesting payment from LSP", context = TAG)

val paymentReceivedDeferred = async(start = CoroutineStart.UNDISPATCHED) {
lightningRepo.nodeEvents
.filterIsInstance<Event.PaymentReceived>()
.first { it.paymentHash == expectedPaymentHash }
}

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

Logger.debug("Gift payment request completed: id=${giftResponse.id}", context = TAG)
Logger.debug(
"Gift payment request completed: id='${giftResponse.id}', awaiting LDK PaymentReceived",
context = TAG,
)

if (Env.isDebug && GIFT_QA_PRE_RECEIVE_DELAY > Duration.ZERO) {
Logger.debug(
"QA window open: sleeping '$GIFT_QA_PRE_RECEIVE_DELAY' before awaiting LDK PaymentReceived " +
"(disable wifi now to simulate routing failure)",
context = TAG,
)
delay(GIFT_QA_PRE_RECEIVE_DELAY)
}

return GiftClaimResult.SuccessWithLiquidity(
paymentHashOrTxId = giftResponse.bolt11PaymentId ?: giftResponse.id,
sats = giftResponse.bolt11Payment?.paidSat?.toLong()
?: giftResponse.appliedGiftCode?.giftSat?.toLong()
?: amount.toLong(),
val paymentReceived = withTimeoutOrNull(GIFT_PAYMENT_RECEIVE_TIMEOUT) {
paymentReceivedDeferred.await()
}

if (paymentReceived == null) {
paymentReceivedDeferred.cancel()
throw ServiceError.GiftClaimPaymentNotReceived()
}

Logger.debug(
"Gift payment confirmed by LDK: hash='${paymentReceived.paymentHash}', " +
"amountMsat='${paymentReceived.amountMsat}'",
context = TAG,
)

val receivedSats = msatCeilOf(paymentReceived.amountMsat).toLong()

GiftClaimResult.SuccessWithLiquidity(
paymentHashOrTxId = paymentReceived.paymentHash,
sats = receivedSats.takeIf { it > 0 } ?: amount.toLong(),
invoice = invoice,
code = code,
)
Expand Down Expand Up @@ -518,6 +561,12 @@ class BlocktankRepo @Inject constructor(
private const val DEFAULT_SOURCE = "bitkit-android"
private const val PEER_CONNECTION_DELAY_MS = 2_000L
private val TIMEOUT_GIFT_CODE = 30.seconds
private val GIFT_PAYMENT_RECEIVE_TIMEOUT = 45.seconds

// QA aid: in debug builds only, pause after `giftPay` returns and before awaiting
// the LDK PaymentReceived event, so a tester can disable wifi/peer to simulate a
// routing failure. Set to Duration.ZERO to disable.
private val GIFT_QA_PRE_RECEIVE_DELAY: Duration = 15.seconds
}
}

Expand Down
6 changes: 5 additions & 1 deletion app/src/main/java/to/bitkit/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,11 @@ class MainActivity : FragmentActivity() {
desc = getString(R.string.notification__channel_node__body),
importance = NotificationManager.IMPORTANCE_LOW
)
appViewModel.handleDeeplinkIntent(intent)
// Skip on Activity recreation (e.g. locale change) — Android re-delivers the
// launching intent and would otherwise re-trigger deeplink flows like the gift sheet.
if (savedInstanceState == null) {
appViewModel.handleDeeplinkIntent(intent)
Comment thread
jvsena42 marked this conversation as resolved.
Outdated
}

installSplashScreen()
enableAppEdgeToEdge()
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/utils/Errors.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ sealed class ServiceError(message: String) : AppError(message) {
class CurrencyRateUnavailable : ServiceError("Currency rate unavailable")
class BlocktankInfoUnavailable : ServiceError("Blocktank info not available")
class GeoBlocked : ServiceError("Geo blocked user")
class GiftClaimPaymentNotReceived : ServiceError("Gift claim payment not received")
}

class HttpError(message: String, val code: Int = 500, cause: Throwable? = null) : AppError(message, cause)
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/929.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix gift card flow showing false-positive confetti when the LSP payment fails, and re-opening unexpectedly after an app language change.
Loading