Skip to content

Commit 2931fe8

Browse files
authored
Merge branch 'fix/address-validation' into chore/bump-verion
2 parents f20b58d + 83a470e commit 2931fe8

6 files changed

Lines changed: 115 additions & 149 deletions

File tree

app/src/main/java/to/bitkit/models/Network.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,10 @@ fun Network.toCoreNetworkType(): NetworkType = when (this) {
2424
Network.SIGNET -> NetworkType.SIGNET
2525
Network.REGTEST -> NetworkType.REGTEST
2626
}
27+
28+
fun NetworkType.toLdkNetwork(): Network = when (this) {
29+
NetworkType.BITCOIN -> Network.BITCOIN
30+
NetworkType.TESTNET -> Network.TESTNET
31+
NetworkType.SIGNET -> Network.SIGNET
32+
NetworkType.REGTEST -> Network.REGTEST
33+
}

app/src/main/java/to/bitkit/utils/Bip21Utils.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@ import to.bitkit.models.SATS_IN_BTC
44

55
object Bip21Utils {
66

7+
private const val BIP21_PREFIX = "bitcoin:"
8+
9+
/**
10+
* Checks if a BIP21 URI is duplicated (contains multiple bitcoin: prefixes).
11+
* Workaround for https://github.com/synonymdev/bitkit-core/issues/63
12+
* @return true if the input contains duplicated BIP21 URIs, false otherwise
13+
*/
14+
fun isDuplicatedBip21(input: String): Boolean {
15+
val lowercased = input.lowercase()
16+
val firstIndex = lowercased.indexOf(BIP21_PREFIX)
17+
if (firstIndex == -1) return false
18+
19+
val secondIndex = lowercased.indexOf(BIP21_PREFIX, firstIndex + BIP21_PREFIX.length)
20+
return secondIndex != -1
21+
}
22+
723
fun buildBip21Url(
824
bitcoinAddress: String,
925
amountSats: ULong? = null,

app/src/main/java/to/bitkit/utils/NetworkValidationHelper.kt

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,31 +6,6 @@ import org.lightningdevkit.ldknode.Network
66
* Helper for validating Bitcoin network compatibility of addresses and invoices
77
*/
88
object NetworkValidationHelper {
9-
10-
/**
11-
* Infer the Bitcoin network from an on-chain address prefix
12-
* @param address The Bitcoin address to check
13-
* @return The detected network, or null if the address format is unrecognized
14-
*/
15-
fun getAddressNetwork(address: String): Network? {
16-
val lowercased = address.lowercase()
17-
18-
// Bech32/Bech32m addresses (order matters: check bcrt1 before bc1)
19-
return when {
20-
lowercased.startsWith("bcrt1") -> Network.REGTEST
21-
lowercased.startsWith("bc1") -> Network.BITCOIN
22-
lowercased.startsWith("tb1") -> Network.TESTNET
23-
else -> {
24-
// Legacy addresses - check first character
25-
when (address.firstOrNull()) {
26-
'1', '3' -> Network.BITCOIN
27-
'm', 'n', '2' -> Network.TESTNET // testnet and regtest share these
28-
else -> null
29-
}
30-
}
31-
}
32-
}
33-
349
/**
3510
* Check if an address/invoice network mismatches the current app network
3611
* @param addressNetwork The network detected from the address/invoice

app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ import to.bitkit.models.Toast
8686
import to.bitkit.models.TransactionSpeed
8787
import to.bitkit.models.safe
8888
import to.bitkit.models.toActivityFilter
89+
import to.bitkit.models.toLdkNetwork
8990
import to.bitkit.models.toTxType
9091
import to.bitkit.repositories.ActivityRepo
9192
import to.bitkit.repositories.BackupRepo
@@ -107,6 +108,7 @@ import to.bitkit.ui.shared.toast.ToastEventBus
107108
import to.bitkit.ui.shared.toast.ToastQueueManager
108109
import to.bitkit.ui.sheets.SendRoute
109110
import to.bitkit.ui.theme.TRANSITION_SCREEN_MS
111+
import to.bitkit.utils.Bip21Utils
110112
import to.bitkit.utils.Logger
111113
import to.bitkit.utils.NetworkValidationHelper
112114
import to.bitkit.utils.jsonLogOf
@@ -701,6 +703,16 @@ class AppViewModel @Inject constructor(
701703
}
702704

703705
private suspend fun validateAddressWithFeedback(input: String) = withContext(bgDispatcher) {
706+
// TODO Workaround for https://github.com/synonymdev/bitkit-core/issues/63
707+
if (Bip21Utils.isDuplicatedBip21(input)) {
708+
showAddressValidationError(
709+
titleRes = R.string.other__scan_err_decoding,
710+
descriptionRes = R.string.other__scan__error__generic,
711+
testTag = "DuplicatedBip21Toast",
712+
)
713+
return@withContext
714+
}
715+
704716
val scanResult = runCatching { decode(input) }
705717

706718
if (scanResult.isFailure) {
@@ -747,9 +759,17 @@ class AppViewModel @Inject constructor(
747759
}
748760

749761
private fun validateOnChainAddress(invoice: OnChainInvoice) {
750-
// Check network mismatch
751-
val addressNetwork = NetworkValidationHelper.getAddressNetwork(invoice.address)
752-
if (NetworkValidationHelper.isNetworkMismatch(addressNetwork, Env.network)) {
762+
val validatedAddress = runCatching { validateBitcoinAddress(invoice.address) }
763+
.getOrElse {
764+
showAddressValidationError(
765+
titleRes = R.string.other__scan_err_decoding,
766+
descriptionRes = R.string.wallet__error_invalid_bitcoin_address,
767+
testTag = "InvalidAddressToast",
768+
)
769+
return
770+
}
771+
772+
if (NetworkValidationHelper.isNetworkMismatch(validatedAddress.network.toLdkNetwork(), Env.network)) {
753773
showAddressValidationError(
754774
titleRes = R.string.other__scan_err_decoding,
755775
descriptionRes = R.string.other__scan__error__generic,
@@ -974,6 +994,17 @@ class AppViewModel @Inject constructor(
974994
resetSendState()
975995
resetQuickPay()
976996

997+
// TODO Workaround for https://github.com/synonymdev/bitkit-core/issues/63
998+
if (Bip21Utils.isDuplicatedBip21(result)) {
999+
toast(
1000+
type = Toast.ToastType.ERROR,
1001+
title = context.getString(R.string.other__scan_err_decoding),
1002+
description = context.getString(R.string.other__scan__error__generic),
1003+
testTag = "DuplicatedBip21Toast",
1004+
)
1005+
return@withContext
1006+
}
1007+
9771008
@Suppress("ForbiddenComment") // TODO: wrap `decode` from bindings in a `CoreService` method and call that one
9781009
val scan = runCatching { decode(result) }
9791010
.onFailure { Logger.error("Failed to decode scan data: '$result'", it, context = TAG) }
@@ -1000,11 +1031,20 @@ class AppViewModel @Inject constructor(
10001031
}
10011032
}
10021033

1003-
@Suppress("LongMethod", "CyclomaticComplexMethod")
1034+
@Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount")
10041035
private suspend fun onScanOnchain(invoice: OnChainInvoice, scanResult: String) {
1005-
// Check network mismatch
1006-
val addressNetwork = NetworkValidationHelper.getAddressNetwork(invoice.address)
1007-
if (NetworkValidationHelper.isNetworkMismatch(addressNetwork, Env.network)) {
1036+
val validatedAddress = runCatching { validateBitcoinAddress(invoice.address) }
1037+
.getOrElse {
1038+
toast(
1039+
type = Toast.ToastType.ERROR,
1040+
title = context.getString(R.string.other__scan_err_decoding),
1041+
description = context.getString(R.string.wallet__error_invalid_bitcoin_address),
1042+
testTag = "InvalidAddressToast",
1043+
)
1044+
return
1045+
}
1046+
1047+
if (NetworkValidationHelper.isNetworkMismatch(validatedAddress.network.toLdkNetwork(), Env.network)) {
10081048
toast(
10091049
type = Toast.ToastType.ERROR,
10101050
title = context.getString(R.string.other__scan_err_decoding),
@@ -1484,18 +1524,9 @@ class AppViewModel @Inject constructor(
14841524
when (_sendUiState.value.payMethod) {
14851525
SendMethod.ONCHAIN -> {
14861526
val address = _sendUiState.value.address
1487-
// TODO validate early, validate network & address types, showing detailed errors
1488-
val validatedAddress = runCatching { validateBitcoinAddress(address) }
1489-
.getOrElse { e ->
1490-
Logger.error("Invalid bitcoin send address: '$address'", e, context = TAG)
1491-
toast(Exception(context.getString(R.string.wallet__error_invalid_bitcoin_address)))
1492-
hideSheet()
1493-
return
1494-
}
1495-
14961527
val tags = _sendUiState.value.selectedTags
14971528

1498-
sendOnchain(validatedAddress.address, amount, tags = tags)
1529+
sendOnchain(address, amount, tags = tags)
14991530
.onSuccess { txId ->
15001531
Logger.info("Onchain send result txid: $txId", context = TAG)
15011532
handlePaymentSuccess(

app/src/test/java/to/bitkit/utils/Bip21UrlBuilderTest.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import org.junit.Test
55
import org.junit.runner.RunWith
66
import org.junit.runners.JUnit4
77
import to.bitkit.utils.Bip21Utils.buildBip21Url
8+
import to.bitkit.utils.Bip21Utils.isDuplicatedBip21
89

910
@RunWith(JUnit4::class)
1011
class Bip21UrlBuilderTest {
@@ -171,4 +172,47 @@ class Bip21UrlBuilderTest {
171172
val expected = "bitcoin:$address?amount=0.0001&message=Bitkit&lightning=${invoice.encodeToUrl()}"
172173
Assert.assertEquals(expected, buildBip21Url(address, amount, lightningInvoice = invoice))
173174
}
175+
176+
// Tests for isDuplicatedBip21 - Workaround for bitkit-core#63
177+
178+
@Test
179+
fun `isDuplicatedBip21 returns false for single valid BIP21 URI`() {
180+
val input = "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.001&message=Bitkit"
181+
Assert.assertFalse(isDuplicatedBip21(input))
182+
}
183+
184+
@Test
185+
fun `isDuplicatedBip21 returns true when BIP21 URI is duplicated`() {
186+
val first = "bitcoin:bcrt1qr289x0fhg62672e8urudfnxnsr8tcax64xk2vk?amount=0.0000002&message=Bitkit"
187+
val second = "bitcoin:bcrt1qr289x0fhg62672e8urudfnxnsr8tcax64xk2vk?amount=0.0000003&message=Bitkit"
188+
val input = first + second
189+
Assert.assertTrue(isDuplicatedBip21(input))
190+
}
191+
192+
@Test
193+
fun `isDuplicatedBip21 handles case-insensitive bitcoin prefix`() {
194+
val first = "BITCOIN:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.001"
195+
val second = "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.002"
196+
val input = first + second
197+
Assert.assertTrue(isDuplicatedBip21(input))
198+
}
199+
200+
@Test
201+
fun `isDuplicatedBip21 returns false for non-bitcoin URIs`() {
202+
val input = "lnbc500n1p3k9v3pp5kzmj..."
203+
Assert.assertFalse(isDuplicatedBip21(input))
204+
}
205+
206+
@Test
207+
fun `isDuplicatedBip21 returns false for empty string`() {
208+
Assert.assertFalse(isDuplicatedBip21(""))
209+
}
210+
211+
@Test
212+
fun `isDuplicatedBip21 handles mixed case duplicated URIs`() {
213+
val first = "Bitcoin:bc1qaddr1?amount=0.001"
214+
val second = "BITCOIN:bc1qaddr2?amount=0.002"
215+
val input = first + second
216+
Assert.assertTrue(isDuplicatedBip21(input))
217+
}
174218
}
Lines changed: 0 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,89 +1,12 @@
11
package to.bitkit.utils
22

3-
import org.junit.Assert.assertEquals
43
import org.junit.Assert.assertFalse
5-
import org.junit.Assert.assertNull
64
import org.junit.Assert.assertTrue
75
import org.junit.Test
86
import org.lightningdevkit.ldknode.Network
97

108
class NetworkValidationHelperTest {
119

12-
// MARK: - getAddressNetwork Tests
13-
14-
// Mainnet addresses
15-
@Test
16-
fun `getAddressNetwork - mainnet bech32`() {
17-
val address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"
18-
assertEquals(Network.BITCOIN, NetworkValidationHelper.getAddressNetwork(address))
19-
}
20-
21-
@Test
22-
fun `getAddressNetwork - mainnet bech32 uppercase`() {
23-
val address = "BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4"
24-
assertEquals(Network.BITCOIN, NetworkValidationHelper.getAddressNetwork(address))
25-
}
26-
27-
@Test
28-
fun `getAddressNetwork - mainnet P2PKH`() {
29-
val address = "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2"
30-
assertEquals(Network.BITCOIN, NetworkValidationHelper.getAddressNetwork(address))
31-
}
32-
33-
@Test
34-
fun `getAddressNetwork - mainnet P2SH`() {
35-
val address = "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy"
36-
assertEquals(Network.BITCOIN, NetworkValidationHelper.getAddressNetwork(address))
37-
}
38-
39-
// Testnet addresses
40-
@Test
41-
fun `getAddressNetwork - testnet bech32`() {
42-
val address = "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"
43-
assertEquals(Network.TESTNET, NetworkValidationHelper.getAddressNetwork(address))
44-
}
45-
46-
@Test
47-
fun `getAddressNetwork - testnet P2PKH m prefix`() {
48-
val address = "mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn"
49-
assertEquals(Network.TESTNET, NetworkValidationHelper.getAddressNetwork(address))
50-
}
51-
52-
@Test
53-
fun `getAddressNetwork - testnet P2PKH n prefix`() {
54-
val address = "n3ZddxzLvAY9o7184TB4c6FJasAybsw4HZ"
55-
assertEquals(Network.TESTNET, NetworkValidationHelper.getAddressNetwork(address))
56-
}
57-
58-
@Test
59-
fun `getAddressNetwork - testnet P2SH`() {
60-
val address = "2MzQwSSnBHWHqSAqtTVQ6v47XtaisrJa1Vc"
61-
assertEquals(Network.TESTNET, NetworkValidationHelper.getAddressNetwork(address))
62-
}
63-
64-
// Regtest addresses
65-
@Test
66-
fun `getAddressNetwork - regtest bech32`() {
67-
val address = "bcrt1q6rhpng9evdsfnn833a4f4vej0asu6dk5srld6x"
68-
assertEquals(Network.REGTEST, NetworkValidationHelper.getAddressNetwork(address))
69-
}
70-
71-
// Edge cases
72-
@Test
73-
fun `getAddressNetwork - empty string`() {
74-
assertNull(NetworkValidationHelper.getAddressNetwork(""))
75-
}
76-
77-
@Test
78-
fun `getAddressNetwork - invalid address`() {
79-
assertNull(NetworkValidationHelper.getAddressNetwork("invalid"))
80-
}
81-
82-
@Test
83-
fun `getAddressNetwork - random text`() {
84-
assertNull(NetworkValidationHelper.getAddressNetwork("test123"))
85-
}
86-
8710
// MARK: - isNetworkMismatch Tests
8811

8912
@Test
@@ -118,34 +41,4 @@ class NetworkValidationHelperTest {
11841
assertFalse(NetworkValidationHelper.isNetworkMismatch(null, Network.BITCOIN))
11942
assertFalse(NetworkValidationHelper.isNetworkMismatch(null, Network.REGTEST))
12043
}
121-
122-
// MARK: - Integration Tests
123-
124-
@Test
125-
fun `mainnet address on regtest should mismatch`() {
126-
val address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"
127-
val addressNetwork = NetworkValidationHelper.getAddressNetwork(address)
128-
assertTrue(NetworkValidationHelper.isNetworkMismatch(addressNetwork, Network.REGTEST))
129-
}
130-
131-
@Test
132-
fun `testnet address on regtest should not mismatch`() {
133-
val address = "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"
134-
val addressNetwork = NetworkValidationHelper.getAddressNetwork(address)
135-
assertFalse(NetworkValidationHelper.isNetworkMismatch(addressNetwork, Network.REGTEST))
136-
}
137-
138-
@Test
139-
fun `regtest address on mainnet should mismatch`() {
140-
val address = "bcrt1q6rhpng9evdsfnn833a4f4vej0asu6dk5srld6x"
141-
val addressNetwork = NetworkValidationHelper.getAddressNetwork(address)
142-
assertTrue(NetworkValidationHelper.isNetworkMismatch(addressNetwork, Network.BITCOIN))
143-
}
144-
145-
@Test
146-
fun `legacy testnet address on regtest should not mismatch`() {
147-
val address = "mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn" // m-prefix testnet
148-
val addressNetwork = NetworkValidationHelper.getAddressNetwork(address)
149-
assertFalse(NetworkValidationHelper.isNetworkMismatch(addressNetwork, Network.REGTEST))
150-
}
15144
}

0 commit comments

Comments
 (0)