diff --git a/app/src/main/java/to/bitkit/models/Network.kt b/app/src/main/java/to/bitkit/models/Network.kt index c65e1bce8..cfb332bdc 100644 --- a/app/src/main/java/to/bitkit/models/Network.kt +++ b/app/src/main/java/to/bitkit/models/Network.kt @@ -24,3 +24,10 @@ fun Network.toCoreNetworkType(): NetworkType = when (this) { Network.SIGNET -> NetworkType.SIGNET Network.REGTEST -> NetworkType.REGTEST } + +fun NetworkType.toLdkNetwork(): Network = when (this) { + NetworkType.BITCOIN -> Network.BITCOIN + NetworkType.TESTNET -> Network.TESTNET + NetworkType.SIGNET -> Network.SIGNET + NetworkType.REGTEST -> Network.REGTEST +} diff --git a/app/src/main/java/to/bitkit/utils/Bip21Utils.kt b/app/src/main/java/to/bitkit/utils/Bip21Utils.kt index bcb4efb69..25da2a293 100644 --- a/app/src/main/java/to/bitkit/utils/Bip21Utils.kt +++ b/app/src/main/java/to/bitkit/utils/Bip21Utils.kt @@ -4,6 +4,22 @@ import to.bitkit.models.SATS_IN_BTC object Bip21Utils { + private const val BIP21_PREFIX = "bitcoin:" + + /** + * Checks if a BIP21 URI is duplicated (contains multiple bitcoin: prefixes). + * Workaround for https://github.com/synonymdev/bitkit-core/issues/63 + * @return true if the input contains duplicated BIP21 URIs, false otherwise + */ + fun isDuplicatedBip21(input: String): Boolean { + val lowercased = input.lowercase() + val firstIndex = lowercased.indexOf(BIP21_PREFIX) + if (firstIndex == -1) return false + + val secondIndex = lowercased.indexOf(BIP21_PREFIX, firstIndex + BIP21_PREFIX.length) + return secondIndex != -1 + } + fun buildBip21Url( bitcoinAddress: String, amountSats: ULong? = null, 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..236aed5d5 --- /dev/null +++ b/app/src/main/java/to/bitkit/utils/NetworkValidationHelper.kt @@ -0,0 +1,25 @@ +package to.bitkit.utils + +import org.lightningdevkit.ldknode.Network + +/** + * Helper for validating Bitcoin network compatibility of addresses and invoices + */ +object NetworkValidationHelper { + /** + * 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/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 94c9a8cb2..c9be82b68 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 @@ -85,6 +86,7 @@ import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed import to.bitkit.models.safe import to.bitkit.models.toActivityFilter +import to.bitkit.models.toLdkNetwork import to.bitkit.models.toTxType import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BackupRepo @@ -106,7 +108,9 @@ import to.bitkit.ui.shared.toast.ToastEventBus 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.Bip21Utils 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 @@ -196,6 +200,7 @@ class AppViewModel @Inject constructor( registerSheet(highBalanceSheet) } private var isCompletingMigration = false + private var addressValidationJob: Job? = null fun setShowForgotPin(value: Boolean) { _showForgotPinSheet.value = value @@ -664,6 +669,7 @@ class AppViewModel @Inject constructor( } private fun resetAddressInput() { + addressValidationJob?.cancel() _sendUiState.update { state -> state.copy( addressInput = "", @@ -674,15 +680,146 @@ 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) { + // TODO Workaround for https://github.com/synonymdev/bitkit-core/issues/63 + if (Bip21Utils.isDuplicatedBip21(input)) { + showAddressValidationError( + titleRes = R.string.other__scan_err_decoding, + descriptionRes = R.string.other__scan__error__generic, + testTag = "DuplicatedBip21Toast", + ) + return@withContext + } + + val scanResult = runCatching { decode(input) } + + if (scanResult.isFailure) { + showAddressValidationError( + titleRes = R.string.other__scan_err_decoding, + descriptionRes = R.string.other__scan__error__generic, + testTag = "InvalidAddressToast", + ) + 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__scan_err_decoding, + descriptionRes = R.string.other__scan__error__expired, + testTag = "ExpiredLightningToast", + ) + return + } + + if (invoice.amountSatoshis > 0uL) { + val maxSendLightning = walletRepo.balanceState.value.maxSendLightningSats + if (maxSendLightning == 0uL || !lightningRepo.canSend(invoice.amountSatoshis)) { + val shortfall = invoice.amountSatoshis.safe() - maxSendLightning.safe() + showAddressValidationError( + 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 } } + + _sendUiState.update { it.copy(isAddressInputValid = true) } + } + + private fun validateOnChainAddress(invoice: OnChainInvoice) { + val validatedAddress = runCatching { validateBitcoinAddress(invoice.address) } + .getOrElse { + showAddressValidationError( + titleRes = R.string.other__scan_err_decoding, + descriptionRes = R.string.wallet__error_invalid_bitcoin_address, + testTag = "InvalidAddressToast", + ) + return + } + + if (NetworkValidationHelper.isNetworkMismatch(validatedAddress.network.toLdkNetwork(), Env.network)) { + showAddressValidationError( + titleRes = R.string.other__scan_err_decoding, + descriptionRes = R.string.other__scan__error__generic, + testTag = "InvalidAddressToast", + ) + return + } + + 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, + testTag = "InsufficientSavingsToast", + ) + return + } + + 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_amount_description, + descriptionArgs = mapOf("amount" to shortfall.toString()), + testTag = "InsufficientSavingsToast", + ) + return + } + + _sendUiState.update { it.copy(isAddressInputValid = true) } + } + + private fun showAddressValidationError( + @StringRes titleRes: Int, + @StringRes descriptionRes: Int, + descriptionArgs: Map = emptyMap(), + testTag: String? = null, + ) { + _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 = description, + testTag = testTag, + ) } private fun onAddressContinue(data: String) { @@ -857,6 +994,17 @@ class AppViewModel @Inject constructor( resetSendState() resetQuickPay() + // TODO Workaround for https://github.com/synonymdev/bitkit-core/issues/63 + if (Bip21Utils.isDuplicatedBip21(result)) { + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.other__scan_err_decoding), + description = context.getString(R.string.other__scan__error__generic), + testTag = "DuplicatedBip21Toast", + ) + return@withContext + } + @Suppress("ForbiddenComment") // TODO: wrap `decode` from bindings in a `CoreService` method and call that one val scan = runCatching { decode(result) } .onFailure { Logger.error("Failed to decode scan data: '$result'", it, context = TAG) } @@ -883,20 +1031,35 @@ class AppViewModel @Inject constructor( } } - @Suppress("LongMethod") + @Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount") private suspend fun onScanOnchain(invoice: OnChainInvoice, scanResult: String) { + val validatedAddress = runCatching { validateBitcoinAddress(invoice.address) } + .getOrElse { + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.other__scan_err_decoding), + description = context.getString(R.string.wallet__error_invalid_bitcoin_address), + testTag = "InvalidAddressToast", + ) + return + } + + if (NetworkValidationHelper.isNetworkMismatch(validatedAddress.network.toLdkNetwork(), 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), + testTag = "InvalidAddressToast", + ) + return + } + val lnInvoice: LightningInvoice? = invoice.params?.get("lightning")?.let { bolt11 -> runCatching { decode(bolt11) }.getOrNull() ?.let { it as? Scanner.Lightning } ?.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 @@ -943,6 +1106,31 @@ 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), + testTag = "InsufficientSavingsToast", + ) + 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()), + testTag = "InsufficientSavingsToast", + ) + return + } + Logger.info( when (invoice.amountSatoshis > 0u) { true -> "Found amount in invoice, proceeding to edit amount" @@ -964,6 +1152,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 } @@ -972,10 +1161,14 @@ class AppViewModel @Inject constructor( if (quickPayHandled) return if (!lightningRepo.canSend(invoice.amountSatoshis)) { + val maxSendLightning = walletRepo.balanceState.value.maxSendLightningSats + val shortfall = invoice.amountSatoshis.safe() - maxSendLightning.safe() 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()), + testTag = "InsufficientSpendingToast", ) return } @@ -1331,18 +1524,9 @@ class AppViewModel @Inject constructor( when (_sendUiState.value.payMethod) { SendMethod.ONCHAIN -> { val address = _sendUiState.value.address - // TODO validate early, validate network & address types, showing detailed errors - val validatedAddress = runCatching { validateBitcoinAddress(address) } - .getOrElse { e -> - Logger.error("Invalid bitcoin send address: '$address'", e, context = TAG) - toast(Exception(context.getString(R.string.wallet__error_invalid_bitcoin_address))) - hideSheet() - return - } - val tags = _sendUiState.value.selectedTags - sendOnchain(validatedAddress.address, amount, tags = tags) + sendOnchain(address, amount, tags = tags) .onSuccess { txId -> Logger.info("Onchain send result txid: $txId", context = TAG) handlePaymentSuccess( @@ -1700,6 +1884,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 @@ -2029,6 +2214,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 54f5d0b3c..4eec55d59 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 Lightning invoice. Insufficient Spending Balance ₿ {amount} more needed to pay this Lightning invoice. Open Phone Settings diff --git a/app/src/test/java/to/bitkit/utils/Bip21UrlBuilderTest.kt b/app/src/test/java/to/bitkit/utils/Bip21UrlBuilderTest.kt index 8ff75c944..ba2135a2b 100644 --- a/app/src/test/java/to/bitkit/utils/Bip21UrlBuilderTest.kt +++ b/app/src/test/java/to/bitkit/utils/Bip21UrlBuilderTest.kt @@ -5,6 +5,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.JUnit4 import to.bitkit.utils.Bip21Utils.buildBip21Url +import to.bitkit.utils.Bip21Utils.isDuplicatedBip21 @RunWith(JUnit4::class) class Bip21UrlBuilderTest { @@ -171,4 +172,47 @@ class Bip21UrlBuilderTest { val expected = "bitcoin:$address?amount=0.0001&message=Bitkit&lightning=${invoice.encodeToUrl()}" Assert.assertEquals(expected, buildBip21Url(address, amount, lightningInvoice = invoice)) } + + // Tests for isDuplicatedBip21 - Workaround for bitkit-core#63 + + @Test + fun `isDuplicatedBip21 returns false for single valid BIP21 URI`() { + val input = "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.001&message=Bitkit" + Assert.assertFalse(isDuplicatedBip21(input)) + } + + @Test + fun `isDuplicatedBip21 returns true when BIP21 URI is duplicated`() { + val first = "bitcoin:bcrt1qr289x0fhg62672e8urudfnxnsr8tcax64xk2vk?amount=0.0000002&message=Bitkit" + val second = "bitcoin:bcrt1qr289x0fhg62672e8urudfnxnsr8tcax64xk2vk?amount=0.0000003&message=Bitkit" + val input = first + second + Assert.assertTrue(isDuplicatedBip21(input)) + } + + @Test + fun `isDuplicatedBip21 handles case-insensitive bitcoin prefix`() { + val first = "BITCOIN:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.001" + val second = "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.002" + val input = first + second + Assert.assertTrue(isDuplicatedBip21(input)) + } + + @Test + fun `isDuplicatedBip21 returns false for non-bitcoin URIs`() { + val input = "lnbc500n1p3k9v3pp5kzmj..." + Assert.assertFalse(isDuplicatedBip21(input)) + } + + @Test + fun `isDuplicatedBip21 returns false for empty string`() { + Assert.assertFalse(isDuplicatedBip21("")) + } + + @Test + fun `isDuplicatedBip21 handles mixed case duplicated URIs`() { + val first = "Bitcoin:bc1qaddr1?amount=0.001" + val second = "BITCOIN:bc1qaddr2?amount=0.002" + val input = first + second + Assert.assertTrue(isDuplicatedBip21(input)) + } } 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..6d70ba37e --- /dev/null +++ b/app/src/test/java/to/bitkit/utils/NetworkValidationHelperTest.kt @@ -0,0 +1,44 @@ +package to.bitkit.utils + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.lightningdevkit.ldknode.Network + +class NetworkValidationHelperTest { + + // 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)) + } +}