diff --git a/src/i18n_messages/en.json b/src/i18n_messages/en.json index b8151c3e..9007c372 100644 --- a/src/i18n_messages/en.json +++ b/src/i18n_messages/en.json @@ -311,6 +311,11 @@ "onchainTransferScreen_feeTierSubtext": "About %{blocks} blocks · up to %{amount} %{currency}", "onchainTransferScreen_memoFromPayee": "Payment description", "onchainTransferScreen_networkFee": "Network fee", + "onchainTransferScreen_noMintTitle": "No mint to pay from", + "onchainTransferScreen_noMintDesc": "You have no mint holding this currency. Add one and top it up before sending Bitcoin.", + "onchainTransferScreen_noOnchainMintTitle": "No mint can pay Bitcoin addresses", + "onchainTransferScreen_noOnchainMintDesc": "None of your mints holding this balance supports Bitcoin payouts. Add a mint that does, or pay with Lightning instead.", + "onchainTransferScreen_mintNoOnchainSupport": "This mint does not support Bitcoin payouts. Select a different mint.", "onchainTransferScreen_noFeeOptions": "The mint returned no fee options for this payment and it can not be paid.", "onchainTransferScreen_requestQuote": "Continue", "onchainTransferScreen_selectFee": "Confirmation speed", diff --git a/src/i18n_messages/es.json b/src/i18n_messages/es.json index 4b9ac339..b207f45e 100644 --- a/src/i18n_messages/es.json +++ b/src/i18n_messages/es.json @@ -310,6 +310,11 @@ "onchainTransferScreen_feeTierSubtext": "Unos %{blocks} bloques · hasta %{amount} %{currency}", "onchainTransferScreen_memoFromPayee": "Descripción del pago", "onchainTransferScreen_networkFee": "Tarifa de red", + "onchainTransferScreen_noMintTitle": "No hay ninguna casa de cambio desde la que pagar", + "onchainTransferScreen_noMintDesc": "No tienes ninguna casa de cambio con esta moneda. Añade una y recárgala antes de enviar Bitcoin.", + "onchainTransferScreen_noOnchainMintTitle": "Ninguna casa de cambio puede pagar a direcciones Bitcoin", + "onchainTransferScreen_noOnchainMintDesc": "Ninguna de tus casas de cambio con este saldo admite pagos en Bitcoin. Añade una que lo admita o paga con Lightning.", + "onchainTransferScreen_mintNoOnchainSupport": "Esta casa de cambio no admite pagos en Bitcoin. Selecciona otra.", "onchainTransferScreen_noFeeOptions": "La casa de cambio no devolvió opciones de tarifa para este pago y no se puede pagar.", "onchainTransferScreen_requestQuote": "Continuar", "onchainTransferScreen_selectFee": "Velocidad de confirmación", diff --git a/src/i18n_messages/pt.json b/src/i18n_messages/pt.json index a1c8e944..4e6d6846 100644 --- a/src/i18n_messages/pt.json +++ b/src/i18n_messages/pt.json @@ -311,6 +311,11 @@ "onchainTransferScreen_feeTierSubtext": "Cerca de %{blocks} blocos · até %{amount} %{currency}", "onchainTransferScreen_memoFromPayee": "Descrição do pagamento", "onchainTransferScreen_networkFee": "Taxa de rede", + "onchainTransferScreen_noMintTitle": "Nenhuma casa da moeda para pagar", + "onchainTransferScreen_noMintDesc": "Não tem nenhuma casa da moeda com esta moeda. Adicione uma e carregue-a antes de enviar Bitcoin.", + "onchainTransferScreen_noOnchainMintTitle": "Nenhuma casa da moeda pode pagar endereços Bitcoin", + "onchainTransferScreen_noOnchainMintDesc": "Nenhuma das suas casas da moeda com este saldo suporta pagamentos em Bitcoin. Adicione uma que suporte, ou pague com Lightning.", + "onchainTransferScreen_mintNoOnchainSupport": "Esta casa da moeda não suporta pagamentos em Bitcoin. Selecione outra.", "onchainTransferScreen_noFeeOptions": "A casa da moeda não devolveu opções de taxa para este pagamento e não pode ser pago.", "onchainTransferScreen_requestQuote": "Continuar", "onchainTransferScreen_selectFee": "Velocidade de confirmação", diff --git a/src/i18n_messages/sk.json b/src/i18n_messages/sk.json index edf19366..5bf039b0 100644 --- a/src/i18n_messages/sk.json +++ b/src/i18n_messages/sk.json @@ -311,6 +311,11 @@ "onchainTransferScreen_feeTierSubtext": "Približne %{blocks} blokov · až do %{amount} %{currency}", "onchainTransferScreen_memoFromPayee": "Popis platby", "onchainTransferScreen_networkFee": "Sieťový poplatok", + "onchainTransferScreen_noMintTitle": "Žiadna mincovňa, z ktorej platiť", + "onchainTransferScreen_noMintDesc": "Nemáte mincovňu s touto menou. Pridajte si ju a dobite ju skôr, než odošlete Bitcoin.", + "onchainTransferScreen_noOnchainMintTitle": "Žiadna mincovňa nedokáže platiť na Bitcoin adresy", + "onchainTransferScreen_noOnchainMintDesc": "Žiadna z vašich mincovní s týmto zostatkom nepodporuje Bitcoin výplaty. Pridajte mincovňu, ktorá ich podporuje, alebo zaplaťte cez Lightning.", + "onchainTransferScreen_mintNoOnchainSupport": "Táto mincovňa nepodporuje Bitcoin výplaty. Vyberte inú mincovňu.", "onchainTransferScreen_noFeeOptions": "Mincovňa nevrátila žiadne možnosti poplatku pre túto platbu, nedá sa zaplatiť.", "onchainTransferScreen_requestQuote": "Pokračovať", "onchainTransferScreen_selectFee": "Rýchlosť potvrdenia", diff --git a/src/screens/OnchainTransferScreen.tsx b/src/screens/OnchainTransferScreen.tsx index f582b1ea..26fc7b38 100644 --- a/src/screens/OnchainTransferScreen.tsx +++ b/src/screens/OnchainTransferScreen.tsx @@ -69,7 +69,7 @@ import {TranItem} from './TranDetailScreen' * whatever the user picks — a fabricated index would be rejected by the mint (NUT-30 * requires it). So the CHOICE is simulated; the payment underneath is real. */ -const MOCK_FEE_TIERS = true +const MOCK_FEE_TIERS = false type Props = StaticScreenProps<{ address: string @@ -264,7 +264,7 @@ export const OnchainTransferScreen = observer(function OnchainTransferScreen({ro useEffect(() => { try { - const {unit, mintUrl, amountSat, memo: bip21Memo} = route.params + const {unit, amountSat, memo: bip21Memo} = route.params if (!unit) { throw new AppError(Err.VALIDATION_ERROR, translate('missingMintUnitRouteParamsError')) } @@ -280,11 +280,19 @@ export const OnchainTransferScreen = observer(function OnchainTransferScreen({ro } if (bip21Memo) setMemo(bip21Memo) - const balance = mintUrl - ? proofsStore.getMintBalance(mintUrl) - : proofsStore.getMintBalancesWithUnit(unit)[0] - - if (balance) dispatch({type: 'SET_MINT_BALANCE', balance}) + // Refresh capabilities for every mint that could conceivably pay this, BEFORE + // deciding any of them cannot. + // + // `supportsMelt` reads cached mintInfo, and a mint whose info we have never + // fetched reports `hasUnknownCapabilities` — which we resolve as "assume bolt11", + // i.e. NOT onchain. So without this, a mint that genuinely supports onchain melt + // would be declared incapable purely because we had not asked it lately. getMint() + // is what triggers the stale-info refresh (see WalletStore.refreshMintInfoIfStale); + // it is fire-and-forget, and the screen is an observer, so the lists below + // recompute when the answers land. + for (const balance of proofsStore.getMintBalancesWithUnit(unit)) { + void walletStore.getMint(balance.mintUrl) + } if (!isInternetReachable) dispatch({type: 'SET_INFO', message: translate('commonOfflinePretty')}) } catch (e: any) { @@ -297,6 +305,62 @@ export const OnchainTransferScreen = observer(function OnchainTransferScreen({ro const handleError = (e: AppError) => dispatch({type: 'SET_ERROR', error: e}) + /** + * The mints that can actually settle this payment: they hold a balance in this unit AND + * advertise onchain melt for it. + * + * Computed rather than assumed. Picking a mint first and discovering it cannot pay a + * Bitcoin address only when the mint answers "not found" is how this screen used to + * behave, and the user got a bare 404 for a decision the wallet already had the + * information to make. + */ + const mintBalancesWithUnit = proofsStore.getMintBalancesWithUnit(unitRef.current) + + const onchainMintBalances = mintBalancesWithUnit.filter(balance => { + const mint = mintsStore.findByUrl(balance.mintUrl) + return !!mint?.supportsMelt!('onchain', unitRef.current) + }) + + /** + * Why this payment cannot be made, if it cannot — kept as two distinct answers. + * + * "You have no mint holding this currency" and "none of your mints can send Bitcoin + * onchain" are different problems with different fixes, and collapsing them into one + * message would send the user off to solve the wrong one. + */ + const blockingReason: 'no-mint' | 'no-onchain-mint' | undefined = + mintBalancesWithUnit.length === 0 + ? 'no-mint' + : onchainMintBalances.length === 0 + ? 'no-onchain-mint' + : undefined + + const hasNoOnchainMint = !!blockingReason + + /** + * Settle on a mint once we know which ones can pay. + * + * The mint carried in from the Pay screen is only a suggestion — it is whichever mint + * the user happened to be looking at, and nothing about scanning a Bitcoin address says + * it can melt onchain. If it cannot, silently fall through to one that can rather than + * making the user discover the problem and fix it themselves. + */ + useEffect(() => { + if (hasNoOnchainMint) return + if (mintBalanceToTransferFrom) { + const stillCapable = onchainMintBalances.some( + b => b.mintUrl === mintBalanceToTransferFrom.mintUrl, + ) + if (stillCapable) return + } + + const preferred = + onchainMintBalances.find(b => b.mintUrl === route.params.mintUrl) ?? + onchainMintBalances[0] + + dispatch({type: 'SET_MINT_BALANCE', balance: preferred}) + }, [onchainMintBalances.length, mintBalanceToTransferFrom?.mintUrl, hasNoOnchainMint]) + const selectedMint = mintBalanceToTransferFrom ? mintsStore.findByUrl(mintBalanceToTransferFrom.mintUrl) : undefined @@ -333,6 +397,18 @@ export const OnchainTransferScreen = observer(function OnchainTransferScreen({ro return } + // Last line of defence. The mint selector already refuses an incapable mint and + // the screen refuses to render this button when none can pay, but asking a + // bolt11-only mint for an onchain melt quote gets a bare "not found" back — a + // 404 for a question the wallet had every means to answer itself. + if (!selectedMint?.supportsMelt!('onchain', unitRef.current)) { + dispatch({ + type: 'SET_INFO', + message: translate('onchainTransferScreen_mintNoOnchainSupport'), + }) + return + } + const {precision, code: currencyCode} = getCurrency(unitRef.current) const amountUnit = round(toNumber(amountToTransfer) * precision, 0) @@ -585,7 +661,7 @@ export const OnchainTransferScreen = observer(function OnchainTransferScreen({ro }} selectTextOnFocus={true} unit={unitRef.current} - editable={!hasQuote && !isSettled} + editable={!hasQuote && !isSettled && !hasNoOnchainMint} style={{color: amountInputColor}} /> @@ -694,7 +770,53 @@ export const OnchainTransferScreen = observer(function OnchainTransferScreen({ro /> )} - {!hasQuote && !isSettled && ( + {/* + * No mint can pay a Bitcoin address. + * + * Said here, plainly, instead of letting the user set an amount, press + * Continue, and receive the mint's bare "not found" — an error about a + * question the wallet already had the information to answer. There is no + * Continue button at all in this state: nothing the user can type makes it + * work, and offering the action anyway is what turns a limitation into a + * bug report. + */} + {hasNoOnchainMint && !isSettled && ( + <> + + } + style={$item} + /> + } + /> + + +