From 0f9a569ec8c3e8576b7838160f455f6195f55350 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 15 Jan 2026 06:30:27 -0300 Subject: [PATCH 01/12] fix: add debounce validation --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 103 +++++++++++++++++- app/src/main/res/values/strings.xml | 1 + 2 files changed, 98 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 31186901d..808e5b92b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -28,6 +28,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -196,6 +197,7 @@ class AppViewModel @Inject constructor( registerSheet(highBalanceSheet) } private var isCompletingMigration = false + private var addressValidationJob: Job? = null fun setShowForgotPin(value: Boolean) { _showForgotPinSheet.value = value @@ -662,6 +664,7 @@ class AppViewModel @Inject constructor( } private fun resetAddressInput() { + addressValidationJob?.cancel() _sendUiState.update { state -> state.copy( addressInput = "", @@ -672,15 +675,101 @@ class AppViewModel @Inject constructor( private fun onAddressChange(value: String) { val valueWithoutSpaces = value.removeSpaces() - viewModelScope.launch { - val result = runCatching { decode(valueWithoutSpaces) } - _sendUiState.update { - it.copy( - addressInput = valueWithoutSpaces, - isAddressInputValid = result.isSuccess, + + // Update text immediately, reset validity until validation completes + _sendUiState.update { + it.copy( + addressInput = valueWithoutSpaces, + isAddressInputValid = false, + ) + } + + // Cancel pending validation + addressValidationJob?.cancel() + + // Skip validation for empty input + if (valueWithoutSpaces.isEmpty()) return + + // Start debounced validation + addressValidationJob = viewModelScope.launch { + delay(ADDRESS_VALIDATION_DEBOUNCE_MS) + validateAddressWithFeedback(valueWithoutSpaces) + } + } + + private suspend fun validateAddressWithFeedback(input: String) = withContext(bgDispatcher) { + val scanResult = runCatching { decode(input) } + + if (scanResult.isFailure) { + showAddressValidationError( + titleRes = R.string.other__scan_err_decoding, + descriptionRes = R.string.other__scan__error__generic, + ) + return@withContext + } + + when (val decoded = scanResult.getOrNull()) { + is Scanner.Lightning -> validateLightningInvoice(decoded.invoice) + is Scanner.OnChain -> validateOnchainAddress(decoded.invoice) + else -> _sendUiState.update { it.copy(isAddressInputValid = true) } + } + } + + private suspend fun validateLightningInvoice(invoice: LightningInvoice) { + if (invoice.isExpired) { + showAddressValidationError( + titleRes = R.string.other__pay_insufficient_spending, + descriptionRes = R.string.other__pay_insufficient_spending_description, + ) + return + } + + if (invoice.amountSatoshis > 0uL) { + val maxSendLightning = walletRepo.balanceState.value.maxSendLightningSats + if (maxSendLightning == 0uL || !lightningRepo.canSend(invoice.amountSatoshis)) { + showAddressValidationError( + titleRes = R.string.other__pay_insufficient_spending, + descriptionRes = R.string.other__pay_insufficient_spending_description, ) + return } } + + _sendUiState.update { it.copy(isAddressInputValid = true) } + } + + private fun validateOnchainAddress(invoice: OnChainInvoice) { + val maxSendOnchain = walletRepo.balanceState.value.maxSendOnchainSats + + if (maxSendOnchain == 0uL) { + showAddressValidationError( + titleRes = R.string.other__pay_insufficient_savings, + descriptionRes = R.string.other__pay_insufficient_savings_description, + ) + return + } + + if (invoice.amountSatoshis > 0uL && invoice.amountSatoshis > maxSendOnchain) { + showAddressValidationError( + titleRes = R.string.other__pay_insufficient_savings, + descriptionRes = R.string.other__pay_insufficient_savings_description, + ) + return + } + + _sendUiState.update { it.copy(isAddressInputValid = true) } + } + + private fun showAddressValidationError( + @StringRes titleRes: Int, + @StringRes descriptionRes: Int, + ) { + _sendUiState.update { it.copy(isAddressInputValid = false) } + toast( + type = Toast.ToastType.ERROR, + title = context.getString(titleRes), + description = context.getString(descriptionRes), + ) } private fun onAddressContinue(data: String) { @@ -1697,6 +1786,7 @@ class AppViewModel @Inject constructor( } suspend fun resetSendState() { + addressValidationJob?.cancel() val speed = settingsStore.data.first().defaultTransactionSpeed val rates = let { // Refresh blocktank info to get latest fee rates @@ -2022,6 +2112,7 @@ class AppViewModel @Inject constructor( private const val REMOTE_RESTORE_NODE_RESTART_DELAY_MS = 500L private const val AUTH_CHECK_INITIAL_DELAY_MS = 1000L private const val AUTH_CHECK_SPLASH_DELAY_MS = 500L + private const val ADDRESS_VALIDATION_DEBOUNCE_MS = 1000L } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bd0d18aeb..411572c81 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -368,6 +368,7 @@ More ₿ needed to pay this Bitcoin invoice. ₿ {amount} more needed to pay this Bitcoin invoice. ₿ {amount} more needed to pay this Lightning invoice. + More ₿ needed to pay this Lightning invoice. Swipe To Confirm Decoding Error Unable To Interpret Provided Data From 349e1337745f356ecfc088946fcb70b74d00e7eb Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 15 Jan 2026 06:43:23 -0300 Subject: [PATCH 02/12] chore: create network validator --- .../bitkit/utils/NetworkValidationHelper.kt | 50 ++++++ .../utils/NetworkValidationHelperTest.kt | 151 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 app/src/main/java/to/bitkit/utils/NetworkValidationHelper.kt create mode 100644 app/src/test/java/to/bitkit/utils/NetworkValidationHelperTest.kt diff --git a/app/src/main/java/to/bitkit/utils/NetworkValidationHelper.kt b/app/src/main/java/to/bitkit/utils/NetworkValidationHelper.kt new file mode 100644 index 000000000..5eb631705 --- /dev/null +++ b/app/src/main/java/to/bitkit/utils/NetworkValidationHelper.kt @@ -0,0 +1,50 @@ +package to.bitkit.utils + +import org.lightningdevkit.ldknode.Network + +/** + * Helper for validating Bitcoin network compatibility of addresses and invoices + */ +object NetworkValidationHelper { + + /** + * Infer the Bitcoin network from an on-chain address prefix + * @param address The Bitcoin address to check + * @return The detected network, or null if the address format is unrecognized + */ + fun getAddressNetwork(address: String): Network? { + val lowercased = address.lowercase() + + // Bech32/Bech32m addresses (order matters: check bcrt1 before bc1) + return when { + lowercased.startsWith("bcrt1") -> Network.REGTEST + lowercased.startsWith("bc1") -> Network.BITCOIN + lowercased.startsWith("tb1") -> Network.TESTNET + else -> { + // Legacy addresses - check first character + when (address.firstOrNull()) { + '1', '3' -> Network.BITCOIN + 'm', 'n', '2' -> Network.TESTNET // testnet and regtest share these + else -> null + } + } + } + } + + /** + * Check if an address/invoice network mismatches the current app network + * @param addressNetwork The network detected from the address/invoice + * @param currentNetwork The app's current network (typically Env.network) + * @return true if there's a mismatch (address won't work on current network) + */ + fun isNetworkMismatch(addressNetwork: Network?, currentNetwork: Network): Boolean { + if (addressNetwork == null) return false + + // Special case: regtest uses testnet prefixes (m, n, 2, tb1) + if (currentNetwork == Network.REGTEST && addressNetwork == Network.TESTNET) { + return false + } + + return addressNetwork != currentNetwork + } +} diff --git a/app/src/test/java/to/bitkit/utils/NetworkValidationHelperTest.kt b/app/src/test/java/to/bitkit/utils/NetworkValidationHelperTest.kt new file mode 100644 index 000000000..5fb0cc2d2 --- /dev/null +++ b/app/src/test/java/to/bitkit/utils/NetworkValidationHelperTest.kt @@ -0,0 +1,151 @@ +package to.bitkit.utils + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.lightningdevkit.ldknode.Network + +class NetworkValidationHelperTest { + + // MARK: - getAddressNetwork Tests + + // Mainnet addresses + @Test + fun `getAddressNetwork - mainnet bech32`() { + val address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" + assertEquals(Network.BITCOIN, NetworkValidationHelper.getAddressNetwork(address)) + } + + @Test + fun `getAddressNetwork - mainnet bech32 uppercase`() { + val address = "BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4" + assertEquals(Network.BITCOIN, NetworkValidationHelper.getAddressNetwork(address)) + } + + @Test + fun `getAddressNetwork - mainnet P2PKH`() { + val address = "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2" + assertEquals(Network.BITCOIN, NetworkValidationHelper.getAddressNetwork(address)) + } + + @Test + fun `getAddressNetwork - mainnet P2SH`() { + val address = "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy" + assertEquals(Network.BITCOIN, NetworkValidationHelper.getAddressNetwork(address)) + } + + // Testnet addresses + @Test + fun `getAddressNetwork - testnet bech32`() { + val address = "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx" + assertEquals(Network.TESTNET, NetworkValidationHelper.getAddressNetwork(address)) + } + + @Test + fun `getAddressNetwork - testnet P2PKH m prefix`() { + val address = "mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn" + assertEquals(Network.TESTNET, NetworkValidationHelper.getAddressNetwork(address)) + } + + @Test + fun `getAddressNetwork - testnet P2PKH n prefix`() { + val address = "n3ZddxzLvAY9o7184TB4c6FJasAybsw4HZ" + assertEquals(Network.TESTNET, NetworkValidationHelper.getAddressNetwork(address)) + } + + @Test + fun `getAddressNetwork - testnet P2SH`() { + val address = "2MzQwSSnBHWHqSAqtTVQ6v47XtaisrJa1Vc" + assertEquals(Network.TESTNET, NetworkValidationHelper.getAddressNetwork(address)) + } + + // Regtest addresses + @Test + fun `getAddressNetwork - regtest bech32`() { + val address = "bcrt1q6rhpng9evdsfnn833a4f4vej0asu6dk5srld6x" + assertEquals(Network.REGTEST, NetworkValidationHelper.getAddressNetwork(address)) + } + + // Edge cases + @Test + fun `getAddressNetwork - empty string`() { + assertNull(NetworkValidationHelper.getAddressNetwork("")) + } + + @Test + fun `getAddressNetwork - invalid address`() { + assertNull(NetworkValidationHelper.getAddressNetwork("invalid")) + } + + @Test + fun `getAddressNetwork - random text`() { + assertNull(NetworkValidationHelper.getAddressNetwork("test123")) + } + + // MARK: - isNetworkMismatch Tests + + @Test + fun `isNetworkMismatch - same network`() { + assertFalse(NetworkValidationHelper.isNetworkMismatch(Network.BITCOIN, Network.BITCOIN)) + assertFalse(NetworkValidationHelper.isNetworkMismatch(Network.TESTNET, Network.TESTNET)) + assertFalse(NetworkValidationHelper.isNetworkMismatch(Network.REGTEST, Network.REGTEST)) + } + + @Test + fun `isNetworkMismatch - different network`() { + assertTrue(NetworkValidationHelper.isNetworkMismatch(Network.BITCOIN, Network.TESTNET)) + assertTrue(NetworkValidationHelper.isNetworkMismatch(Network.BITCOIN, Network.REGTEST)) + assertTrue(NetworkValidationHelper.isNetworkMismatch(Network.TESTNET, Network.BITCOIN)) + } + + @Test + fun `isNetworkMismatch - regtest accepts testnet prefixes`() { + // Regtest should accept testnet prefixes (m, n, 2, tb1) + assertFalse(NetworkValidationHelper.isNetworkMismatch(Network.TESTNET, Network.REGTEST)) + } + + @Test + fun `isNetworkMismatch - testnet rejects regtest addresses`() { + // Testnet should NOT accept regtest-specific addresses (bcrt1) + assertTrue(NetworkValidationHelper.isNetworkMismatch(Network.REGTEST, Network.TESTNET)) + } + + @Test + fun `isNetworkMismatch - null address network`() { + // When address network is nil (unrecognized format), no mismatch + assertFalse(NetworkValidationHelper.isNetworkMismatch(null, Network.BITCOIN)) + assertFalse(NetworkValidationHelper.isNetworkMismatch(null, Network.REGTEST)) + } + + // MARK: - Integration Tests + + @Test + fun `mainnet address on regtest should mismatch`() { + val address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" + val addressNetwork = NetworkValidationHelper.getAddressNetwork(address) + assertTrue(NetworkValidationHelper.isNetworkMismatch(addressNetwork, Network.REGTEST)) + } + + @Test + fun `testnet address on regtest should not mismatch`() { + val address = "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx" + val addressNetwork = NetworkValidationHelper.getAddressNetwork(address) + assertFalse(NetworkValidationHelper.isNetworkMismatch(addressNetwork, Network.REGTEST)) + } + + @Test + fun `regtest address on mainnet should mismatch`() { + val address = "bcrt1q6rhpng9evdsfnn833a4f4vej0asu6dk5srld6x" + val addressNetwork = NetworkValidationHelper.getAddressNetwork(address) + assertTrue(NetworkValidationHelper.isNetworkMismatch(addressNetwork, Network.BITCOIN)) + } + + @Test + fun `legacy testnet address on regtest should not mismatch`() { + val address = "mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn" // m-prefix testnet + val addressNetwork = NetworkValidationHelper.getAddressNetwork(address) + assertFalse(NetworkValidationHelper.isNetworkMismatch(addressNetwork, Network.REGTEST)) + } +} From 220c1a5f812cd56e5299eac57a4befa6b7aa10d9 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 15 Jan 2026 06:44:30 -0300 Subject: [PATCH 03/12] fix: implement network validation --- .../main/java/to/bitkit/viewmodels/AppViewModel.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 808e5b92b..0f2556008 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -108,6 +108,7 @@ import to.bitkit.ui.shared.toast.ToastQueueManager import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.theme.TRANSITION_SCREEN_MS import to.bitkit.utils.Logger +import to.bitkit.utils.NetworkValidationHelper import to.bitkit.utils.jsonLogOf import to.bitkit.utils.timedsheets.TimedSheetManager import to.bitkit.utils.timedsheets.sheets.AppUpdateTimedSheet @@ -739,6 +740,16 @@ class AppViewModel @Inject constructor( } private fun validateOnchainAddress(invoice: OnChainInvoice) { + // Check network mismatch + val addressNetwork = NetworkValidationHelper.getAddressNetwork(invoice.address) + if (NetworkValidationHelper.isNetworkMismatch(addressNetwork, Env.network)) { + showAddressValidationError( + titleRes = R.string.other__scan_err_decoding, + descriptionRes = R.string.other__scan__error__generic, + ) + return + } + val maxSendOnchain = walletRepo.balanceState.value.maxSendOnchainSats if (maxSendOnchain == 0uL) { From 69f6b4097434e6eb5a7cbda89e998b6e512e1a29 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 15 Jan 2026 07:17:08 -0300 Subject: [PATCH 04/12] fix: error message --- app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 6 +++--- app/src/main/res/values/strings.xml | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 0f2556008..29962bcf7 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -719,8 +719,8 @@ class AppViewModel @Inject constructor( private suspend fun validateLightningInvoice(invoice: LightningInvoice) { if (invoice.isExpired) { showAddressValidationError( - titleRes = R.string.other__pay_insufficient_spending, - descriptionRes = R.string.other__pay_insufficient_spending_description, + titleRes = R.string.other__scan_err_decoding, + descriptionRes = R.string.other__scan__error__expired, ) return } @@ -730,7 +730,7 @@ class AppViewModel @Inject constructor( if (maxSendLightning == 0uL || !lightningRepo.canSend(invoice.amountSatoshis)) { showAddressValidationError( titleRes = R.string.other__pay_insufficient_spending, - descriptionRes = R.string.other__pay_insufficient_spending_description, + descriptionRes = R.string.other__pay_insufficient_savings_description, ) return } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 411572c81..bd0d18aeb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -368,7 +368,6 @@ More ₿ needed to pay this Bitcoin invoice. ₿ {amount} more needed to pay this Bitcoin invoice. ₿ {amount} more needed to pay this Lightning invoice. - More ₿ needed to pay this Lightning invoice. Swipe To Confirm Decoding Error Unable To Interpret Provided Data From 2e52a24abd606333b60f0ddce36bac33a4ff0f91 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 15 Jan 2026 07:35:12 -0300 Subject: [PATCH 05/12] fix: remove expired error on unified invoice --- app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 29962bcf7..b367a32a0 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -988,12 +988,6 @@ class AppViewModel @Inject constructor( ?.invoice ?.takeIf { invoice -> if (invoice.isExpired) { - toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.other__scan_err_decoding), - description = context.getString(R.string.other__scan__error__expired), - ) - Logger.debug( "Lightning invoice expired in unified URI, defaulting to onchain-only", context = TAG From d5cf3d1b61adfc5825ac50555aa20c17ef59d044 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 15 Jan 2026 08:08:18 -0300 Subject: [PATCH 06/12] fix: network check on address pasting --- .../main/java/to/bitkit/viewmodels/AppViewModel.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index b367a32a0..6e26a1004 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -982,6 +982,17 @@ class AppViewModel @Inject constructor( } private suspend fun onScanOnchain(invoice: OnChainInvoice, scanResult: String) { + // Check network mismatch + val addressNetwork = NetworkValidationHelper.getAddressNetwork(invoice.address) + if (NetworkValidationHelper.isNetworkMismatch(addressNetwork, Env.network)) { + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.other__scan_err_decoding), + description = context.getString(R.string.other__scan__error__generic), + ) + return + } + val lnInvoice: LightningInvoice? = invoice.params?.get("lightning")?.let { bolt11 -> runCatching { decode(bolt11) }.getOrNull() ?.let { it as? Scanner.Lightning } From 81967bfed1172825795b73212b13dba88f8868a2 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 16 Jan 2026 09:50:51 -0300 Subject: [PATCH 07/12] chore: insufficient spending description --- app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- app/src/main/res/values/strings.xml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 6782136b4..5c2f85505 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -731,7 +731,7 @@ class AppViewModel @Inject constructor( if (maxSendLightning == 0uL || !lightningRepo.canSend(invoice.amountSatoshis)) { showAddressValidationError( titleRes = R.string.other__pay_insufficient_spending, - descriptionRes = R.string.other__pay_insufficient_savings_description, + descriptionRes = R.string.other__pay_insufficient_spending_description, ) return } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 54f5d0b3c..5a233c76a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -465,6 +465,7 @@ Insufficient Savings ₿ {amount} more needed to pay this Bitcoin invoice. More ₿ needed to pay this Bitcoin invoice. + More ₿ needed to pay this Lighting invoice. Insufficient Spending Balance ₿ {amount} more needed to pay this Lightning invoice. Open Phone Settings From e64ab5748dfce383a090c6d3c0b40aab2d2b097c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 16 Jan 2026 11:19:55 -0300 Subject: [PATCH 08/12] fix: display amount on errors --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 5c2f85505..7bb8223e8 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -729,9 +729,11 @@ class AppViewModel @Inject constructor( if (invoice.amountSatoshis > 0uL) { val maxSendLightning = walletRepo.balanceState.value.maxSendLightningSats if (maxSendLightning == 0uL || !lightningRepo.canSend(invoice.amountSatoshis)) { + val shortfall = invoice.amountSatoshis - maxSendLightning showAddressValidationError( titleRes = R.string.other__pay_insufficient_spending, - descriptionRes = R.string.other__pay_insufficient_spending_description, + descriptionRes = R.string.other__pay_insufficient_spending_amount_description, + descriptionArgs = mapOf("amount" to shortfall.toString()), ) return } @@ -762,9 +764,11 @@ class AppViewModel @Inject constructor( } if (invoice.amountSatoshis > 0uL && invoice.amountSatoshis > maxSendOnchain) { + val shortfall = invoice.amountSatoshis - maxSendOnchain showAddressValidationError( titleRes = R.string.other__pay_insufficient_savings, - descriptionRes = R.string.other__pay_insufficient_savings_description, + descriptionRes = R.string.other__pay_insufficient_savings_amount_description, + descriptionArgs = mapOf("amount" to shortfall.toString()), ) return } @@ -775,12 +779,17 @@ class AppViewModel @Inject constructor( private fun showAddressValidationError( @StringRes titleRes: Int, @StringRes descriptionRes: Int, + descriptionArgs: Map = emptyMap(), ) { _sendUiState.update { it.copy(isAddressInputValid = false) } + var description = context.getString(descriptionRes) + descriptionArgs.forEach { (key, value) -> + description = description.replace("{$key}", value) + } toast( type = Toast.ToastType.ERROR, title = context.getString(titleRes), - description = context.getString(descriptionRes), + description = description, ) } @@ -982,7 +991,7 @@ class AppViewModel @Inject constructor( } } - @Suppress("LongMethod") + @Suppress("LongMethod", "CyclomaticComplexMethod") private suspend fun onScanOnchain(invoice: OnChainInvoice, scanResult: String) { // Check network mismatch val addressNetwork = NetworkValidationHelper.getAddressNetwork(invoice.address) @@ -1047,6 +1056,17 @@ class AppViewModel @Inject constructor( return } + // Check on-chain balance before proceeding to amount screen + val maxSendOnchain = walletRepo.balanceState.value.maxSendOnchainSats + if (maxSendOnchain == 0uL) { + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.other__pay_insufficient_savings), + description = context.getString(R.string.other__pay_insufficient_savings_description), + ) + return + } + Logger.info( when (invoice.amountSatoshis > 0u) { true -> "Found amount in invoice, proceeding to edit amount" @@ -1076,10 +1096,13 @@ class AppViewModel @Inject constructor( if (quickPayHandled) return if (!lightningRepo.canSend(invoice.amountSatoshis)) { + val maxSendLightning = walletRepo.balanceState.value.maxSendLightningSats + val shortfall = invoice.amountSatoshis - maxSendLightning toast( type = Toast.ToastType.ERROR, - title = context.getString(R.string.wallet__error_insufficient_funds_title), - description = context.getString(R.string.wallet__error_insufficient_funds_msg) + title = context.getString(R.string.other__pay_insufficient_spending), + description = context.getString(R.string.other__pay_insufficient_spending_amount_description) + .replace("{amount}", shortfall.toString()), ) return } From e91a052ddc7b69972919f5965becc7d77d1eee2e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 16 Jan 2026 13:25:47 -0300 Subject: [PATCH 09/12] fix: check for on-chain sufficient amount --- .../main/java/to/bitkit/viewmodels/AppViewModel.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index ef97e017a..0100f38ee 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1068,6 +1068,18 @@ class AppViewModel @Inject constructor( return } + // Check if on-chain invoice amount exceeds available balance + if (invoice.amountSatoshis > 0uL && invoice.amountSatoshis > maxSendOnchain) { + val shortfall = invoice.amountSatoshis - maxSendOnchain + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.other__pay_insufficient_savings), + description = context.getString(R.string.other__pay_insufficient_savings_amount_description) + .replace("{amount}", shortfall.toString()), + ) + return + } + Logger.info( when (invoice.amountSatoshis > 0u) { true -> "Found amount in invoice, proceeding to edit amount" From 44618b1e61b366d4de17b56f4938c1a9dce4d16f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor=20Sena?= Date: Fri, 16 Jan 2026 13:39:57 -0300 Subject: [PATCH 10/12] fix: typo Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5a233c76a..4eec55d59 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -465,7 +465,7 @@ Insufficient Savings ₿ {amount} more needed to pay this Bitcoin invoice. More ₿ needed to pay this Bitcoin invoice. - More ₿ needed to pay this Lighting invoice. + More ₿ needed to pay this Lightning invoice. Insufficient Spending Balance ₿ {amount} more needed to pay this Lightning invoice. Open Phone Settings From eb6e25f892ac74dda8c814a20a125640f6a444cd Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 16 Jan 2026 13:41:59 -0300 Subject: [PATCH 11/12] chore: case --- app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 0100f38ee..1ea4303da 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -713,7 +713,7 @@ class AppViewModel @Inject constructor( when (val decoded = scanResult.getOrNull()) { is Scanner.Lightning -> validateLightningInvoice(decoded.invoice) - is Scanner.OnChain -> validateOnchainAddress(decoded.invoice) + is Scanner.OnChain -> validateOnChainAddress(decoded.invoice) else -> _sendUiState.update { it.copy(isAddressInputValid = true) } } } @@ -743,7 +743,7 @@ class AppViewModel @Inject constructor( _sendUiState.update { it.copy(isAddressInputValid = true) } } - private fun validateOnchainAddress(invoice: OnChainInvoice) { + private fun validateOnChainAddress(invoice: OnChainInvoice) { // Check network mismatch val addressNetwork = NetworkValidationHelper.getAddressNetwork(invoice.address) if (NetworkValidationHelper.isNetworkMismatch(addressNetwork, Env.network)) { From 84a12419924a387be497dc2c36ded0c318bbc4a9 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 16 Jan 2026 22:57:53 +0100 Subject: [PATCH 12/12] add toast ids --- .../main/java/to/bitkit/viewmodels/AppViewModel.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 1ea4303da..222b1e219 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -707,6 +707,7 @@ class AppViewModel @Inject constructor( showAddressValidationError( titleRes = R.string.other__scan_err_decoding, descriptionRes = R.string.other__scan__error__generic, + testTag = "InvalidAddressToast", ) return@withContext } @@ -723,6 +724,7 @@ class AppViewModel @Inject constructor( showAddressValidationError( titleRes = R.string.other__scan_err_decoding, descriptionRes = R.string.other__scan__error__expired, + testTag = "ExpiredLightningToast", ) return } @@ -735,6 +737,7 @@ class AppViewModel @Inject constructor( titleRes = R.string.other__pay_insufficient_spending, descriptionRes = R.string.other__pay_insufficient_spending_amount_description, descriptionArgs = mapOf("amount" to shortfall.toString()), + testTag = "InsufficientSpendingToast", ) return } @@ -750,6 +753,7 @@ class AppViewModel @Inject constructor( showAddressValidationError( titleRes = R.string.other__scan_err_decoding, descriptionRes = R.string.other__scan__error__generic, + testTag = "InvalidAddressToast", ) return } @@ -760,6 +764,7 @@ class AppViewModel @Inject constructor( showAddressValidationError( titleRes = R.string.other__pay_insufficient_savings, descriptionRes = R.string.other__pay_insufficient_savings_description, + testTag = "InsufficientSavingsToast", ) return } @@ -770,6 +775,7 @@ class AppViewModel @Inject constructor( titleRes = R.string.other__pay_insufficient_savings, descriptionRes = R.string.other__pay_insufficient_savings_amount_description, descriptionArgs = mapOf("amount" to shortfall.toString()), + testTag = "InsufficientSavingsToast", ) return } @@ -781,6 +787,7 @@ class AppViewModel @Inject constructor( @StringRes titleRes: Int, @StringRes descriptionRes: Int, descriptionArgs: Map = emptyMap(), + testTag: String? = null, ) { _sendUiState.update { it.copy(isAddressInputValid = false) } var description = context.getString(descriptionRes) @@ -791,6 +798,7 @@ class AppViewModel @Inject constructor( type = Toast.ToastType.ERROR, title = context.getString(titleRes), description = description, + testTag = testTag, ) } @@ -1001,6 +1009,7 @@ class AppViewModel @Inject constructor( type = Toast.ToastType.ERROR, title = context.getString(R.string.other__scan_err_decoding), description = context.getString(R.string.other__scan__error__generic), + testTag = "InvalidAddressToast", ) return } @@ -1064,6 +1073,7 @@ class AppViewModel @Inject constructor( type = Toast.ToastType.ERROR, title = context.getString(R.string.other__pay_insufficient_savings), description = context.getString(R.string.other__pay_insufficient_savings_description), + testTag = "InsufficientSavingsToast", ) return } @@ -1076,6 +1086,7 @@ class AppViewModel @Inject constructor( title = context.getString(R.string.other__pay_insufficient_savings), description = context.getString(R.string.other__pay_insufficient_savings_amount_description) .replace("{amount}", shortfall.toString()), + testTag = "InsufficientSavingsToast", ) return } @@ -1101,6 +1112,7 @@ class AppViewModel @Inject constructor( type = Toast.ToastType.ERROR, title = context.getString(R.string.other__scan_err_decoding), description = context.getString(R.string.other__scan__error__expired), + testTag = "ExpiredLightningToast", ) return } @@ -1116,6 +1128,7 @@ class AppViewModel @Inject constructor( title = context.getString(R.string.other__pay_insufficient_spending), description = context.getString(R.string.other__pay_insufficient_spending_amount_description) .replace("{amount}", shortfall.toString()), + testTag = "InsufficientSpendingToast", ) return }