From 0eb1faf247d03b43042940192328a68cd1b7bfd3 Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Fri, 3 Oct 2025 19:08:30 +0200 Subject: [PATCH 1/2] (android) support fiat offers Bolt12 offers may specify an amount, and that amount may be in a fiat currency. The wallet should be able to understand that, and convert the amount to btc, using the feerate fetched by the CurrencyManager. A new method has been added in CurrencyManager to fetch the feearate of a given fiat currency immediately (or use the cached value is cache is fresh). Since the conversion is not accurate, the user can change the amount to send, even use an amount below what Phoenix computed. The recipient may then reject the payment if it's too far from what it actually expects for the given fiat amount. --- .../components/inputs/AmountHeroInput.kt | 6 +- .../payments/receive/ReceiveLightningView.kt | 2 +- .../payments/receive/ReceiveViewModel.kt | 2 +- .../payments/send/offer/SendOfferView.kt | 127 +++++++++++++++++- .../src/main/res/drawable/ic_refresh.xml | 34 +++++ .../src/main/res/values/strings.xml | 10 ++ .../managers/PaymentMetadataQueue.kt | 2 +- .../managers/global/CurrencyManager.kt | 60 ++++++--- 8 files changed, 217 insertions(+), 26 deletions(-) create mode 100644 phoenix-android/src/main/res/drawable/ic_refresh.xml diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/inputs/AmountHeroInput.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/inputs/AmountHeroInput.kt index 03265903c..985b8346e 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/inputs/AmountHeroInput.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/inputs/AmountHeroInput.kt @@ -112,16 +112,16 @@ fun AmountHeroInput( val rate = rates[unit] if (rate != null) { initialAmount?.toFiat(rate.price).toPlainString(limitDecimal = true) - } else "?!" + } else context.getString(R.string.utils_unknown_amount) } is BitcoinUnit -> { initialAmount?.toUnit(u).toPlainString() } - else -> "?!" + else -> context.getString(R.string.utils_unknown_amount) } )) } - var inputAmount by remember { mutableStateOf(initialAmount) } + var inputAmount by remember(initialAmount) { mutableStateOf(initialAmount) } val convertedValue: String by remember(inputAmount, unit) { val s = when (unit) { is FiatCurrency -> { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveLightningView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveLightningView.kt index 495caf2d8..757c4f41b 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveLightningView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveLightningView.kt @@ -185,7 +185,7 @@ fun ColumnScope.LightningInvoiceView( if (state is LightningInvoiceState.Done) { Column(modifier = Modifier.padding(top = 2.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { if (state.description.isNullOrBlank() && state.amount == null) { - Text(text = "Add amount and description", style = MaterialTheme.typography.subtitle2) + Text(text = stringResource(id = R.string.receive_lightning_edit_button), style = MaterialTheme.typography.subtitle2) } state.description?.takeIf { it.isNotBlank() }?.let { desc -> QRCodeLabel(label = stringResource(R.string.receive_lightning_desc_label)) { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveViewModel.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveViewModel.kt index c3ebbcc18..9d3a2b8c2 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveViewModel.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveViewModel.kt @@ -65,7 +65,7 @@ sealed class LightningInvoiceState { data class Bolt12(val offer: OfferTypes.Offer) : Done() { override val paymentData by lazy { offer.encode() } override val description: String? by lazy { offer.description } - override val amount: MilliSatoshi? by lazy { offer.amount } + override val amount: MilliSatoshi? by lazy { offer.amountMsat } } } data class Error(val e: Throwable) : LightningInvoiceState() diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/offer/SendOfferView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/offer/SendOfferView.kt index 67ae41ba7..a15ade556 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/offer/SendOfferView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/offer/SendOfferView.kt @@ -31,6 +31,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -42,27 +43,63 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel +import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.MilliSatoshi import fr.acinq.lightning.wire.OfferTypes import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.android.LocalBitcoinUnits import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.WalletId +import fr.acinq.phoenix.android.application import fr.acinq.phoenix.android.components.inputs.AmountHeroInput import fr.acinq.phoenix.android.components.AmountWithFiatRowView import fr.acinq.phoenix.android.components.buttons.BackButtonWithActiveWallet import fr.acinq.phoenix.android.components.buttons.Clickable import fr.acinq.phoenix.android.components.buttons.FilledButton import fr.acinq.phoenix.android.components.ProgressView +import fr.acinq.phoenix.android.components.TextWithIcon import fr.acinq.phoenix.android.components.layouts.SplashLabelRow import fr.acinq.phoenix.android.components.layouts.SplashLayout import fr.acinq.phoenix.android.components.inputs.TextInput import fr.acinq.phoenix.android.components.buttons.SmartSpendButton import fr.acinq.phoenix.android.components.contact.ContactOrOfferView +import fr.acinq.phoenix.android.components.dialogs.Dialog import fr.acinq.phoenix.android.components.dialogs.ModalBottomSheet import fr.acinq.phoenix.android.components.feedback.ErrorMessage import fr.acinq.phoenix.android.payments.details.splash.translatePaymentError +import fr.acinq.phoenix.android.utils.annotatedStringResource +import fr.acinq.phoenix.android.utils.converters.AmountConverter.toMilliSatoshi +import fr.acinq.phoenix.android.utils.converters.AmountFormatter.toPlainString import fr.acinq.phoenix.android.utils.converters.AmountFormatter.toPrettyString +import fr.acinq.phoenix.android.utils.converters.DateFormatter.toAbsoluteDateTimeString +import fr.acinq.phoenix.data.ExchangeRate +import fr.acinq.phoenix.data.FiatCurrency +import kotlinx.coroutines.launch + + +sealed class InitialAmount { + abstract val amount: MilliSatoshi? + + data object None : InitialAmount() { override val amount = null } + + data class MilliSat(override val amount: MilliSatoshi): InitialAmount() + + sealed class Fiat : InitialAmount() { + abstract val value: Long + abstract val currencyCode: String? + data class ResolvingToMsat(override val value: Long, override val currencyCode: String) : Fiat() { + override val amount = null + } + data class ConvertedToMsat(override val value: Long, override val currencyCode: String, override val amount: MilliSatoshi, val rate: ExchangeRate.BitcoinPriceRate): Fiat() + data class CurrencyCodeUnsupported(override val value: Long, override val currencyCode: String): Fiat() { + override val amount = null + } + data class NoCurrencyInOffer(override val value: Long) : Fiat() { + override val amount = null + override val currencyCode = null + } + } +} @Composable fun SendToOfferView( @@ -79,16 +116,48 @@ fun SendToOfferView( val balance = business.balanceManager.balance.collectAsState(null).value val peer by business.peerManager.peerState.collectAsState() val trampolineFees = peer?.walletParams?.trampolineFees?.firstOrNull() + val currencyManager = application.phoenixGlobal.currencyManager val vm = viewModel(factory = SendOfferViewModel.Factory(offer, business.peerManager, business.nodeParamsManager, business.databaseManager), key = offer.encode()) - val requestedAmount = offer.amount - var amount by remember { mutableStateOf(requestedAmount) } + val requestedAmountState = produceState(initialValue = null, key1 = offer) { + value = when (val amountInOffer = offer.amount) { + is Either.Left -> InitialAmount.MilliSat(amountInOffer.value) + is Either.Right -> { + val currencyCode = offer.currency + if (currencyCode == null) { + InitialAmount.Fiat.NoCurrencyInOffer(value = amountInOffer.value) + } else { + val fiatCurrency = FiatCurrency.valueOfOrNull(currencyCode) + if (fiatCurrency == null) { + InitialAmount.Fiat.CurrencyCodeUnsupported(value = amountInOffer.value, currencyCode = currencyCode) + } else { + launch { + currencyManager.fetchRateForCurrency(fiatCurrency)?.let { rate -> + value = InitialAmount.Fiat.ConvertedToMsat( + value = amountInOffer.value, + currencyCode = currencyCode, + amount = amountInOffer.value.toDouble().toMilliSatoshi(rate.price), + rate = rate, + ) + } + } + InitialAmount.Fiat.ResolvingToMsat(value = amountInOffer.value, currencyCode = currencyCode) + } + } + } + null -> InitialAmount.None + } + } + val requestedAmount = requestedAmountState.value?.amount + + // the requested amount may be in fiat and needs to be converted first + var amount by remember(requestedAmount) { mutableStateOf(requestedAmount) } val amountErrorMessage: String = remember(amount) { val currentAmount = amount when { currentAmount == null -> "" balance != null && currentAmount > balance -> context.getString(R.string.send_error_amount_over_balance) - requestedAmount != null && currentAmount < requestedAmount -> context.getString( + requestedAmount != null && currentAmount < requestedAmount && requestedAmountState.value is InitialAmount.MilliSat -> context.getString( R.string.send_error_amount_below_requested, (requestedAmount).toPrettyString(prefBitcoinUnit, withUnit = true) ) @@ -112,6 +181,10 @@ fun SendToOfferView( validationErrorMessage = amountErrorMessage, inputTextSize = 42.sp, ) + when (val state = requestedAmountState.value) { + is InitialAmount.Fiat -> ConvertingFiatOfferAmount(state) + else -> Unit + } } ) { offer.description?.takeIf { it.isNotBlank() }?.let { @@ -248,3 +321,51 @@ private fun PayerNoteInput( Spacer(modifier = Modifier.height(80.dp)) } } + +@Composable +private fun ConvertingFiatOfferAmount(state: InitialAmount.Fiat) { + var showOfferInFiatDialog by remember { mutableStateOf(false) } + + Clickable(onClick = { showOfferInFiatDialog = true }, internalPadding = PaddingValues(8.dp), shape = RoundedCornerShape(12.dp)) { + TextWithIcon( + text = when (state) { + is InitialAmount.Fiat.ResolvingToMsat -> stringResource(R.string.send_offer_fiat_converting) + is InitialAmount.Fiat.CurrencyCodeUnsupported, is InitialAmount.Fiat.NoCurrencyInOffer, is InitialAmount.Fiat.ConvertedToMsat -> + stringResource(R.string.send_offer_fiat_main_label, "${state.value} ${state.currencyCode}") + }, + textStyle = MaterialTheme.typography.caption.copy(fontSize = 14.sp), + icon = when (state) { + is InitialAmount.Fiat.ResolvingToMsat -> R.drawable.ic_refresh + is InitialAmount.Fiat.CurrencyCodeUnsupported, is InitialAmount.Fiat.NoCurrencyInOffer, is InitialAmount.Fiat.ConvertedToMsat -> R.drawable.ic_info + }, + iconTint = MaterialTheme.typography.caption.color, + ) + } + + if (showOfferInFiatDialog) { + Dialog(onDismiss = { showOfferInFiatDialog = false }, buttons = null) { + Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp)) { + Text(text = stringResource(id = R.string.send_offer_fiat_main_label, "${state.value} ${state.currencyCode}"), style = MaterialTheme.typography.h4) + Spacer(Modifier.height(12.dp)) + when (state) { + is InitialAmount.Fiat.ResolvingToMsat -> { + Text(text = stringResource(id = R.string.send_offer_fiat_converting_desc, state.currencyCode)) + } + is InitialAmount.Fiat.CurrencyCodeUnsupported -> { + Text(text = stringResource(id = R.string.send_offer_fiat_err_unsupported, state.currencyCode)) + } + is InitialAmount.Fiat.NoCurrencyInOffer -> { + Text(text = stringResource(id = R.string.send_offer_fiat_err_malformed_invoice)) + } + is InitialAmount.Fiat.ConvertedToMsat -> { + Text(text = annotatedStringResource(id = R.string.send_offer_fiat_converted_to, state.amount.toPrettyString(unit = LocalBitcoinUnits.current.primary, withUnit = true))) + Spacer(Modifier.height(12.dp)) + Text(text = stringResource(id = R.string.send_offer_fiat_converted_rate_details, state.rate.price.toPlainString(), state.rate.fiatCurrency.name), style = MaterialTheme.typography.subtitle2) + Spacer(Modifier.height(2.dp)) + Text(text = stringResource(id = R.string.send_offer_fiat_converted_rate_timestamp, state.rate.timestampMillis.toAbsoluteDateTimeString()), style = MaterialTheme.typography.subtitle2) + } + } + } + } + } +} diff --git a/phoenix-android/src/main/res/drawable/ic_refresh.xml b/phoenix-android/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 000000000..a841d7952 --- /dev/null +++ b/phoenix-android/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/phoenix-android/src/main/res/values/strings.xml b/phoenix-android/src/main/res/values/strings.xml index a5083c492..9e64de957 100644 --- a/phoenix-android/src/main/res/values/strings.xml +++ b/phoenix-android/src/main/res/values/strings.xml @@ -143,6 +143,7 @@ This Lightning address uses the modern Bip353 standard that works with Bolt12 payment requests.\n\nIt is more private than LNURL-based Lightning addresses, and can even be self-hosted.\n\nHowever, it\'s is bleeding edge tech ; some wallets or services do not understand it yet and won\'t be able to pay you. Learn more. + Add amount and description Customise this Bolt11 invoice Customise this Bolt12 invoice Amount (optional) @@ -181,6 +182,15 @@ Payment has failed Could not retrieve payment details within a reasonable time.\n\nThe recipient may be offline or unreachable. + Invoice requests %1$s + Converting to BTC… + Retrieving %1$s + %1$s is not supported by Phoenix. You must convert the amount to BTC manually. + This invoice is malformed and misses a fiat currency code. + Phoenix converted that to %1$s, but you may adjust that value. + Rate: %1$s %2$s/BTC + Refreshed: %1$s + Pay on-chain On-chain transactions are typically slower and best suited for large payments. Pay with Lightning diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentMetadataQueue.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentMetadataQueue.kt index 0a6da06e4..58fb9a266 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentMetadataQueue.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentMetadataQueue.kt @@ -57,7 +57,7 @@ class PaymentMetadataQueue( internal fun enrichPaymentMetadata(metadata: WalletPaymentMetadata?): WalletPaymentMetadata { val metadataOrDefault = metadata ?: WalletPaymentMetadata() return if (metadataOrDefault.originalFiat == null) { - val currentFiatRate = appConfigurationManager.preferredFiatCurrencies.value?.let { currencyManager.calculateOriginalFiat(it.primary) } + val currentFiatRate = appConfigurationManager.preferredFiatCurrencies.value?.let { currencyManager.getSnapshotRateForCurrency(it.primary) } metadataOrDefault.copy(originalFiat = currentFiatRate) } else metadataOrDefault } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/global/CurrencyManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/global/CurrencyManager.kt index 256649cb4..9642f10db 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/global/CurrencyManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/global/CurrencyManager.kt @@ -19,6 +19,7 @@ package fr.acinq.phoenix.managers.global import fr.acinq.lightning.logging.LoggerFactory import fr.acinq.lightning.logging.debug import fr.acinq.lightning.logging.info +import fr.acinq.lightning.utils.currentTimestampMillis import fr.acinq.phoenix.data.ExchangeRate import fr.acinq.phoenix.data.FiatCurrency import fr.acinq.phoenix.data.PreferredFiatCurrencies @@ -96,6 +97,15 @@ class CurrencyManager( ) } + @OptIn(ExperimentalCoroutinesApi::class) + val usdRate: StateFlow = ratesFlow.mapLatest { + it.filterIsInstance().firstOrNull { it.fiatCurrency == FiatCurrency.USD } + }.stateIn( + scope = scope, + started = SharingStarted.Companion.Eagerly, + initialValue = null + ) + private var refreshList = mutableMapOf() private var autoRefreshJob: Job? = null @@ -214,29 +224,45 @@ class CurrencyManager( } /** - * Returns a snapshot of the ExchangeRate for the primary FiatCurrency. - * That is, an instance of OriginalFiat, where: - * - type => current primary FiatCurrency (via AppConfigurationManager) - * - price => BitcoinPriceRate.price for FiatCurrency type + * Fetches the BTC exchange rate for the given [fiatCurrency] from the adequate API. May return null. This method should not be called + * repeatedly as it makes an HTTP request every time. To monitor a currency on the long run, use [startMonitoringCurrencies] instead. */ - fun calculateOriginalFiat(currency: FiatCurrency): ExchangeRate.BitcoinPriceRate? { + suspend fun fetchRateForCurrency(fiatCurrency: FiatCurrency): ExchangeRate.BitcoinPriceRate? { val rates = ratesFlow.value - val fiatRate = rates.firstOrNull { it.fiatCurrency == currency } ?: return null + val now = currentTimestampMillis() + val knownMatch = rates.firstOrNull { it.fiatCurrency == fiatCurrency && (now - it.timestampMillis) < 10.minutes.inWholeMilliseconds } + if (knownMatch != null) { + log.debug { "returning cached rate for ${fiatCurrency.name}" } + return toBitcoinExchangeRate(knownMatch) + } + + val api = listOf(coinbaseAPI, blockchainInfoAPI, yadioAPI, bluelyticsAPI) + .firstOrNull { it.fiatCurrencies.contains(fiatCurrency) } ?: return null + + val target = setOf(fiatCurrency) + log.debug { "fetching rate for ${fiatCurrency.name}" } + val rate = api.fetch(target).firstOrNull { it.fiatCurrency == fiatCurrency } ?: return null + updateRefreshList(api = api, attempted = target, refreshed = target) + appDb.saveExchangeRates(listOf(rate)) + return toBitcoinExchangeRate(rate) + } + + /** Returns a snapshot of [currency] in [ratesFlow], converted to a [ExchangeRate.BitcoinPriceRate]. */ + fun getSnapshotRateForCurrency(currency: FiatCurrency): ExchangeRate.BitcoinPriceRate? { + val fiatRate = ratesFlow.value.firstOrNull { it.fiatCurrency == currency } ?: return null + return toBitcoinExchangeRate(fiatRate) + } + + /** Utility method that converts a generic exchange rate to a Bitcoin price rate, using the USD rate when needed. This is needed because + * the exchange rate for many currency is in USD, and not BTC! */ + private fun toBitcoinExchangeRate(fiatRate: ExchangeRate): ExchangeRate.BitcoinPriceRate? { return when (fiatRate) { - is ExchangeRate.BitcoinPriceRate -> { - // We have a direct exchange rate. - // BitcoinPriceRate.rate => The price of 1 BTC in this currency - fiatRate - } + is ExchangeRate.BitcoinPriceRate -> fiatRate is ExchangeRate.UsdPriceRate -> { - // We have an indirect exchange rate. - // UsdPriceRate.price => The price of 1 US Dollar in this currency - rates.filterIsInstance().firstOrNull { - it.fiatCurrency == FiatCurrency.USD - }?.let { usdRate -> + usdRate.value?.let { usdRate -> ExchangeRate.BitcoinPriceRate( - fiatCurrency = currency, + fiatCurrency = fiatRate.fiatCurrency, price = usdRate.price * fiatRate.price, source = "${fiatRate.source}/${usdRate.source}", timestampMillis = fiatRate.timestampMillis.coerceAtMost( From 7fe3d4902f50d76fa2f5acf827bb7f9377c2f052 Mon Sep 17 00:00:00 2001 From: Dominique Padiou <5765435+dpad85@users.noreply.github.com> Date: Mon, 6 Oct 2025 18:47:18 +0200 Subject: [PATCH 2/2] (android) Simplify ui when retrieving exchange rate --- .../android/payments/send/offer/SendOfferView.kt | 11 ++--------- phoenix-android/src/main/res/values/strings.xml | 3 +-- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/offer/SendOfferView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/offer/SendOfferView.kt index a15ade556..322531096 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/offer/SendOfferView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/send/offer/SendOfferView.kt @@ -328,16 +328,9 @@ private fun ConvertingFiatOfferAmount(state: InitialAmount.Fiat) { Clickable(onClick = { showOfferInFiatDialog = true }, internalPadding = PaddingValues(8.dp), shape = RoundedCornerShape(12.dp)) { TextWithIcon( - text = when (state) { - is InitialAmount.Fiat.ResolvingToMsat -> stringResource(R.string.send_offer_fiat_converting) - is InitialAmount.Fiat.CurrencyCodeUnsupported, is InitialAmount.Fiat.NoCurrencyInOffer, is InitialAmount.Fiat.ConvertedToMsat -> - stringResource(R.string.send_offer_fiat_main_label, "${state.value} ${state.currencyCode}") - }, + text = stringResource(R.string.send_offer_fiat_main_label, "${state.value} ${state.currencyCode}"), textStyle = MaterialTheme.typography.caption.copy(fontSize = 14.sp), - icon = when (state) { - is InitialAmount.Fiat.ResolvingToMsat -> R.drawable.ic_refresh - is InitialAmount.Fiat.CurrencyCodeUnsupported, is InitialAmount.Fiat.NoCurrencyInOffer, is InitialAmount.Fiat.ConvertedToMsat -> R.drawable.ic_info - }, + icon = R.drawable.ic_refresh, iconTint = MaterialTheme.typography.caption.color, ) } diff --git a/phoenix-android/src/main/res/values/strings.xml b/phoenix-android/src/main/res/values/strings.xml index 9e64de957..f22e86627 100644 --- a/phoenix-android/src/main/res/values/strings.xml +++ b/phoenix-android/src/main/res/values/strings.xml @@ -183,8 +183,7 @@ Could not retrieve payment details within a reasonable time.\n\nThe recipient may be offline or unreachable. Invoice requests %1$s - Converting to BTC… - Retrieving %1$s + Retrieving %1$s/BTC feerate… %1$s is not supported by Phoenix. You must convert the amount to BTC manually. This invoice is malformed and misses a fiat currency code. Phoenix converted that to %1$s, but you may adjust that value.