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