diff --git a/phoenix-android/build.gradle.kts b/phoenix-android/build.gradle.kts index c00e5d4b0..0b619bbc7 100644 --- a/phoenix-android/build.gradle.kts +++ b/phoenix-android/build.gradle.kts @@ -25,8 +25,8 @@ android { applicationId = "fr.acinq.phoenix.mainnet" minSdk = 26 targetSdk = 34 - versionCode = 85 - versionName = "2.3.4" + versionCode = 86 + versionName = gitCommitHash() testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resourceConfigurations.addAll(listOf("en", "fr", "de", "es", "b+es+419", "cs", "pt-rBR", "sk", "vi")) } 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 239f552b0..704e62104 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 @@ -361,6 +361,8 @@ private fun ScanErrorView( is Scan.BadRequestReason.InvalidLnurl -> stringResource(R.string.scan_error_lnurl_invalid) is Scan.BadRequestReason.UnsupportedLnurl -> stringResource(R.string.scan_error_lnurl_unsupported) is Scan.BadRequestReason.UnknownFormat -> stringResource(R.string.scan_error_invalid_generic) + is Scan.BadRequestReason.Bip353NameNotFound -> stringResource(id = R.string.scan_error_bip353_name_not_found, reason.username, reason.domain) + is Scan.BadRequestReason.Bip353InvalidUri -> stringResource(id = R.string.scan_error_bip353_invalid_uri) is Scan.BadRequestReason.Bip353InvalidOffer -> stringResource(id = R.string.scan_error_bip353_invalid_offer) is Scan.BadRequestReason.Bip353NoDNSSEC -> stringResource(id = R.string.scan_error_bip353_dnssec) } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/MutualCloseView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/MutualCloseView.kt index 40e0b7b0f..348564b4b 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/MutualCloseView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/MutualCloseView.kt @@ -152,7 +152,7 @@ fun MutualCloseView( modifier = Modifier.fillMaxWidth(), enabled = address.isNotBlank() && model.channels.isNotEmpty(), onClick = { - when (val validation = Parser.readBitcoinAddress(chain, address)) { + when (val validation = Parser.parseBip21Uri(chain, address)) { is Either.Left -> { val error = validation.value addressErrorMessage = when (error) { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/PaymentSettingsView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/PaymentSettingsView.kt index 988b73a40..8f79f5bb8 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/PaymentSettingsView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/PaymentSettingsView.kt @@ -17,13 +17,10 @@ package fr.acinq.phoenix.android.settings import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Slider import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -34,7 +31,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.components.* @@ -48,6 +44,8 @@ import fr.acinq.phoenix.android.utils.datastore.SwapAddressFormat import fr.acinq.phoenix.data.lnurl.LnurlAuth import kotlinx.coroutines.launch import java.text.NumberFormat +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours @Composable fun PaymentSettingsView( @@ -58,7 +56,6 @@ fun PaymentSettingsView( val userPrefs = userPrefs var showDescriptionDialog by rememberSaveable { mutableStateOf(false) } - var showExpiryDialog by rememberSaveable { mutableStateOf(false) } val invoiceDefaultDesc by userPrefs.getInvoiceDefaultDesc.collectAsState(initial = "") val invoiceDefaultExpiry by userPrefs.getInvoiceDefaultExpiry.collectAsState(null) @@ -77,18 +74,7 @@ fun PaymentSettingsView( description = invoiceDefaultDesc.ifEmpty { stringResource(id = R.string.paymentsettings_defaultdesc_none) }, onClick = { showDescriptionDialog = true } ) - Setting( - title = stringResource(id = R.string.paymentsettings_expiry_title), - description = when (invoiceDefaultExpiry) { - null -> stringResource(id = R.string.utils_unknown) - 1 * 604800L -> stringResource(id = R.string.paymentsettings_expiry_one_week) - 2 * 604800L -> stringResource(id = R.string.paymentsettings_expiry_two_weeks) - 3 * 604800L -> stringResource(id = R.string.paymentsettings_expiry_three_weeks) - else -> stringResource(id = R.string.paymentsettings_expiry_value, NumberFormat.getInstance().format(invoiceDefaultExpiry)) - }, - onClick = { showExpiryDialog = true } - ) - + Bolt11ExpiryPreference() val swapAddressFormat = swapAddressFormatState.value if (swapAddressFormat != null) { val schemes = listOf( @@ -192,70 +178,42 @@ fun PaymentSettingsView( } ) } - - if (showExpiryDialog) { - invoiceDefaultExpiry?.let { - DefaultExpiryInvoiceDialog( - expiry = it, - onDismiss = { showExpiryDialog = false }, - onConfirm = { - scope.launch { userPrefs.saveInvoiceDefaultExpiry(it.toLong()) } - showExpiryDialog = false - } - ) - } - } } @Composable -private fun DefaultExpiryInvoiceDialog( - expiry: (Long), - onDismiss: () -> Unit, - onConfirm: (Float) -> Unit, -) { - var paymentExpiry by rememberSaveable { mutableStateOf(expiry.toFloat()) } - - Dialog( - onDismiss = onDismiss, - title = stringResource(id = R.string.paymentsettings_expiry_dialog_title), - buttons = { - Button(onClick = onDismiss, text = stringResource(id = R.string.btn_cancel)) - Button( - onClick = { onConfirm(paymentExpiry) }, - text = stringResource(id = R.string.btn_ok) - ) - } - ) { - Column(Modifier.padding(horizontal = 24.dp)) { - Text(text = stringResource(id = R.string.paymentsettings_expiry_dialog_description)) - Spacer(Modifier.height(16.dp)) - Slider( - value = paymentExpiry, - onValueChange = { paymentExpiry = it }, - valueRange = 604800f..1814400f, - steps = 1, - ) - Row { - Text( - text = stringResource(id = R.string.paymentsettings_expiry_one_week), - style = MaterialTheme.typography.caption, - textAlign = TextAlign.Start, - modifier = Modifier.weight(1f) - ) - Text( - text = stringResource(id = R.string.paymentsettings_expiry_two_weeks), - style = MaterialTheme.typography.caption, - textAlign = TextAlign.Center, - modifier = Modifier.weight(1f) - ) - Text( - text = stringResource(id = R.string.paymentsettings_expiry_three_weeks), - style = MaterialTheme.typography.caption, - textAlign = TextAlign.End, - modifier = Modifier.weight(1f) - ) +private fun Bolt11ExpiryPreference() { + val scope = rememberCoroutineScope() + val userPrefs = userPrefs + val preferences = listOf( + PreferenceItem(item = 1.hours.inWholeSeconds, title = stringResource(id = R.string.paymentsettings_expiry_one_hour)), + PreferenceItem(item = 1.days.inWholeSeconds, title = stringResource(id = R.string.paymentsettings_expiry_one_day)), + PreferenceItem(item = 7.days.inWholeSeconds, title = stringResource(id = R.string.paymentsettings_expiry_one_week)), + PreferenceItem(item = 14.days.inWholeSeconds, title = stringResource(id = R.string.paymentsettings_expiry_two_weeks)), + PreferenceItem(item = 21.days.inWholeSeconds, title = stringResource(id = R.string.paymentsettings_expiry_three_weeks)), + ) + val expiry = userPrefs.getInvoiceDefaultExpiry.collectAsState(initial = null) + expiry.value?.let { expiry -> + ListPreferenceButton( + title = stringResource(id = R.string.paymentsettings_expiry_title), + subtitle = { + Text(text = when (expiry) { + 1.hours.inWholeSeconds -> stringResource(id = R.string.paymentsettings_expiry_one_hour) + 1.days.inWholeSeconds -> stringResource(id = R.string.paymentsettings_expiry_one_day) + 7.days.inWholeSeconds -> stringResource(id = R.string.paymentsettings_expiry_one_week) + 14.days.inWholeSeconds -> stringResource(id = R.string.paymentsettings_expiry_two_weeks) + 21.days.inWholeSeconds -> stringResource(id = R.string.paymentsettings_expiry_three_weeks) + else -> stringResource(id = R.string.paymentsettings_expiry_value, NumberFormat.getInstance().format(expiry)) + }) + }, + selectedItem = expiry, + preferences = preferences, + enabled = true, + onPreferenceSubmit = { + scope.launch { + userPrefs.saveInvoiceDefaultExpiry(it.item) + } } - } + ) } } 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 0fc734d25..2f1295cdd 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 @@ -75,7 +75,7 @@ class SwapInRefundViewModel( log.error("error when estimating swap-in refund fees: ", e) state = SwapInRefundState.Done.Failed.Error(e) }) { - when (val parseAddress = Parser.readBitcoinAddress(NodeParamsManager.chain, address)) { + when (val parseAddress = Parser.parseBip21Uri(NodeParamsManager.chain, address)) { is Either.Left -> { log.debug("invalid refund address=$address (${parseAddress.value}") state = SwapInRefundState.Done.Failed.InvalidAddress(address, parseAddress.value) diff --git a/phoenix-android/src/main/res/values-b+es+419/important_strings.xml b/phoenix-android/src/main/res/values-b+es+419/important_strings.xml index e7b66ae86..5e546b468 100644 --- a/phoenix-android/src/main/res/values-b+es+419/important_strings.xml +++ b/phoenix-android/src/main/res/values-b+es+419/important_strings.xml @@ -166,6 +166,8 @@ Error al procesar este enlace LNURL. Comprueba que sea válido. Este tipo de LNURL aún no es compatible. Estos datos usan un formato desconocido por lo que no se pueden procesar. + El nombre \"%1$s\" no se encuentra en \"%2$s\". + Esta dirección utiliza un recurso Bip21 no válido. Esta dirección utiliza una oferta Bolt12 no válida. Esta dirección está alojada en un DNS no seguro. DNSSEC debe estar habilitado. diff --git a/phoenix-android/src/main/res/values-b+es+419/strings.xml b/phoenix-android/src/main/res/values-b+es+419/strings.xml index 0205959ad..d2ba38f2b 100644 --- a/phoenix-android/src/main/res/values-b+es+419/strings.xml +++ b/phoenix-android/src/main/res/values-b+es+419/strings.xml @@ -597,7 +597,9 @@ Vencimiento de la factura Vencimiento de la factura Las facturas que crees caducan después de este plazo. El valor predeterminado es 1 semana. - 1 semana + 1 hora + 1 día + 1 semana (por defecto) 2 semanas 3 semanas %1$s segundos diff --git a/phoenix-android/src/main/res/values-cs/important_strings.xml b/phoenix-android/src/main/res/values-cs/important_strings.xml index 1e8058161..9f4a465c7 100644 --- a/phoenix-android/src/main/res/values-cs/important_strings.xml +++ b/phoenix-android/src/main/res/values-cs/important_strings.xml @@ -162,6 +162,8 @@ Tento LNURL odkaz se nepodařilo zpracovat. Ujistěte se, že je platný. Tento typ LNURL zatím není podporován. Tato data používají neznámý formát a nelze je zpracovat. + Název \"%1$s\" nebyl nalezen na \"%2$s\". + Tato adresa používá neplatný zdroj Bip21. Tato adresa používá neplatnou nabídku Bolt12. Tato adresa je hostována na nezabezpečeném DNS. DNSSEC musí být povolen. diff --git a/phoenix-android/src/main/res/values-cs/strings.xml b/phoenix-android/src/main/res/values-cs/strings.xml index b92730ea6..190f82ccb 100644 --- a/phoenix-android/src/main/res/values-cs/strings.xml +++ b/phoenix-android/src/main/res/values-cs/strings.xml @@ -604,7 +604,9 @@ Platnost faktury Platnost faktury Vytvořené faktury jsou po této prodlevě neaktuální. Výchozí hodnota je 1 týden. - 1 týden + 1 hodina + 1 den + 1 týden (výchozí) 2 týdny 3 týdny %1$s sekund diff --git a/phoenix-android/src/main/res/values-de/important_strings.xml b/phoenix-android/src/main/res/values-de/important_strings.xml index a1d1ec5c2..08c413c06 100644 --- a/phoenix-android/src/main/res/values-de/important_strings.xml +++ b/phoenix-android/src/main/res/values-de/important_strings.xml @@ -165,6 +165,8 @@ Dieser LNURL-Link konnte nicht verarbeitet werden. Stellen Sie sicher, dass er gültig ist. Dieser LNURL-Typ wird noch nicht unterstützt. Diese Daten haben ein unbekanntes Format und können nicht verarbeitet werden. + Name \"%1$s\" wird auf \"%2$s\" nicht gefunden. + Diese Adresse verwendet eine ungültige Bip21 Ressource. Diese Adresse verwendet ein ungültiges Bolt12-Angebot. Diese Adresse wird über einen unsicheren DNS gehostet. DNSSEC muss aktiviert sein. diff --git a/phoenix-android/src/main/res/values-de/strings.xml b/phoenix-android/src/main/res/values-de/strings.xml index 7993275a7..8a8cf9305 100644 --- a/phoenix-android/src/main/res/values-de/strings.xml +++ b/phoenix-android/src/main/res/values-de/strings.xml @@ -612,7 +612,9 @@ Verfallsfrist von Rechnungen Verfallsfrist von Rechnungen Rechnungen, die Sie erstellen, sind nach dieser Frist ungültig. Standardwert ist 1 Woche. - 1 Woche + 1 Stunde + 1 Tag + 1 Woche (Standard) 2 Wochen 3 Wochen %1$s Sekunden diff --git a/phoenix-android/src/main/res/values-es/important_strings.xml b/phoenix-android/src/main/res/values-es/important_strings.xml index 764a61234..b77e6643a 100644 --- a/phoenix-android/src/main/res/values-es/important_strings.xml +++ b/phoenix-android/src/main/res/values-es/important_strings.xml @@ -169,6 +169,8 @@ No se ha podido procesar este enlace LNURL. Asegúrese de que es válido. Este tipo de LNURL aún no se admite. Estos datos utilizan un formato desconocido y no pueden ser procesados. + El nombre \"%1$s\" no se encuentra en \"%2$s\". + Esta dirección utiliza un recurso Bip21 no válido. Esta dirección utiliza una oferta Bolt12 no válida. Esta dirección está alojada en un DNS no seguro. DNSSEC debe estar habilitado. diff --git a/phoenix-android/src/main/res/values-fr/important_strings.xml b/phoenix-android/src/main/res/values-fr/important_strings.xml index 317b6638e..52c827bbe 100644 --- a/phoenix-android/src/main/res/values-fr/important_strings.xml +++ b/phoenix-android/src/main/res/values-fr/important_strings.xml @@ -169,6 +169,8 @@ Ce lien LNURL n\'a pas pu être traité. Assurez-vous qu\'il soit valide. Ce type de lien LNURL n\'est pas supporté. Ce contenu est mal formatté ou bien n\'est pas géré par Phoenix. + Le nom \"%1$s\" est introuvable sur \"%2$s\" + Cette adresse utilise une ressource Bip21 non valide. Cette adresse utilise une offre Bolt12 invalide. Cette adresse est hébergée sur un DNS non sécurisé. DNSSEC doit être activé. diff --git a/phoenix-android/src/main/res/values-fr/strings.xml b/phoenix-android/src/main/res/values-fr/strings.xml index 73be77188..e8f9e90ab 100644 --- a/phoenix-android/src/main/res/values-fr/strings.xml +++ b/phoenix-android/src/main/res/values-fr/strings.xml @@ -627,10 +627,12 @@ Expiration des requêtes de paiement Expiration des requêtes Vos requêtes de paiements seront inactives une fois ce délai passé. Par défaut, 1 semaine. - 1 week - 2 weeks - 3 weeks - %1$s seconds + 1 heure + 1 jour + 1 semaine (défaut) + 2 semaines + 3 semaines + %1$s secondes Schéma d\'authentification LNURL diff --git a/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml b/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml index 27d04e913..75c5c5ed4 100644 --- a/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml +++ b/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml @@ -167,6 +167,8 @@ Falha ao processar esse link LNURL. Verifique se ele é válido. Este tipo de LNURL ainda não é suportado. Esses dados usam um formato desconhecido e não podem ser processados. + O nome \"%1$s\" não foi encontrado em \"%2$s\". + Este endereço usa um recurso Bip21 inválido. Este endereço usa uma oferta Bolt12 inválida. Este endereço está hospedado em um DNS não seguro. O DNSSEC deve estar ativado. diff --git a/phoenix-android/src/main/res/values-sk/important_strings.xml b/phoenix-android/src/main/res/values-sk/important_strings.xml index 7c875ec76..0b61efaa3 100644 --- a/phoenix-android/src/main/res/values-sk/important_strings.xml +++ b/phoenix-android/src/main/res/values-sk/important_strings.xml @@ -168,6 +168,8 @@ Tento LNURL odkaz sa nepodarilo spracovať. Uistite sa, že je platný. Tento typ LNURL zatiaľ nie je podporovaný. Tieto údaje používajú neznámy formát a nemožno ich spracovať. + Názov \"%1$s\" sa nenašiel na \"%2$s\". + Táto adresa používa neplatný zdroj Bip21. Táto adresa používa neplatnú ponuku Bolt12. Táto adresa je umiestnená na nezabezpečenom DNS. DNSSEC musí byť povolený. diff --git a/phoenix-android/src/main/res/values-sk/strings.xml b/phoenix-android/src/main/res/values-sk/strings.xml index bba0f99df..6fa13c7fa 100644 --- a/phoenix-android/src/main/res/values-sk/strings.xml +++ b/phoenix-android/src/main/res/values-sk/strings.xml @@ -654,7 +654,9 @@ Platnosť faktúry Platnosť faktúry Vytvorené faktúry sú po tejto lehote neaktuálne. Predvolená hodnota je 1 týždeň. - 1 týždeň + 1 hodina + 1 deň + 1 týždeň (predvolená) 2 týždne 3 týždne %1$s sekúnd diff --git a/phoenix-android/src/main/res/values-vi/important_strings.xml b/phoenix-android/src/main/res/values-vi/important_strings.xml index 0e4f6a226..a8a7d5224 100644 --- a/phoenix-android/src/main/res/values-vi/important_strings.xml +++ b/phoenix-android/src/main/res/values-vi/important_strings.xml @@ -175,6 +175,8 @@ Không thể xử lý liên kết LNURL này. Hãy đảm bảo liên kết này có hiệu lực. Loại LNURL này chưa được hỗ trợ. Dữ liệu này sử dụng định dạng không xác định và không thể xử lý được. + Không tìm thấy tên \"%1$s\" trên \"%2$s\". + Địa chỉ này sử dụng tài nguyên Bip21 không hợp lệ. Địa chỉ này sử dụng ưu đãi Bolt12 không hợp lệ. Địa chỉ này được lưu trữ trên một DNS không an toàn. DNSSEC phải được bật. diff --git a/phoenix-android/src/main/res/values-vi/strings.xml b/phoenix-android/src/main/res/values-vi/strings.xml index ccbea42fd..17b04421e 100644 --- a/phoenix-android/src/main/res/values-vi/strings.xml +++ b/phoenix-android/src/main/res/values-vi/strings.xml @@ -612,7 +612,9 @@ Hóa đơn hết hạn Hóa đơn hết hạn Hóa đơn bạn tạo đã hết hạn sau khoảng trì hoãn này. Thời hạn mặc định là 1 tuần. - 1 tuần + 1 giờ + 1 ngày + 1 tuần (mặc định) 2 tuần 3 tuần %1$s giây diff --git a/phoenix-android/src/main/res/values/important_strings.xml b/phoenix-android/src/main/res/values/important_strings.xml index d9a443e67..a67f3efc9 100644 --- a/phoenix-android/src/main/res/values/important_strings.xml +++ b/phoenix-android/src/main/res/values/important_strings.xml @@ -171,6 +171,8 @@ Failed to process this LNURL link. Make sure it is valid. This type of LNURL is not supported yet. This data uses an unknown format and cannot be processed. + Name \"%1$s\" is not found on \"%2$s\". + This address uses an invalid Bip21 resource. This address uses an invalid Bolt12 offer. This address is hosted on an unsecure DNS. DNSSEC must be enabled. diff --git a/phoenix-android/src/main/res/values/strings.xml b/phoenix-android/src/main/res/values/strings.xml index d2e2deb29..912f68681 100644 --- a/phoenix-android/src/main/res/values/strings.xml +++ b/phoenix-android/src/main/res/values/strings.xml @@ -678,7 +678,9 @@ Invoice expiry Invoice expiry Invoices that you create are stale after this delay. Default value is 1 week. - 1 week + 1 hour + 1 day + 1 week (default) 2 weeks 3 weeks %1$s seconds diff --git a/phoenix-legacy/build.gradle.kts b/phoenix-legacy/build.gradle.kts index cb753cb94..1a9202b6d 100644 --- a/phoenix-legacy/build.gradle.kts +++ b/phoenix-legacy/build.gradle.kts @@ -30,7 +30,7 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { - val libCode = 85 + val libCode = 86 getByName("debug") { resValue("string", "CHAIN", chain) buildConfigField("String", "CHAIN", chain) diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/config/CloseChannelsConfigurationController.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/config/CloseChannelsConfigurationController.kt index 48833bb1d..5cb5c9785 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/config/CloseChannelsConfigurationController.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/config/CloseChannelsConfigurationController.kt @@ -107,7 +107,7 @@ class AppCloseChannelsConfigurationController( override fun process(intent: CloseChannelsConfiguration.Intent) { val scriptPubKey = if (intent is CloseChannelsConfiguration.Intent.MutualCloseAllChannels) { try { - Parser.readBitcoinAddress(chain, intent.address).right!!.script + Parser.parseBip21Uri(chain, intent.address).right!!.script } catch (e: Exception) { throw IllegalArgumentException("Address is invalid. Caller MUST validate user input via readBitcoinAddress") } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Scan.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Scan.kt index 3e9b5f58a..6402ce47f 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Scan.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Scan.kt @@ -42,6 +42,8 @@ object Scan { data class ChainMismatch(val expected: Chain) : BadRequestReason() data class ServiceError(val url: Url, val error: LnurlError.RemoteFailure) : BadRequestReason() data class InvalidLnurl(val url: Url) : BadRequestReason() + data class Bip353NameNotFound(val username: String, val domain: String) : BadRequestReason() + data class Bip353InvalidUri(val path: String) : BadRequestReason() data class Bip353InvalidOffer(val path: String) : BadRequestReason() data class Bip353NoDNSSEC(val path: String) : BadRequestReason() data class UnsupportedLnurl(val url: Url) : BadRequestReason() 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 6af01f33c..02bb8ba18 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 @@ -19,7 +19,6 @@ package fr.acinq.phoenix.controllers.payments 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.Lightning import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.TrampolineFees @@ -576,35 +575,32 @@ class AppScanController( logger.debug { "dns resolved to ${json.toString().take(100)}" } val status = json["Status"]?.jsonPrimitive?.intOrNull + // could be a [BadRequestReason.Bip353NameNotFound] it status == 3 if (status == null || status > 0) return null + // check dnssec + val ad = json["AD"]?.jsonPrimitive?.booleanOrNull + if (ad != true) { + logger.debug { "AD false, abort dns lookup" } + throw Scan.BadRequestReason.Bip353NoDNSSEC(dnsPath) + } + + // check name matches records val records = json["Answer"]?.jsonArray if (records.isNullOrEmpty()) { - logger.debug { "no records for $dnsPath" } - return null + logger.debug { "no answer for $dnsPath" } + throw Scan.BadRequestReason.Bip353NameNotFound(username, domain) } val matchingRecord = records.filterIsInstance().firstOrNull { logger.debug { "inspecting record=$it" } it["name"]?.jsonPrimitive?.content == dnsPath - } ?: return null - - val ad = json["AD"]?.jsonPrimitive?.booleanOrNull - if (ad != true) { - logger.debug { "AD false, abort dns lookup" } - throw Scan.BadRequestReason.Bip353NoDNSSEC(dnsPath) - } - - val data = matchingRecord["data"]?.jsonPrimitive?.content ?: return null - if (!data.startsWith("bitcoin:")) throw Scan.BadRequestReason.Bip353InvalidOffer(dnsPath) - val offerString = data.substringAfter("lno=").substringBefore("?") - if (offerString.isBlank()) throw Scan.BadRequestReason.Bip353InvalidOffer(dnsPath) + } ?: throw Scan.BadRequestReason.Bip353NameNotFound(username, domain) - return when (val offer = OfferTypes.Offer.decode(offerString)) { - is Try.Success -> { offer.result } - is Try.Failure -> { - throw Scan.BadRequestReason.Bip353InvalidOffer(dnsPath) - } + val data = matchingRecord["data"]?.jsonPrimitive?.content ?: throw Scan.BadRequestReason.Bip353InvalidUri(dnsPath) + return when (val res = Parser.parseBip21Uri(chain, data)) { + is Either.Left -> throw Scan.BadRequestReason.Bip353InvalidUri(dnsPath) + is Either.Right -> res.value.offer ?: throw Scan.BadRequestReason.Bip353InvalidOffer(dnsPath) } } @@ -617,7 +613,7 @@ class AppScanController( /** Invokes `Parser.readBitcoinAddress`, but maps [BitcoinUriError.InvalidUri] to a null result instead of a fatal error. */ private fun readBitcoinAddress(input: String): Either? { - return when (val result = Parser.readBitcoinAddress(chain, input)) { + return when (val result = Parser.parseBip21Uri(chain, input)) { is Either.Left -> when (result.left) { is BitcoinUriError.InvalidUri -> null else -> result 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 2c809848c..59ae3c288 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 @@ -87,12 +87,12 @@ object Parser { } /** - * Parses an input and returns a [BitcoinUri] if it is valid, or a typed error otherwise. + * Parses an input and returns a bip-21 [BitcoinUri] if it is valid, or a typed error otherwise. * * @param chain the chain this parser expects the address to be valid on. * @param input can range from a basic bitcoin address to a sophisticated Bitcoin URI with a prefix and parameters. */ - fun readBitcoinAddress( + fun parseBip21Uri( chain: Chain, input: String ): Either { @@ -128,13 +128,13 @@ object Parser { val message = url.parameters["message"] val lightning = url.parameters["lightning"]?.let { when (val res = Bolt11Invoice.read(it)) { - is Try.Success -> res.get() + is Try.Success -> res.result is Try.Failure -> null } } val offer = url.parameters["lno"]?.let { when (val res = OfferTypes.Offer.decode(it)) { - is Try.Success -> res.get() + is Try.Success -> res.result is Try.Failure -> null } } @@ -160,6 +160,6 @@ object Parser { /** Transforms a bitcoin address into a public key script if valid, otherwise returns null. */ fun addressToPublicKeyScriptOrNull(chain: Chain, address: String): ByteVector? { - return readBitcoinAddress(chain, address).right?.script + return parseBip21Uri(chain, address).right?.script } } \ No newline at end of file diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/data/lnurl/LnurlPayTest.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/data/lnurl/LnurlPayTest.kt index 4e5fa6df8..c0ca30761 100644 --- a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/data/lnurl/LnurlPayTest.kt +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/data/lnurl/LnurlPayTest.kt @@ -117,8 +117,8 @@ class LnurlPayTest { ) } val client = fakeClient(engine) - val lnurl = Lnurl.extractLnurl("acinq@zbd.gg", logger) - assertIs(lnurl) + // TODO move to an email-like reader test with bip353 + val lnurl = Lnurl.Request(Url("https://zbd.gg/.well-known/lnurlp/acinq"), tag = Lnurl.Tag.Pay) val response: HttpResponse = client.get(lnurl.initialUrl) val json = Lnurl.processLnurlResponse(response, logger) assertEquals("payRequest", json.get("tag")!!.jsonPrimitive.content) 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 242e6130f..56f240d33 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 @@ -17,14 +17,13 @@ package fr.acinq.phoenix.utils -import fr.acinq.bitcoin.Bitcoin import fr.acinq.bitcoin.Chain import fr.acinq.bitcoin.BitcoinError import fr.acinq.bitcoin.ByteVector import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.payment.Bolt11Invoice -import fr.acinq.lightning.payment.PaymentRequest import fr.acinq.lightning.utils.sat +import fr.acinq.lightning.wire.OfferTypes import fr.acinq.phoenix.data.BitcoinUriError import fr.acinq.phoenix.data.BitcoinUri import io.ktor.http.* @@ -36,47 +35,47 @@ class ParserTest { @Test fun parse_bitcoin_uri_with_valid_addresses() { - assertIs>(Parser.readBitcoinAddress(Chain.Mainnet, "17VZNX1SN5NtKa8UQFxwQbFeFc3iqRYhem")) - assertIs>(Parser.readBitcoinAddress(Chain.Mainnet, "3EktnHQD7RiAE6uzMj2ZifT9YgRrkSgzQX")) - assertIs>(Parser.readBitcoinAddress(Chain.Mainnet, "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4")) - assertIs>(Parser.readBitcoinAddress(Chain.Mainnet, "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3")) + assertIs>(Parser.parseBip21Uri(Chain.Mainnet, "17VZNX1SN5NtKa8UQFxwQbFeFc3iqRYhem")) + assertIs>(Parser.parseBip21Uri(Chain.Mainnet, "3EktnHQD7RiAE6uzMj2ZifT9YgRrkSgzQX")) + assertIs>(Parser.parseBip21Uri(Chain.Mainnet, "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4")) + assertIs>(Parser.parseBip21Uri(Chain.Mainnet, "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3")) - assertIs>(Parser.readBitcoinAddress(Chain.Testnet, "mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn")) - assertIs>(Parser.readBitcoinAddress(Chain.Testnet, "2MzQwSSnBHWHqSAqtTVQ6v47XtaisrJa1Vc")) - assertIs>(Parser.readBitcoinAddress(Chain.Testnet, "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx")) - assertIs>(Parser.readBitcoinAddress(Chain.Testnet, "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7")) - assertIs>(Parser.readBitcoinAddress(Chain.Testnet, "tb1p607g5ea77m370pey3y5rg58fz7542hnpg40rs2cqw6w69yt5lf2qlktj2a")) + assertIs>(Parser.parseBip21Uri(Chain.Testnet, "mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn")) + assertIs>(Parser.parseBip21Uri(Chain.Testnet, "2MzQwSSnBHWHqSAqtTVQ6v47XtaisrJa1Vc")) + assertIs>(Parser.parseBip21Uri(Chain.Testnet, "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx")) + assertIs>(Parser.parseBip21Uri(Chain.Testnet, "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7")) + assertIs>(Parser.parseBip21Uri(Chain.Testnet, "tb1p607g5ea77m370pey3y5rg58fz7542hnpg40rs2cqw6w69yt5lf2qlktj2a")) } @Test fun parse_bitcoin_uri_chain_mismatch() { assertEquals( expected = Either.Left(BitcoinUriError.InvalidScript(error = BitcoinError.ChainHashMismatch)), - actual = Parser.readBitcoinAddress(Chain.Testnet, "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3") + actual = Parser.parseBip21Uri(Chain.Testnet, "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3") ) assertEquals( expected = Either.Left(BitcoinUriError.InvalidScript(error = BitcoinError.ChainHashMismatch)), - actual = Parser.readBitcoinAddress(Chain.Mainnet, "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx") + actual = Parser.parseBip21Uri(Chain.Mainnet, "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx") ) } @Test fun parse_bitcoin_uri_with_invalid_addresses() { assertIs>( - Parser.readBitcoinAddress(Chain.Mainnet, "17VZNX1SN5NtKa8UQFxwQbFeFc3iqRYhe") + Parser.parseBip21Uri(Chain.Mainnet, "17VZNX1SN5NtKa8UQFxwQbFeFc3iqRYhe") ) } @Test fun parse_bitcoin_uri_with_bitcoin_prefixes() { assertIs>( - Parser.readBitcoinAddress(Chain.Mainnet, "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") + Parser.parseBip21Uri(Chain.Mainnet, "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") ) assertIs>( - Parser.readBitcoinAddress(Chain.Mainnet, "bitcoin://bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") + Parser.parseBip21Uri(Chain.Mainnet, "bitcoin://bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") ) assertIs>( - Parser.readBitcoinAddress(Chain.Testnet, "bitcoin:?lno=lno1qgsyxjtl6luzd9t3pr62xr7eemp6awnejusgf6gw45q75vcfqqqqqqqsespexwyy4tcadvgg89l9aljus6709kx235hhqrk6n8dey98uyuftzdqrt2gkjvf2rj2vnt7m7chnmazen8wpur2h65ttgftkqaugy6ql9dcsyq39xc2g084xfn0s50zlh2ex22vvaqxqz3vmudklz453nns4d0624sqr8ux4p5usm22qevld4ydfck7hwgcg9wc3f78y7jqhc6hwdq7e9dwkhty3svq5ju4dptxtldjumlxh5lw48jsz6pnagtwrmeus7uq9rc5g6uddwcwldpklxexvlezld8egntua4gsqqy8auz966nksacdac8yv3maq6elp") + Parser.parseBip21Uri(Chain.Testnet, "bitcoin:?lno=lno1qgsyxjtl6luzd9t3pr62xr7eemp6awnejusgf6gw45q75vcfqqqqqqqsespexwyy4tcadvgg89l9aljus6709kx235hhqrk6n8dey98uyuftzdqrt2gkjvf2rj2vnt7m7chnmazen8wpur2h65ttgftkqaugy6ql9dcsyq39xc2g084xfn0s50zlh2ex22vvaqxqz3vmudklz453nns4d0624sqr8ux4p5usm22qevld4ydfck7hwgcg9wc3f78y7jqhc6hwdq7e9dwkhty3svq5ju4dptxtldjumlxh5lw48jsz6pnagtwrmeus7uq9rc5g6uddwcwldpklxexvlezld8egntua4gsqqy8auz966nksacdac8yv3maq6elp") ) } @@ -84,13 +83,13 @@ class ParserTest { fun parse_bitcoin_uri_with_non_bitcoin_prefixes() { // non-bitcoin prefixes are not trimmed, so error is invalid script assertIs>( - Parser.readBitcoinAddress(Chain.Mainnet, "btc:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") + Parser.parseBip21Uri(Chain.Mainnet, "btc:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") ) assertIs>( - Parser.readBitcoinAddress(Chain.Mainnet, "lightning:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") + Parser.parseBip21Uri(Chain.Mainnet, "lightning:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") ) assertIs>( - Parser.readBitcoinAddress(Chain.Mainnet, "lnurl://bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") + Parser.parseBip21Uri(Chain.Mainnet, "lnurl://bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") ) } @@ -120,7 +119,7 @@ class ParserTest { BitcoinUriError.UnhandledRequiredParams(parameters = listOf("req-somethingyoudontunderstand" to "50", "req-somethingelseyoudontget" to "999")) ), ).forEach { - assertEquals(it.second, Parser.readBitcoinAddress(Chain.Mainnet, it.first)) + assertEquals(it.second, Parser.parseBip21Uri(Chain.Mainnet, it.first)) } } @@ -146,7 +145,7 @@ class ParserTest { BitcoinUri(chain = Chain.Mainnet, address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6")) ), ).forEach { (address, expected) -> - val uri = Parser.readBitcoinAddress(Chain.Mainnet, address) + val uri = Parser.parseBip21Uri(Chain.Mainnet, address) assertEquals(expected, uri) } } @@ -224,7 +223,7 @@ class ParserTest { ) ) ).forEach { - assertEquals(it.second, Parser.readBitcoinAddress(Chain.Mainnet, it.first)) + assertEquals(it.second, Parser.parseBip21Uri(Chain.Mainnet, it.first)) } } @@ -264,4 +263,61 @@ class ParserTest { } } + @Test + fun parse_bitcoin_uri_with_offer_parameter() { + val offer = OfferTypes.Offer + .decode("lno1qgsyxjtl6luzd9t3pr62xr7eemp6awnejusgf6gw45q75vcfqqqqqqqsespexwyy4tcadvgg89l9aljus6709kx235hhqrk6n8dey98uyuftzdqzrtkahuum7m56dxlnx8r6tffy54004l7kvs7pylmxx7xs4n54986qyqeeuhhunayntt50snmdkq4t7fzsgghpl69v9csgparek8kv7dlp5uqr8ymp5s4z9upmwr2s8xu020d45t5phqc8nljrq8gzsjmurzevawjz6j6rc95xwfvnhgfx6v4c3jha7jwynecrz3y092nn25ek4yl7xp9yu9ry9zqagt0ktn4wwvqg52v9ss9ls22sqyqqestzp2l6decpn87pq96udsvx") + .get() + + val validUri = BitcoinUri( + chain = Chain.Mainnet, + address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6"), + offer = offer, + ) + val validFoobarUri = validUri + + listOf>>( + // valid offer uris + "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?lno=$offer" to Either.Right(validFoobarUri), + "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?lno=$offer&foo=bar" to Either.Right( + validFoobarUri.copy(ignoredParams = ParametersBuilder().apply { set("foo", "bar") }.build()) + ), + "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?foo=bar&lno=$offer" to Either.Right( + validFoobarUri.copy(ignoredParams = ParametersBuilder().apply { set("foo", "bar") }.build()) + ), + "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?foo=bar&lno=$offer&bar=baz" to Either.Right( + validFoobarUri.copy(ignoredParams = ParametersBuilder().apply { set("foo", "bar") ; set("bar", "baz") }.build()) + ), + // valid offer in a typical bip353 uri + "bitcoin:?sp=silentpayment&lno=$offer" to Either.Right( + BitcoinUri( + chain = Chain.Mainnet, + address = "", + script = null, + offer = offer, + ignoredParams = ParametersBuilder().apply { set("sp", "silentpayment") }.build() + ) + ), + // invalid offer parameter + "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?lightning=lno1qgsyxjtl6luzd9t3pr62xr7eemp6awnejusgf6gw45q75vcfqqqqqqqsespexwyy4tcadvgg89l9a" to Either.Right( + BitcoinUri( + chain = Chain.Mainnet, + address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6") + ) + ), + // empty offer invoice + "bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?lno=" to Either.Right( + BitcoinUri( + chain = Chain.Mainnet, + address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + script = ByteVector("0014751e76e8199196d454941c45d1b3a323f1433bd6") + ) + ), + ).forEach { (address, expected) -> + val uri = Parser.parseBip21Uri(Chain.Mainnet, address) + assertEquals(expected, uri) + } + } } \ No newline at end of file