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
50 changes: 50 additions & 0 deletions app/src/main/java/to/bitkit/utils/NetworkValidationHelper.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
185 changes: 170 additions & 15 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -107,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
Expand Down Expand Up @@ -196,6 +198,7 @@ class AppViewModel @Inject constructor(
registerSheet(highBalanceSheet)
}
private var isCompletingMigration = false
private var addressValidationJob: Job? = null

fun setShowForgotPin(value: Boolean) {
_showForgotPinSheet.value = value
Expand Down Expand Up @@ -664,6 +667,7 @@ class AppViewModel @Inject constructor(
}

private fun resetAddressInput() {
addressValidationJob?.cancel()
_sendUiState.update { state ->
state.copy(
addressInput = "",
Expand All @@ -674,15 +678,128 @@ 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,
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 - maxSendLightning
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) {
// 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,
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<String, String> = 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) {
Expand Down Expand Up @@ -883,20 +1000,26 @@ 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)
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),
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
Expand Down Expand Up @@ -943,6 +1066,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"
Expand All @@ -964,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
}
Expand All @@ -972,10 +1121,14 @@ 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()),
testTag = "InsufficientSpendingToast",
)
return
}
Expand Down Expand Up @@ -1700,6 +1853,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
Expand Down Expand Up @@ -2029,6 +2183,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
}
}

Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,7 @@
<string name="other__pay_insufficient_savings">Insufficient Savings</string>
<string name="other__pay_insufficient_savings_amount_description">₿ {amount} more needed to pay this Bitcoin invoice.</string>
<string name="other__pay_insufficient_savings_description">More ₿ needed to pay this Bitcoin invoice.</string>
<string name="other__pay_insufficient_spending_description">More ₿ needed to pay this Lightning 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__phone_settings">Open Phone Settings</string>
Expand Down
Loading
Loading