From 58d67c8c614d05ad95a4f3d67b0106eae7dbcc27 Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Fri, 12 Jul 2024 15:14:02 +0200 Subject: [PATCH] Fix BIP353 implementation details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - accept BIP353 address starting with ₿ - skip LNURL resolution for address starting with ₿ - accept `lno` offer parameter in BIP21 uris - do not reject BIP21 uris without an address on Android: - display the ₿ prefix in BIP353 address - copying the address now includes the ₿ prefix - add a button to copy the BIP21 uri - allow the user to pay the offer in bip21 uris Fixes #588 --- .../phoenix/android/components/Inputs.kt | 3 +- .../phoenix/android/payments/ScanDataView.kt | 11 ++- .../payments/receive/ReceiveOfferView.kt | 15 ++-- .../android/settings/ExperimentalView.kt | 9 +- .../walletinfo/SwapInRefundViewModel.kt | 31 ++++--- .../src/main/res/values/important_strings.xml | 4 + .../src/main/res/values/strings.xml | 7 +- .../controllers/payments/ScanController.kt | 78 +++++++++--------- .../fr.acinq.phoenix/data/BitcoinAddress.kt | 6 +- .../fr.acinq.phoenix/utils/DnsResolvers.kt | 82 +++++++++++++++++++ .../kotlin/fr.acinq.phoenix/utils/Parser.kt | 22 ++++- .../phoenix/db/SqlitePaymentsDatabaseTest.kt | 4 +- .../acinq/phoenix/db/cloud/CloudDataTest.kt | 2 +- .../fr/acinq/phoenix/utils/CsvWriterTests.kt | 3 +- .../acinq/phoenix/utils/DnsResolversTests.kt | 36 ++++++++ .../fr/acinq/phoenix/utils/ParserTest.kt | 3 + 16 files changed, 244 insertions(+), 72 deletions(-) create mode 100644 phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/DnsResolvers.kt create mode 100644 phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/DnsResolversTests.kt diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Inputs.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Inputs.kt index 1dc289864..28c87009f 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Inputs.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Inputs.kt @@ -64,6 +64,7 @@ fun TextInput( onTextChange: (String) -> Unit, textFieldColors: TextFieldColors = outlinedTextFieldColors(), showResetButton: Boolean = true, + keyboardType: KeyboardType = KeyboardType.Text ) { val charsCount by remember(text) { mutableStateOf(text.length) } val focusManager = LocalFocusManager.current @@ -81,7 +82,7 @@ fun TextInput( singleLine = singleLine, keyboardOptions = KeyboardOptions.Default.copy( imeAction = ImeAction.Done, - keyboardType = KeyboardType.Text + keyboardType = keyboardType ), label = null, placeholder = placeholder, diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/ScanDataView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/ScanDataView.kt index 0020e8774..b147f5c66 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/ScanDataView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/ScanDataView.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidViewBinding @@ -157,8 +158,8 @@ fun ScanDataView( SendOfferView(offer = model.offer, trampolineFees = trampolineFees, onBackClick = onBackClick, onPaymentSent = onProcessingFinished) } is Scan.Model.OnchainFlow -> { - val paymentRequest = model.uri.paymentRequest - if (paymentRequest == null) { + val lightningRequest = model.uri.paymentRequest?.write() ?: model.uri.offer?.encode() + if (lightningRequest == null) { SendSpliceOutView( requestedAmount = model.uri.amount, address = model.uri.address, @@ -171,11 +172,11 @@ fun ScanDataView( ChoosePaymentModeDialog( onPayOffchainClick = { showPaymentModeDialog = true - postIntent(Scan.Intent.Parse(request = paymentRequest.write())) + postIntent(Scan.Intent.Parse(request = lightningRequest)) }, onPayOnchainClick = { showPaymentModeDialog = true - postIntent(Scan.Intent.Parse(request = model.uri.copy(paymentRequest = null).write())) + postIntent(Scan.Intent.Parse(request = model.uri.copy(paymentRequest = null, offer = null).write())) }, onDismiss = { showPaymentModeDialog = false @@ -428,6 +429,8 @@ private fun ManualInputDialog( minLines = 2, maxLines = 3, staticLabel = stringResource(id = R.string.scan_manual_input_label), + placeholder = { Text(text = stringResource(id = R.string.scan_manual_input_hint)) }, + keyboardType = KeyboardType.Email, ) } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveOfferView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveOfferView.kt index 9bb9231d2..e7eef433f 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveOfferView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveOfferView.kt @@ -134,7 +134,7 @@ fun Bip353AddressDisplay(address: String?) { address.isNullOrBlank() -> {} else -> { SelectionContainer { - Text(text = address, style = MaterialTheme.typography.body2) + Text(text = stringResource(id = R.string.utils_bip353_with_prefix, address), style = MaterialTheme.typography.body2) } Spacer(modifier = Modifier.height(16.dp)) } @@ -161,19 +161,24 @@ private fun CopyAddressDialog( containerColor = MaterialTheme.colors.surface, contentColor = MaterialTheme.colors.onSurface, scrimColor = MaterialTheme.colors.onBackground.copy(alpha = 0.2f), - //dragHandle = null ) { Column(Modifier.padding(bottom = 50.dp)) { Button( - text = "Copy Bolt12 code", + text = stringResource(id = R.string.receive_offer_copy_bip353), + icon = R.drawable.ic_copy, + onClick = { copyToClipboard(context, data = context.getString(R.string.utils_bip353_with_prefix, address)) }, + modifier = Modifier.fillMaxWidth(), + ) + Button( + text = stringResource(id = R.string.receive_offer_copy_bolt12), icon = R.drawable.ic_copy, onClick = { copyToClipboard(context, data = offer) }, modifier = Modifier.fillMaxWidth(), ) Button( - text = "Copy address", + text = stringResource(id = R.string.receive_offer_copy_bip21), icon = R.drawable.ic_copy, - onClick = { copyToClipboard(context, data = address) }, + onClick = { copyToClipboard(context, data = "bitcoin:?lno=$offer") }, modifier = Modifier.fillMaxWidth(), ) } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ExperimentalView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ExperimentalView.kt index efc65097b..273934caf 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ExperimentalView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ExperimentalView.kt @@ -177,9 +177,14 @@ private fun ClaimAddressButton( is ClaimAddressState.Done -> { val context = LocalContext.current Setting( - title = state.address, + title = stringResource(id = R.string.utils_bip353_with_prefix, state.address), leadingIcon = { PhoenixIcon(R.drawable.ic_arobase) }, - trailingIcon = { Button(icon = R.drawable.ic_copy, onClick = { copyToClipboard(context, state.address) }) }, + trailingIcon = { + Button( + icon = R.drawable.ic_copy, + onClick = { copyToClipboard(context, context.getString(R.string.utils_bip353_with_prefix, state.address)) } + ) + }, subtitle = { Text(text = stringResource(id = R.string.bip353_subtitle)) Spacer(modifier = Modifier.height(4.dp)) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInRefundViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInRefundViewModel.kt index 5f19bd5eb..0fc734d25 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInRefundViewModel.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/SwapInRefundViewModel.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import fr.acinq.bitcoin.BitcoinError import fr.acinq.bitcoin.Satoshi import fr.acinq.bitcoin.Transaction import fr.acinq.bitcoin.utils.Either @@ -83,18 +84,26 @@ class SwapInRefundViewModel( is Either.Right -> { val keyManager = walletManager.keyManager.filterNotNull().first() val swapInWallet = peerManager.swapInWallet.filterNotNull().first() - val res = swapInWallet.spendExpiredSwapIn( - swapInKeys = keyManager.swapInOnChainWallet, - scriptPubKey = parseAddress.value.script, - feerate = FeeratePerKw(feerate) - ) - state = if (res == null) { - log.error("could not generate a swap-in refund transaction") - SwapInRefundState.Done.Failed.CannotCreateTx + val script = parseAddress.value.script + if (script == null) { + state = SwapInRefundState.Done.Failed.InvalidAddress( + address = parseAddress.value.address, + error = BitcoinUriError.InvalidScript(BitcoinError.InvalidScript) + ) } else { - val (tx, fee) = res - log.info("estimated fee=$fee for swap-in refund") - SwapInRefundState.ReviewFee(fees = fee, transaction = tx) + val res = swapInWallet.spendExpiredSwapIn( + swapInKeys = keyManager.swapInOnChainWallet, + scriptPubKey = script, + feerate = FeeratePerKw(feerate) + ) + state = if (res == null) { + log.error("could not generate a swap-in refund transaction") + SwapInRefundState.Done.Failed.CannotCreateTx + } else { + val (tx, fee) = res + log.info("estimated fee=$fee for swap-in refund") + SwapInRefundState.ReviewFee(fees = fee, transaction = tx) + } } } } diff --git a/phoenix-android/src/main/res/values/important_strings.xml b/phoenix-android/src/main/res/values/important_strings.xml index ff84c087f..62e0a68d4 100644 --- a/phoenix-android/src/main/res/values/important_strings.xml +++ b/phoenix-android/src/main/res/values/important_strings.xml @@ -157,6 +157,10 @@ Check that Android does not apply a battery saving mode to Phoenix. Otherwise, you won\'t be able to receive payments if the app is closed or in the background. Enabling TOR in Phoenix will also cause issues. Learn more + Copy human-readable address + Copy payment code + Copy full URI + Tor is enabled diff --git a/phoenix-android/src/main/res/values/strings.xml b/phoenix-android/src/main/res/values/strings.xml index 6cd6f778c..23cfa99dd 100644 --- a/phoenix-android/src/main/res/values/strings.xml +++ b/phoenix-android/src/main/res/values/strings.xml @@ -198,8 +198,9 @@ Resolving payment request over DNS… Manual input - Enter a Lightning invoice, LNURL or Lightning address you want to send money to. - Invoice or LN address + Enter a Lightning invoice, an offer, a LNURL or a Lightning address you want to send money to. + Invoice, LN address… + ₿satoshi@domain.com @@ -214,6 +215,8 @@ Loading preferences… Copied to clipboard! + ₿%1$s + Open link in a browser Open transaction in an explorer Open address in an explorer diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ScanController.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ScanController.kt index f7397e37b..a8ae9b5fd 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ScanController.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ScanController.kt @@ -20,35 +20,45 @@ import fr.acinq.bitcoin.BitcoinError import fr.acinq.bitcoin.Chain import fr.acinq.bitcoin.utils.Either import fr.acinq.bitcoin.utils.Try -import fr.acinq.lightning.* +import fr.acinq.lightning.Lightning +import fr.acinq.lightning.MilliSatoshi +import fr.acinq.lightning.TrampolineFees import fr.acinq.lightning.db.LightningOutgoingPayment import fr.acinq.lightning.io.PayInvoice import fr.acinq.lightning.logging.LoggerFactory import fr.acinq.lightning.logging.debug -import fr.acinq.lightning.utils.* -import fr.acinq.phoenix.PhoenixBusiness -import fr.acinq.phoenix.controllers.AppController -import fr.acinq.phoenix.data.* -import fr.acinq.phoenix.data.lnurl.* -import fr.acinq.phoenix.db.payments.WalletPaymentMetadataRow -import fr.acinq.phoenix.managers.* -import fr.acinq.phoenix.utils.Parser import fr.acinq.lightning.logging.error import fr.acinq.lightning.logging.info -import fr.acinq.lightning.logging.warning import fr.acinq.lightning.payment.Bolt11Invoice +import fr.acinq.lightning.utils.UUID +import fr.acinq.lightning.utils.currentTimestampSeconds import fr.acinq.lightning.wire.OfferTypes -import io.ktor.client.HttpClient -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.request.get -import io.ktor.client.statement.bodyAsText +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.controllers.AppController +import fr.acinq.phoenix.data.BitcoinUri +import fr.acinq.phoenix.data.BitcoinUriError +import fr.acinq.phoenix.data.LnurlPayMetadata +import fr.acinq.phoenix.data.WalletPaymentId +import fr.acinq.phoenix.data.WalletPaymentMetadata +import fr.acinq.phoenix.data.lnurl.Lnurl +import fr.acinq.phoenix.data.lnurl.LnurlAuth +import fr.acinq.phoenix.data.lnurl.LnurlError +import fr.acinq.phoenix.data.lnurl.LnurlPay +import fr.acinq.phoenix.data.lnurl.LnurlWithdraw +import fr.acinq.phoenix.db.payments.WalletPaymentMetadataRow +import fr.acinq.phoenix.managers.DatabaseManager +import fr.acinq.phoenix.managers.LnurlManager +import fr.acinq.phoenix.managers.PeerManager +import fr.acinq.phoenix.utils.DnsResolvers +import fr.acinq.phoenix.utils.Parser import io.ktor.http.Url -import io.ktor.serialization.kotlinx.json.json -import io.ktor.utils.io.charsets.Charsets -import kotlinx.coroutines.* +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first -import kotlinx.serialization.json.Json +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.intOrNull @@ -528,17 +538,15 @@ class AppScanController( val username = components[0].lowercase() val domain = components[1] - return resolveBip353Offer(username, domain)?.let { - Either.Left(it) - } ?: Either.Right(Lnurl.Request(Url("https://$domain/.well-known/lnurlp/$username"), tag = Lnurl.Tag.Pay)) - } + val signalBip353 = username.startsWith("₿") + val cleanUsername = username.dropWhile { it == '₿' } - val bip353HttpClient: HttpClient by lazy { - HttpClient { - install(ContentNegotiation) { - json(json = Json { ignoreUnknownKeys = true }) - expectSuccess = true - } + val offer = resolveBip353Offer(cleanUsername, domain) + return if (signalBip353) { + offer?.let { Either.Left(it) } // skip lnurl resolution if it's a bip353 address + } else { + offer?.let { Either.Left(it) } + ?: Either.Right(Lnurl.Request(Url("https://$domain/.well-known/lnurlp/$username"), tag = Lnurl.Tag.Pay)) } } @@ -550,26 +558,22 @@ class AppScanController( model(Scan.Model.ResolvingBip353) val dnsPath = "$username.user._bitcoin-payment.$domain." - // list of resolvers: https://dnsprivacy.org/public_resolvers/ - val url = Url("https://dns.google/resolve?name=$dnsPath&type=TXT") - try { - val response = bip353HttpClient.get(url) - val json = Json.decodeFromString(response.bodyAsText(Charsets.UTF_8)) - logger.info { "dns resolved to ${json.toString().take(100)}" } + val json = DnsResolvers.getRandom().getTxtRecord(dnsPath) + logger.debug { "dns resolved to ${json.toString().take(100)}" } val status = json["Status"]?.jsonPrimitive?.intOrNull if (status == null || status > 0) throw RuntimeException("invalid status=$status") val ad = json["AD"]?.jsonPrimitive?.booleanOrNull if (ad != true) { - logger.info { "AD false, abort dns lookup to $url" } + logger.info { "AD false, abort dns lookup" } return null } val records = json["Answer"]?.jsonArray if (records.isNullOrEmpty()) { - logger.debug { "no records for $url" } + logger.debug { "no records for $dnsPath" } return null } @@ -586,7 +590,7 @@ class AppScanController( return when (val offer = OfferTypes.Offer.decode(offerString)) { is Try.Success -> { offer.result } is Try.Failure -> { - model(Scan.Model.BadRequest(request = url.toString(), reason = Scan.BadRequestReason.InvalidBip353(dnsPath))) + model(Scan.Model.BadRequest(request = dnsPath, reason = Scan.BadRequestReason.InvalidBip353(dnsPath))) null } } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/BitcoinAddress.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/BitcoinAddress.kt index 1d96ef7c1..8f2d697d2 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/BitcoinAddress.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/BitcoinAddress.kt @@ -21,20 +21,22 @@ import fr.acinq.bitcoin.ByteVector import fr.acinq.bitcoin.Chain import fr.acinq.bitcoin.Satoshi import fr.acinq.lightning.payment.Bolt11Invoice +import fr.acinq.lightning.wire.OfferTypes import io.ktor.http.* data class BitcoinUri( val chain: Chain, /** Actual Bitcoin address; may be different than the source, e.g. if the source is an URI like "bitcoin:xyz?param=123". */ val address: String, - val script: ByteVector, + val script: ByteVector?, // Bip-21 parameters val label: String? = null, val message: String? = null, /** Amount requested in the URI. */ val amount: Satoshi? = null, - /** A Bitcoin URI may contain a Lightning payment request as an alternative way to make the payment. */ + /** A Bitcoin URI may contain a Bolt11 invoice or an offer as an alternative way to make the payment. */ val paymentRequest: Bolt11Invoice? = null, + val offer: OfferTypes.Offer? = null, /** Other bip-21 parameters in the URI that we do not handle. */ val ignoredParams: Parameters = Parameters.Empty, ) { diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/DnsResolvers.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/DnsResolvers.kt new file mode 100644 index 000000000..09c0e3894 --- /dev/null +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/DnsResolvers.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.utils + +import fr.acinq.lightning.utils.getValue +import io.ktor.client.HttpClient +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpHeaders +import io.ktor.http.headers +import io.ktor.serialization.kotlinx.json.json +import io.ktor.utils.io.charsets.Charsets +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlin.random.Random + +enum class DnsResolvers { + Google { + override fun url(name: String): Pair Unit> = "https://dns.google/resolve?name=$name&type=TXT" to {} + }, +// Cloudflare { +// override fun url(name: String): Pair Unit> = "https://cloudflare-dns.com/dns-query?name=$name&type=TXT" to { +// headers { append(HttpHeaders.Accept, "application/dns-json") } +// } +// }, + ; + + abstract fun url(name: String): Pair Unit> + + suspend fun getTxtRecord(query: String): JsonObject { + val (url, builder) = url(query) + val response = dohClient.get(url, builder) + return Json.decodeFromString(response.bodyAsText(Charsets.UTF_8)) + } + + companion object { + private val dohClient: HttpClient by lazy { + HttpClient { + install(ContentNegotiation) { + json(json = Json { ignoreUnknownKeys = true }) + expectSuccess = true + } + } + } + + fun getRandom(): DnsResolvers { + return Random.nextInt(0, DnsResolvers.entries.size).let { + DnsResolvers.entries[it] + } + } + } +} + +//object DnsHelper { +// + +// +// suspend fun getTXTRecord(query: String): JsonObject { +// Random.nextInt(0, DnsResolvers.entries.size).let { +// DnsResolvers.entries[it] +// }.let { +// val response = dohClient.get("$it?name=$query&type=TXT") +// return Json.decodeFromString(response.bodyAsText(Charsets.UTF_8)) +// } +// } +//} \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/Parser.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/Parser.kt index 4b57ec682..2c809848c 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/Parser.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/Parser.kt @@ -132,15 +132,29 @@ object Parser { is Try.Failure -> null } } + val offer = url.parameters["lno"]?.let { + when (val res = OfferTypes.Offer.decode(it)) { + is Try.Success -> res.get() + is Try.Failure -> null + } + } val otherParams = ParametersBuilder().apply { appendAll(url.parameters.filter { entry, _ -> - !listOf("amount", "label", "message", "lightning").contains(entry) + !listOf("amount", "label", "message", "lightning", "lno").contains(entry) }) }.build() - return when (val res = Bitcoin.addressToPublicKeyScript(chain.chainHash, address)) { - is Either.Left -> Either.Left(BitcoinUriError.InvalidScript(res.left)) - is Either.Right -> Either.Right(BitcoinUri(chain, address, res.right.let { Script.write(it) }.byteVector(), label, message, amount, lightning, otherParams)) + val scriptParse = address.takeIf { it.isNotBlank() }?.let { + Bitcoin.addressToPublicKeyScript(chain.chainHash, address) + } + return when (scriptParse) { + is Either.Left -> Either.Left(BitcoinUriError.InvalidScript(scriptParse.left)) + else -> Either.Right( + BitcoinUri( + chain = chain, address = address, script = scriptParse?.right?.let { Script.write(it) }?.byteVector(), label = label, + message = message, amount = amount, paymentRequest = lightning, offer = offer, ignoredParams = otherParams, + ) + ) } } diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/SqlitePaymentsDatabaseTest.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/SqlitePaymentsDatabaseTest.kt index 74e5023a3..aef6f06b9 100644 --- a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/SqlitePaymentsDatabaseTest.kt +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/SqlitePaymentsDatabaseTest.kt @@ -154,7 +154,7 @@ class SqlitePaymentsDatabaseTest { fun incoming__purge_expired() = runTest { val expiredPreimage = randomBytes32() val expiredInvoice = Bolt11Invoice.create( - chainHash = Block.TestnetGenesisBlock.hash, + chain = Chain.Testnet, amount = 150_000.msat, paymentHash = Crypto.sha256(expiredPreimage).toByteVector32(), privateKey = Lightning.randomKey(), @@ -407,7 +407,7 @@ class SqlitePaymentsDatabaseTest { msat: MilliSatoshi = 150_000.msat ): Bolt11Invoice { return Bolt11Invoice.create( - chainHash = Block.LivenetGenesisBlock.hash, + chain = Chain.Mainnet, amount = msat, paymentHash = Crypto.sha256(preimage).toByteVector32(), privateKey = Lightning.randomKey(), diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/cloud/CloudDataTest.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/cloud/CloudDataTest.kt index 565e67add..adcf64408 100644 --- a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/cloud/CloudDataTest.kt +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/db/cloud/CloudDataTest.kt @@ -398,7 +398,7 @@ class CloudDataTest { private fun createBolt11Invoice(preimage: ByteVector32, amount: MilliSatoshi): Bolt11Invoice { return Bolt11Invoice.create( - chainHash = Block.LivenetGenesisBlock.hash, + chain = Chain.Mainnet, amount = amount, paymentHash = Crypto.sha256(preimage).toByteVector32(), privateKey = randomKey(), diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/CsvWriterTests.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/CsvWriterTests.kt index 46de3a343..b459dcd6b 100644 --- a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/CsvWriterTests.kt +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/CsvWriterTests.kt @@ -1,6 +1,7 @@ package fr.acinq.phoenix.utils import fr.acinq.bitcoin.Block +import fr.acinq.bitcoin.Chain import fr.acinq.bitcoin.OutPoint import fr.acinq.bitcoin.PrivateKey import fr.acinq.bitcoin.PublicKey @@ -336,7 +337,7 @@ class CsvWriterTests { */ private fun makePaymentRequest() = Bolt11Invoice.create( - chainHash = Block.TestnetGenesisBlock.hash, + chain = Chain.Testnet, amount = 10_000.msat, paymentHash = randomBytes32(), privateKey = PrivateKey(value = randomBytes32()), diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/DnsResolversTests.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/DnsResolversTests.kt new file mode 100644 index 000000000..d2bc86004 --- /dev/null +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/DnsResolversTests.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2024 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.phoenix.utils + +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertTrue + +class DnsResolversTests { + + @Test + fun testDnsResolversBip353() = runBlocking { + + val username = "flashybugle70" + val domain = "testnet.phoenixwallet.me" + + DnsResolvers.entries.forEach { + val json = it.getTxtRecord("$username.user._bitcoin-payment.$domain") + assertTrue { json.isNotEmpty() } + } + } +} \ No newline at end of file diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/ParserTest.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/ParserTest.kt index 33a245e70..242e6130f 100644 --- a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/ParserTest.kt +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/ParserTest.kt @@ -75,6 +75,9 @@ class ParserTest { assertIs>( Parser.readBitcoinAddress(Chain.Mainnet, "bitcoin://bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") ) + assertIs>( + Parser.readBitcoinAddress(Chain.Testnet, "bitcoin:?lno=lno1qgsyxjtl6luzd9t3pr62xr7eemp6awnejusgf6gw45q75vcfqqqqqqqsespexwyy4tcadvgg89l9aljus6709kx235hhqrk6n8dey98uyuftzdqrt2gkjvf2rj2vnt7m7chnmazen8wpur2h65ttgftkqaugy6ql9dcsyq39xc2g084xfn0s50zlh2ex22vvaqxqz3vmudklz453nns4d0624sqr8ux4p5usm22qevld4ydfck7hwgcg9wc3f78y7jqhc6hwdq7e9dwkhty3svq5ju4dptxtldjumlxh5lw48jsz6pnagtwrmeus7uq9rc5g6uddwcwldpklxexvlezld8egntua4gsqqy8auz966nksacdac8yv3maq6elp") + ) } @Test