Skip to content
Open
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
26 changes: 25 additions & 1 deletion app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ class LightningRepo @Inject constructor(
@Volatile
private var isWiping = false

@Volatile
private var lastKnownNodeId: String? = null

private val channelCache = ConcurrentHashMap<String, ChannelDetails>()
private val probeOutcomeCache = ConcurrentHashMap<PaymentId, ProbeOutcome>()
private val probeOutcomeSignal = MutableSharedFlow<ProbeOutcome>(extraBufferCapacity = 64)
Expand Down Expand Up @@ -880,6 +883,7 @@ class LightningRepo @Inject constructor(
lifecycleMutex.withLock {
stopLocked().mapCatching {
Logger.debug("node stopped, calling wipeStorage", context = TAG)
lastKnownNodeId = null
lightningService.wipeStorage(walletIndex)
clearProbeOutcomes()
_lightningState.update {
Expand Down Expand Up @@ -1728,7 +1732,23 @@ class LightningRepo @Inject constructor(
}

fun getNodeId(): String? =
if (_lightningState.value.nodeLifecycleState.isRunning()) lightningService.nodeId else null
if (_lightningState.value.nodeLifecycleState.isRunning()) {
lightningService.nodeId?.also { lastKnownNodeId = it }
} else {
null
}

/**
* Node id of the current node, falling back to the one observed while it last ran.
* The id is derived from the wallet mnemonic and only cleared on storage wipe, which is the
* single path to another mnemonic, so it always belongs to the active wallet.
*/
fun getLastKnownNodeId(): String? = getNodeId() ?: lastKnownNodeId

suspend fun awaitNodeId(): String? = lastKnownNodeId
?: executeWhenNodeRunning("awaitNodeId", NODE_ID_WAIT_TIMEOUT) {
runCatching { requireNotNull(lightningService.nodeId) { "Node id not available" } }
}.getOrNull()?.also { lastKnownNodeId = it }

fun getBalances(): BalanceDetails? =
if (_lightningState.value.nodeLifecycleState.isRunning()) lightningService.balances else null
Expand Down Expand Up @@ -2157,6 +2177,10 @@ class LightningRepo @Inject constructor(
private val BACKGROUND_STOP_DELAY = 5.seconds
private val CHANNELS_USABLE_TIMEOUT = 15.seconds
private val NO_USABLE_CHANNELS_FEEDBACK_DELAY = 2_500.milliseconds

/** Max time to wait for a starting node before its id is treated as unavailable. */
private val NODE_ID_WAIT_TIMEOUT = 15.seconds

val SEND_LN_TIMEOUT = 10.seconds
private val PROBE_TIMEOUT = 60.seconds
private val PAYMENT_ROUTING_REFRESH_TIMEOUT = 20.seconds
Expand Down
116 changes: 90 additions & 26 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2064,6 +2064,15 @@ class AppViewModel @Inject constructor(
return
}

if (invoice.isOwnInvoice()) {
showAddressValidationError(
titleRes = R.string.other__pay_self_invoice_title,
descriptionRes = R.string.other__pay_self_invoice_description,
testTag = "SelfPaymentToast",
)
return
}

if (invoice.amountSatoshis > 0uL) {
lightningRepo.syncState()
if (!lightningRepo.canSend(invoice.amountSatoshis)) {
Expand All @@ -2082,6 +2091,7 @@ class AppViewModel @Inject constructor(
_sendUiState.update { it.copy(isAddressInputValid = true) }
}

@Suppress("LongMethod", "ReturnCount")
private suspend fun validateOnChainAddress(invoice: OnChainInvoice) {
val validatedAddress = runCatching { coreService.validateBitcoinAddress(invoice.address) }
.getOrElse {
Expand Down Expand Up @@ -2124,6 +2134,14 @@ class AppViewModel @Inject constructor(
val maxSendOnchain = maximumAvailableOnchainSats(selectedMaxSendOnchain, hardwareWalletId)

if (maxSendOnchain == 0uL) {
if (hardwareWalletId == null && hasOwnLightningInvoice(invoice.params)) {
showAddressValidationError(
titleRes = R.string.other__pay_self_invoice_title,
descriptionRes = R.string.other__pay_self_invoice_description,
testTag = "SelfPaymentToast",
)
return
}
showAddressValidationError(
titleRes = R.string.other__pay_insufficient_savings,
descriptionRes = R.string.other__pay_insufficient_savings_description,
Expand Down Expand Up @@ -2155,36 +2173,56 @@ class AppViewModel @Inject constructor(
}

private suspend fun extractViableLightningInvoice(params: Map<String, String>?): LightningInvoice? =
params?.get("lightning")?.let { bolt11 ->
runSuspendCatching { coreService.decode(bolt11) }.getOrNull()
?.let { it as? Scanner.Lightning }
?.invoice
?.takeIf { lnInv ->
if (lnInv.isExpired) {
Logger.debug(
"Lightning invoice expired in unified URI, defaulting to onchain-only",
context = TAG
)
return@takeIf false
}
lightningRepo.waitForUsableChannels()
val canSend = lightningRepo.canSend(lnInv.amountSatoshis.coerceAtLeast(1u))
if (!canSend) {
val nodeState = lightningRepo.lightningState.value.nodeLifecycleState
if (nodeState is NodeLifecycleState.Stopped) {
Logger.debug(
"Node stopped, optimistically including LN invoice in unified QR",
context = TAG,
)
return@takeIf true
}
decodeLightningParam(params)
?.takeIf { lnInv ->
if (lnInv.isExpired) {
Logger.debug(
"Lightning invoice expired in unified URI, defaulting to onchain-only",
context = TAG
)
return@takeIf false
}
if (lnInv.isOwnInvoice()) {
Logger.debug(
"Skipped own lightning invoice in unified URI, defaulting to onchain",
context = TAG,
)
return@takeIf false
}
lightningRepo.waitForUsableChannels()
val canSend = lightningRepo.canSend(lnInv.amountSatoshis.coerceAtLeast(1u))
if (!canSend) {
val nodeState = lightningRepo.lightningState.value.nodeLifecycleState
if (nodeState is NodeLifecycleState.Stopped) {
Logger.debug(
"Cannot pay unified invoice using LN, defaulting to onchain-only",
"Node stopped, optimistically including LN invoice in unified QR",
context = TAG,
)
return@takeIf true
}
return@takeIf canSend
Logger.debug(
"Cannot pay unified invoice using LN, defaulting to onchain-only",
context = TAG,
)
}
return@takeIf canSend
}

private suspend fun LightningInvoice.isOwnInvoice(): Boolean = isPayee(lightningRepo.awaitNodeId())

private fun LightningInvoice.isPayee(nodeId: String?): Boolean {
val payee = payeeNodeId?.toHex() ?: return false
return nodeId != null && payee.equals(nodeId, ignoreCase = true)
}

private suspend fun hasOwnLightningInvoice(params: Map<String, String>?): Boolean =
decodeLightningParam(params)?.isPayee(lightningRepo.getLastKnownNodeId()) == true

private suspend fun decodeLightningParam(params: Map<String, String>?): LightningInvoice? =
params?.get("lightning")?.let { bolt11 ->
runSuspendCatching { coreService.decode(bolt11) }.getOrNull()
?.let { it as? Scanner.Lightning }
?.invoice
}

private fun showAddressValidationError(
Expand Down Expand Up @@ -3295,6 +3333,16 @@ class AppViewModel @Inject constructor(

// Check on-chain balance before proceeding to amount screen
if (maxSendOnchain == 0uL && _sendUiState.value.payMethod == SendMethod.ONCHAIN) {
if (hardwareWalletId == null && hasOwnLightningInvoice(invoice.params)) {
toast(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.other__pay_self_invoice_title),
description = context.getString(R.string.other__pay_self_invoice_description),
testTag = "SelfPaymentToast",
)
clearActiveContactPaymentContext()
return
}
toast(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.other__pay_insufficient_savings),
Expand Down Expand Up @@ -3393,7 +3441,7 @@ class AppViewModel @Inject constructor(
else -> SendFundingSource.Savings
}

@Suppress("ReturnCount")
@Suppress("LongMethod", "ReturnCount")
private suspend fun onScanLightning(
invoice: LightningInvoice,
scanResult: String,
Expand All @@ -3410,6 +3458,11 @@ class AppViewModel @Inject constructor(
return
}

if (invoice.isOwnInvoice()) {
rejectOwnInvoiceScan()
return
}

val incomingPaymentRequest = activeIncomingPaymentRequest()
if (incomingPaymentRequest?.acceptsLightningInvoiceAmountSats(invoice.amountSatoshis) == false) {
rejectMismatchedPaymentRequest()
Expand Down Expand Up @@ -3462,6 +3515,17 @@ class AppViewModel @Inject constructor(
navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount)
}

private fun rejectOwnInvoiceScan() {
toast(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.other__pay_self_invoice_title),
description = context.getString(R.string.other__pay_self_invoice_description),
testTag = "SelfPaymentToast",
)
clearActiveContactPaymentContext(retryIncomingRequest = false)
hideSheet()
}

private suspend fun onScanLnurlPay(data: LnurlPayData, fromMainScanner: Boolean) {
Logger.debug("LNURL: $data", context = TAG)

Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,8 @@
<string name="other__pay_insufficient_savings_description">More ₿ needed to pay this Bitcoin invoice.</string>
<string name="other__pay_insufficient_spending">Insufficient Spending Balance</string>
<string name="other__pay_insufficient_spending_amount_description">₿ {amount} more needed to pay this Lightning invoice.</string>
<string name="other__pay_self_invoice_description">This invoice was created by your own wallet. Share it with someone else to receive a payment.</string>
<string name="other__pay_self_invoice_title">Cannot Pay Own Invoice</string>
<string name="other__qr_error_header">Unable To Read QR</string>
<string name="other__qr_error_network_header">Incorrect Network</string>
<string name="other__qr_error_network_text">Bitkit is currently set to {selectedNetwork} but data is for {dataNetwork}.</string>
Expand Down
92 changes: 92 additions & 0 deletions app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1049,6 +1049,98 @@ class LightningRepoTest : BaseUnitTest() {
assertEquals(testNodeId, sut.getNodeId())
}

@Test
fun `awaitNodeId should return null without waiting when node cannot run`() = test {
whenever(lightningService.nodeId).thenReturn("test_node_id")

assertNull(sut.awaitNodeId())
}

@Test
fun `awaitNodeId should wait for starting node to run`() = test {
sut.setInitNodeLifecycleState()
val testNodeId = "test_node_id"
whenever(lightningService.nodeId).thenReturn(testNodeId)

val nodeId = async { sut.awaitNodeId() }
runCurrent()
assertFalse(nodeId.isCompleted)

startNodeForTesting()

assertEquals(testNodeId, nodeId.await())
}

@Test
fun `awaitNodeId should return null when node does not start in time`() = test {
sut.setInitNodeLifecycleState()
whenever(lightningService.nodeId).thenReturn("test_node_id")

assertNull(sut.awaitNodeId())
}

@Test
fun `awaitNodeId should return last known id after node stops`() = test {
val testNodeId = "test_node_id"
whenever(lightningService.nodeId).thenReturn(testNodeId)
startNodeForTesting()
assertEquals(testNodeId, sut.awaitNodeId())

whenever(lightningService.stop()).thenReturn(Unit)
sut.stop()
whenever(lightningService.nodeId).thenReturn(null)

assertEquals(testNodeId, sut.awaitNodeId())
}

@Test
fun `getLastKnownNodeId should return remembered id while node is not running`() = test {
val testNodeId = "test_node_id"
whenever(lightningService.nodeId).thenReturn(testNodeId)
startNodeForTesting()
assertEquals(testNodeId, sut.getNodeId())

whenever(lightningService.stop()).thenReturn(Unit)
sut.stop()

assertNull(sut.getNodeId())
assertEquals(testNodeId, sut.getLastKnownNodeId())
}

@Test
fun `getLastKnownNodeId should return null after storage wipe`() = test {
whenever(lightningService.nodeId).thenReturn("test_node_id")
startNodeForTesting()
assertEquals("test_node_id", sut.getNodeId())
whenever(lightningService.stop()).thenReturn(Unit)

assertTrue(sut.wipeStorage(0).isSuccess)
whenever(lightningService.nodeId).thenReturn(null)

assertNull(sut.getLastKnownNodeId())
}

@Test
fun `awaitNodeId should return last known id when restart after stop fails`() = test {
val testNodeId = "test_node_id"
whenever(lightningService.nodeId).thenReturn(testNodeId)
startNodeForTesting()
assertEquals(testNodeId, sut.awaitNodeId())

whenever(lightningService.stop()).thenReturn(Unit)
sut.stop()
whenever(lightningService.nodeId).thenReturn(null)
whenever(lightningService.node).thenReturn(null)
whenever(lightningService.setup(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()))
.thenThrow(IllegalStateException("electrum server unreachable"))

assertTrue(sut.start(shouldRetry = false).isFailure)
assertIs<NodeLifecycleState.ErrorStarting>(sut.lightningState.value.nodeLifecycleState)

assertEquals(testNodeId, sut.awaitNodeId())
assertEquals(testNodeId, sut.getLastKnownNodeId())
}

@Test
fun `getBalances should return null when node is not running`() = test {
assertNull(sut.getBalances())
Expand Down
Loading
Loading