Stage 6b: onchain melt UI — Pay screen, address parsing, fee picker

LightningPayScreen becomes PayScreen: it can no longer call itself Lightning
now that it also takes Bitcoin addresses. Paste, auto-paste and scan all route
through the same parser.

New OnchainTransferScreen rather than a third mode inside TransferScreen. The
lightning screen is built around a payment request that already carries its
amount and a quote it can fetch as soon as a mint is chosen; onchain has neither
(the user types the amount, and the quote returns a LIST of fee tiers). Folding
that in would have meant re-auditing every `!encodedInvoice &&` render gate on a
screen where a mistake breaks lightning payments. The SERVICE layer is still a
single shared TransferOperationApi - this is a screen, not a second
implementation.

Address handling (src/services/bitcoin/bitcoinUtils.ts):

- Checksums verified locally, so a mistyped address fails on the screen the user
  is looking at rather than one round-trip later. Witness version selects the
  checksum constant - v0 must be bech32, v1+ bech32m - and accepting either for
  both would let a corrupted address through whenever it satisfied the other.

- MAINNET ONLY, and this is the point rather than a detail. The CDK fakewallet
  hands out REGTEST deposit addresses for onchain topup quotes, so anyone testing
  this wallet has a bcrt1q... in their clipboard - the single most likely wrong
  thing to be pasted into Pay. It is decoded (so we can say "wrong network"
  rather than "unknown data") and then refused. Pinned in tests with the exact
  address the fakewallet produced.

- Unified QR: LIGHTNING WINS. A bitcoin: URI carrying a lightning= invoice
  offers both rails and lightning is cheaper and faster for everyone, so the
  invoice is taken and the address goes unused.

Fee tiers default to the MIDDLE one, rounding to the cheaper side: the user can
always choose to pay more, but a wallet must never round a fee up on their
behalf. The picker collapses to a read-only row when the mint returns one tier -
which is the common case, not the edge case. Tiers are sorted before selection
because fee_index is the mint's IDENTIFIER for a tier, not its rank.

PENDING is styled as the SUCCESS state here, not a warning: NUT-30 requires the
mint to answer PENDING and broadcast in the background, so every onchain payment
lands there. The money has left; only confirmation is outstanding. The
transaction detail surfaces the outpoint (txid:vout) once broadcast - the only
handle on the payment that does not depend on the mint - and offers a manual
status check, since the sweep only runs on the ~60s pending cadence.

87 tsc errors (unchanged baseline), 318/318 tests, i18n clean.
This commit is contained in:
minibits-cash
2026-07-14 18:05:28 +02:00
parent 6b5b1da356
commit 183ef1cf56
13 changed files with 1456 additions and 64 deletions
+173
View File
@@ -0,0 +1,173 @@
/**
* The transfer-method resolver: the one place the two melt rails differ.
*
* `TransferOperationApi` reads everything rail-specific from here instead of branching
* on `method` at each site that needs a fee or an expiry, so this is where a mistake
* about a rail would actually live. Worth pinning:
*
* - where the fee reserve comes from (a field on the quote vs the SELECTED tier),
* - that an onchain transfer is never expirable,
* - that a fee_index the mint never offered is refused before it reaches the mint.
*
* @jest-environment node
*/
// Transaction.ts -> services -> logService -> @sentry/react-native (ESM, not transformed)
// and on into the MST store graph + op-sqlite. Mock the chain so the test stays pure,
// same as transactionStates.test.ts.
jest.mock('../src/services/logService', () => ({
log: {
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
trace: jest.fn(),
warn: jest.fn(),
},
LogLevel: {ERROR: 'ERROR', WARN: 'WARN', INFO: 'INFO', DEBUG: 'DEBUG', TRACE: 'TRACE'},
}))
jest.mock('../src/services', () => ({
log: {
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
trace: jest.fn(),
warn: jest.fn(),
},
Database: {},
}))
import {resolveTransferMethod} from '../src/services/wallet/operations/transferMethods'
import {TransactionType} from '../src/models/Transaction'
/** cashu-ts hands amounts over as Amount objects, not numbers. */
const amount = (n: number) => ({toNumber: () => n}) as any
const onchainQuote = (feeOptions: Array<[number, number, number]>) =>
({
quote: 'quote-onchain-1',
amount: amount(50000),
unit: 'sat',
state: 'UNPAID',
expiry: 1800000000,
request: 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4',
fee_options: feeOptions.map(([fee_index, fee_reserve, estimated_blocks]) => ({
fee_index,
fee_reserve: amount(fee_reserve),
estimated_blocks,
})),
selected_fee_index: null,
outpoint: null,
}) as any
describe('resolveTransferMethod — onchain', () => {
const QUOTE_EXPIRY = new Date('2027-01-01T00:00:00Z')
const ADDRESS = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'
const resolveOnchain = (feeIndex: number, feeOptions: Array<[number, number, number]>) =>
resolveTransferMethod({
method: 'onchain',
options: {
address: ADDRESS,
meltQuote: onchainQuote(feeOptions),
feeIndex,
quoteExpiry: QUOTE_EXPIRY,
},
})
it('records the onchain transaction type', () => {
const resolved = resolveOnchain(0, [[0, 400, 6]])
expect(resolved.transactionType).toBe(TransactionType.TRANSFER_ONCHAIN)
expect(resolved.method).toBe('onchain')
})
// An onchain quote has NO fee_reserve field — it has a list of tiers, and the reserve
// depends entirely on which one the user picked.
it('takes the fee reserve from the SELECTED tier', () => {
const tiers: Array<[number, number, number]> = [
[0, 400, 6],
[1, 900, 3],
[2, 2100, 1],
]
expect(resolveOnchain(0, tiers).feeReserve).toBe(400)
expect(resolveOnchain(1, tiers).feeReserve).toBe(900)
expect(resolveOnchain(2, tiers).feeReserve).toBe(2100)
})
// fee_index is the mint's identifier for a tier, not its position in the array.
it('selects by the mint\'s fee_index, not by array position', () => {
const tiers: Array<[number, number, number]> = [
[7, 2100, 1],
[3, 400, 6],
]
expect(resolveOnchain(3, tiers).feeReserve).toBe(400)
expect(resolveOnchain(7, tiers).feeReserve).toBe(2100)
})
// The mint MUST reject a fee_index it never offered, so catching it locally saves a
// round-trip and a quote that can then never be executed with a different index.
it('refuses a fee_index the mint never offered', () => {
expect(() => resolveOnchain(5, [[0, 400, 6]])).toThrow(/Fee index 5 was not offered/)
})
/**
* THE rule that keeps a real payment from being declared dead.
*
* A melt quote's expiry bounds EXECUTING the quote, not CONFIRMING the payment. Once
* the mint has broadcast, the transaction confirms on the chain's schedule and can
* easily outlive the quote it came from. If anything ever expires a pending onchain
* transfer on the strength of this date, it marks an irreversible, in-flight payment
* as failed and hides it from the user.
*/
it('is never expirable once pending', () => {
expect(resolveOnchain(0, [[0, 400, 6]]).expiresPendingTransfer).toBe(false)
})
it('carries the address as the payment request and has no payment id', () => {
const resolved = resolveOnchain(0, [[0, 400, 6]])
expect(resolved.paymentRequest).toBe(ADDRESS)
// Onchain has no preimage-style identifier. It gets an `outpoint` once broadcast.
expect(resolved.paymentId).toBeUndefined()
})
it('passes the quote id and expiry through', () => {
const resolved = resolveOnchain(0, [[0, 400, 6]])
expect(resolved.quoteId).toBe('quote-onchain-1')
expect(resolved.expiry).toBe(QUOTE_EXPIRY)
})
})
describe('resolveTransferMethod — bolt11', () => {
// BOLT11 spec test vector (2500 µBTC). Must actually decode — resolveTransferMethod
// derives the payment hash from it.
const INVOICE =
'lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpuaztrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9srp'
const invoiceExpiry = new Date('2027-01-01T00:00:00Z')
const bolt11Quote = {
quote: 'quote-bolt11-1',
amount: amount(1000),
unit: 'sat',
state: 'UNPAID',
expiry: 1800000000,
request: INVOICE,
fee_reserve: amount(12),
payment_preimage: null,
} as any
it('takes the fee reserve straight off the quote and stays expirable', () => {
const resolved = resolveTransferMethod({
method: 'bolt11',
options: {encodedInvoice: INVOICE, meltQuote: bolt11Quote, invoiceExpiry},
})
expect(resolved.method).toBe('bolt11')
expect(resolved.transactionType).toBe(TransactionType.TRANSFER)
expect(resolved.feeReserve).toBe(12)
expect(resolved.paymentRequest).toBe(INVOICE)
// An expired invoice genuinely cannot be paid, so there IS nothing left to wait for.
expect(resolved.expiresPendingTransfer).toBe(true)
// bolt11 settles with a preimage, and the payment hash identifies it beforehand.
expect(typeof resolved.paymentId).toBe('string')
expect(resolved.paymentId).toHaveLength(64)
})
})
+20 -5
View File
@@ -181,8 +181,8 @@
"invalidRelayUrl": "Invalid relay URL.", "invalidRelayUrl": "Invalid relay URL.",
"lightningAddress": "Lightning address", "lightningAddress": "Lightning address",
"lightningInvoiceSharedWaiting": "Lightning invoice has been shared, waiting to be paid by receiver.", "lightningInvoiceSharedWaiting": "Lightning invoice has been shared, waiting to be paid by receiver.",
"lightningPayScreen_onPasteEmptyClipboard": "First copy Lightning invoice, address or pay code to pay to. Then paste.", "payScreen_onPasteEmptyClipboard": "First copy a Lightning invoice, address, pay code or a Bitcoin address to pay to. Then paste.",
"lightningPayScreen_payHeading": "Pay", "payScreen_payHeading": "Pay",
"loadingPublicInfo": "Loading public info", "loadingPublicInfo": "Loading public info",
"localBackupDisabled": "Local backup is off", "localBackupDisabled": "Local backup is off",
"localBackupEnabled": "Local backup is on", "localBackupEnabled": "Local backup is on",
@@ -303,8 +303,19 @@
"payCommon_payFromWallet": "Pay from wallet", "payCommon_payFromWallet": "Pay from wallet",
"payCommon_payMe": "Pay me", "payCommon_payMe": "Pay me",
"payCommon_payNow": "Pay now", "payCommon_payNow": "Pay now",
"onchainTransferScreen_broadcastTitle": "Payment sent",
"onchainTransferScreen_changeFee": "Change",
"onchainTransferScreen_feeHint": "Bitcoin payments are settled onchain. The miner fee depends on how fast you want the payment confirmed.",
"onchainTransferScreen_feeTierBlocks": "About %{blocks} blocks",
"onchainTransferScreen_feeTierReserve": "Up to %{amount} %{currency}",
"onchainTransferScreen_feeTierSubtext": "About %{blocks} blocks · up to %{amount} %{currency}",
"onchainTransferScreen_networkFee": "Network fee",
"onchainTransferScreen_noFeeOptions": "The mint returned no fee options for this payment and it can not be paid.",
"onchainTransferScreen_requestQuote": "Continue",
"onchainTransferScreen_selectFee": "Confirmation speed",
"onchainTransferScreen_toAddress": "Bitcoin address",
"payCommon_payToAddress": "Pay to this address", "payCommon_payToAddress": "Pay to this address",
"payCommon_payWithLightning": "Pay with Lightning", "payCommon_payWithBitcoin": "Pay with Bitcoin",
"payCommon_receive": "Receive", "payCommon_receive": "Receive",
"payCommon_receiveEcash": "Receive ecash", "payCommon_receiveEcash": "Receive ecash",
"payCommon_receiveInPerson": "Receive in person", "payCommon_receiveInPerson": "Receive in person",
@@ -318,7 +329,7 @@
"pos_scanToPay": "Scan to pay", "pos_scanToPay": "Scan to pay",
"pos_waitingForPayment": "Waiting for payment...", "pos_waitingForPayment": "Waiting for payment...",
"payerMemo": "Memo for the payer", "payerMemo": "Memo for the payer",
"payWithLightningDesc": "Enter or paste Lightning address, invoice or LNURL pay code you want to pay to.", "payWithBitcoinDesc": "Enter or paste a Lightning invoice, address or pay code, or a Bitcoin address you want to pay to.",
"pictureRetrieveFail": "Could not retrieve pictures from the server, try again later.", "pictureRetrieveFail": "Could not retrieve pictures from the server, try again later.",
"placeholderNpub": "npub...", "placeholderNpub": "npub...",
"placeholderRelay": "wss://...", "placeholderRelay": "wss://...",
@@ -618,6 +629,7 @@
"tranDetailScreen_sentFrom": "Sent from", "tranDetailScreen_sentFrom": "Sent from",
"tranDetailScreen_sentTo": "Sent to", "tranDetailScreen_sentTo": "Sent to",
"tranDetailScreen_status": "Status", "tranDetailScreen_status": "Status",
"tranDetailScreen_outpoint": "Bitcoin transaction",
"tranDetailScreen_successRetrieveEcashAfterRetry": "Ecash has been successfully received after retry. New transaction has been created.", "tranDetailScreen_successRetrieveEcashAfterRetry": "Ecash has been successfully received after retry. New transaction has been created.",
"tranDetailScreen_tokenTracking": "Token tracking", "tranDetailScreen_tokenTracking": "Token tracking",
"tranDetailScreen_tokenTrackingDesc": "Logged ecash tokens for debugging purposes", "tranDetailScreen_tokenTrackingDesc": "Logged ecash tokens for debugging purposes",
@@ -710,7 +722,7 @@
"updateScreen_updateNotAvailableDesc": "Good, you are running the latest version of the app.", "updateScreen_updateNotAvailableDesc": "Good, you are running the latest version of the app.",
"updateScreen_updateNow": "Update and restart", "updateScreen_updateNow": "Update and restart",
"updateScreen_updateSize": "Download size", "updateScreen_updateSize": "Download size",
"userErrorMissingLightningData": "Missing Lightning invoice, paycode or address.", "userErrorMissingPaymentData": "Missing Lightning invoice, paycode, address or Bitcoin address.",
"wallerScreen_mintButton": "Mint", "wallerScreen_mintButton": "Mint",
"walletAddressRecovery": "Wallet address recovery", "walletAddressRecovery": "Wallet address recovery",
"walletAddressRecoveryDesc": "Use your seed phrase from another device to get your original wallet address on this device. This will not recover balances.", "walletAddressRecoveryDesc": "Use your seed phrase from another device to get your original wallet address on this device. This will not recover balances.",
@@ -752,12 +764,15 @@
"welcomeScreen_terms_agreePrivacy": "Privacy Policy", "welcomeScreen_terms_agreePrivacy": "Privacy Policy",
"mintSelector_noTopupSupport": "Top up not supported for this currency", "mintSelector_noTopupSupport": "Top up not supported for this currency",
"mintSelector_noPayoutSupport": "Payouts not supported for this currency", "mintSelector_noPayoutSupport": "Payouts not supported for this currency",
"mintSelector_noOnchainPayoutSupport": "This mint does not support Bitcoin payouts",
"topupScreen_onchainAddress": "Address", "topupScreen_onchainAddress": "Address",
"topupScreen_ligtningInvoice": "Invoice", "topupScreen_ligtningInvoice": "Invoice",
"topupScreen_onchainAddressToPay": "Bitcoin address to pay", "topupScreen_onchainAddressToPay": "Bitcoin address to pay",
"topupScreen_onchainAmountIsHint": "This is Bitcoin onchain address to be used to topup your wallet. The amount is only a suggestion. Your balance updates with whatever actually arrives, whether more or less.", "topupScreen_onchainAmountIsHint": "This is Bitcoin onchain address to be used to topup your wallet. The amount is only a suggestion. Your balance updates with whatever actually arrives, whether more or less.",
"topupScreen_onchainConfirmationsNeeded": "Your ecash appears once the deposit confirms on the Bitcoin network. Number of confirmations depends on the mint.", "topupScreen_onchainConfirmationsNeeded": "Your ecash appears once the deposit confirms on the Bitcoin network. Number of confirmations depends on the mint.",
"tranDetail_checkForDeposits": "Check for deposits", "tranDetail_checkForDeposits": "Check for deposits",
"tranDetail_checkStatus": "Check status",
"tranDetail_onchainStillUnconfirmed": "The payment is still waiting for confirmation on the blockchain.",
"tranDetail_onchainNoDeposits": "No new deposits found at this address yet.", "tranDetail_onchainNoDeposits": "No new deposits found at this address yet.",
"tranDetail_onchainOutpoint": "Bitcoin transaction", "tranDetail_onchainOutpoint": "Bitcoin transaction",
"transactionType_topupOnchain": "Topup onchain", "transactionType_topupOnchain": "Topup onchain",
+20 -5
View File
@@ -180,8 +180,8 @@
"invalidRelayUrl": "URL de retransmisión no válida.", "invalidRelayUrl": "URL de retransmisión no válida.",
"lightningAddress": "Dirección de Lightning", "lightningAddress": "Dirección de Lightning",
"lightningInvoiceSharedWaiting": "Se ha compartido una factura Lightning y está a la espera de ser pagada por el receptor.", "lightningInvoiceSharedWaiting": "Se ha compartido una factura Lightning y está a la espera de ser pagada por el receptor.",
"lightningPayScreen_onPasteEmptyClipboard": "Primero, copia la factura Lightning, la dirección o el código de pago. Luego, pégalo.", "payScreen_onPasteEmptyClipboard": "Primero copia una factura Lightning, dirección, código de pago o una dirección Bitcoin a la que pagar. Luego pega.",
"lightningPayScreen_payHeading": "Pagar", "payScreen_payHeading": "Pagar",
"loadingPublicInfo": "Cargando información pública", "loadingPublicInfo": "Cargando información pública",
"localBackupDisabled": "La copia de seguridad local está desactivada", "localBackupDisabled": "La copia de seguridad local está desactivada",
"localBackupEnabled": "La copia de seguridad local está activada", "localBackupEnabled": "La copia de seguridad local está activada",
@@ -302,8 +302,19 @@
"payCommon_payFromWallet": "Pagar desde la billetera", "payCommon_payFromWallet": "Pagar desde la billetera",
"payCommon_payMe": "Pagame", "payCommon_payMe": "Pagame",
"payCommon_payNow": "Pagar ahora", "payCommon_payNow": "Pagar ahora",
"onchainTransferScreen_broadcastTitle": "Pago enviado",
"onchainTransferScreen_changeFee": "Cambiar",
"onchainTransferScreen_feeHint": "Los pagos en Bitcoin se liquidan en la cadena. La tarifa del minero depende de la rapidez con la que quieras que se confirme el pago.",
"onchainTransferScreen_feeTierBlocks": "Unos %{blocks} bloques",
"onchainTransferScreen_feeTierReserve": "Hasta %{amount} %{currency}",
"onchainTransferScreen_feeTierSubtext": "Unos %{blocks} bloques · hasta %{amount} %{currency}",
"onchainTransferScreen_networkFee": "Tarifa de red",
"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",
"onchainTransferScreen_toAddress": "Dirección Bitcoin",
"payCommon_payToAddress": "Pagar a esta dirección", "payCommon_payToAddress": "Pagar a esta dirección",
"payCommon_payWithLightning": "Pagar con Lightning", "payCommon_payWithBitcoin": "Pagar con Bitcoin",
"payCommon_receive": "Recibir", "payCommon_receive": "Recibir",
"payCommon_receiveEcash": "Recibir ecash", "payCommon_receiveEcash": "Recibir ecash",
"payCommon_receiveInPerson": "Recibir en persona", "payCommon_receiveInPerson": "Recibir en persona",
@@ -317,7 +328,7 @@
"pos_scanToPay": "Escanea para pagar", "pos_scanToPay": "Escanea para pagar",
"pos_waitingForPayment": "Esperando el pago...", "pos_waitingForPayment": "Esperando el pago...",
"payerMemo": "Memo para el pagador", "payerMemo": "Memo para el pagador",
"payWithLightningDesc": "Ingrese o pegue la dirección Lightning, la factura o el código de pago LNURL al que desea pagar.", "payWithBitcoinDesc": "Introduce o pega una factura Lightning, dirección o código de pago, o una dirección Bitcoin a la que quieras pagar.",
"pictureRetrieveFail": "No se pudieron recuperar las imágenes del servidor, inténtelo nuevamente más tarde.", "pictureRetrieveFail": "No se pudieron recuperar las imágenes del servidor, inténtelo nuevamente más tarde.",
"placeholderNpub": "npub...", "placeholderNpub": "npub...",
"placeholderRelay": "wss://...", "placeholderRelay": "wss://...",
@@ -617,6 +628,7 @@
"tranDetailScreen_sentFrom": "Enviado desde", "tranDetailScreen_sentFrom": "Enviado desde",
"tranDetailScreen_sentTo": "Enviado a", "tranDetailScreen_sentTo": "Enviado a",
"tranDetailScreen_status": "Estado", "tranDetailScreen_status": "Estado",
"tranDetailScreen_outpoint": "Transacción Bitcoin",
"tranDetailScreen_successRetrieveEcashAfterRetry": "El eCash se recibió correctamente tras reintentarlo. Se creó una nueva transacción.", "tranDetailScreen_successRetrieveEcashAfterRetry": "El eCash se recibió correctamente tras reintentarlo. Se creó una nueva transacción.",
"tranDetailScreen_tokenTracking": "Seguimiento de tokens", "tranDetailScreen_tokenTracking": "Seguimiento de tokens",
"tranDetailScreen_tokenTrackingDesc": "Tokens de ecash registrados para fines de depuración", "tranDetailScreen_tokenTrackingDesc": "Tokens de ecash registrados para fines de depuración",
@@ -709,7 +721,7 @@
"updateScreen_updateNotAvailableDesc": "Bueno, estás ejecutando la última versión de la aplicación.", "updateScreen_updateNotAvailableDesc": "Bueno, estás ejecutando la última versión de la aplicación.",
"updateScreen_updateNow": "Actualizar y reiniciar", "updateScreen_updateNow": "Actualizar y reiniciar",
"updateScreen_updateSize": "Tamaño de descarga", "updateScreen_updateSize": "Tamaño de descarga",
"userErrorMissingLightningData": "Falta factura de Lightning, código de pago o dirección.", "userErrorMissingPaymentData": "Falta la factura Lightning, el código de pago, la dirección o la dirección Bitcoin.",
"wallerScreen_mintButton": "Menta", "wallerScreen_mintButton": "Menta",
"walletAddressRecovery": "Recuperación de la dirección de la billetera", "walletAddressRecovery": "Recuperación de la dirección de la billetera",
"walletAddressRecoveryDesc": "Usa tu frase inicial de otro dispositivo para obtener la dirección de tu billetera original en este. Esto no recuperará saldos.", "walletAddressRecoveryDesc": "Usa tu frase inicial de otro dispositivo para obtener la dirección de tu billetera original en este. Esto no recuperará saldos.",
@@ -751,11 +763,14 @@
"welcomeScreen_terms_agreePrivacy": "Política de Privacidad", "welcomeScreen_terms_agreePrivacy": "Política de Privacidad",
"mintSelector_noTopupSupport": "Recargas no soportadas para esta moneda", "mintSelector_noTopupSupport": "Recargas no soportadas para esta moneda",
"mintSelector_noPayoutSupport": "Pagos no soportados para esta moneda", "mintSelector_noPayoutSupport": "Pagos no soportados para esta moneda",
"mintSelector_noOnchainPayoutSupport": "Esta casa de cambio no admite pagos en Bitcoin",
"topupScreen_onchainAddress": "Dirección", "topupScreen_onchainAddress": "Dirección",
"topupScreen_onchainAddressToPay": "Dirección Bitcoin a pagar", "topupScreen_onchainAddressToPay": "Dirección Bitcoin a pagar",
"topupScreen_onchainAmountIsHint": "El importe es solo una sugerencia. Tu saldo se actualizará con lo que realmente llegue, sea más o menos.", "topupScreen_onchainAmountIsHint": "El importe es solo una sugerencia. Tu saldo se actualizará con lo que realmente llegue, sea más o menos.",
"topupScreen_onchainConfirmationsNeeded": "Tu ecash aparecerá cuando el depósito se confirme en la red Bitcoin.", "topupScreen_onchainConfirmationsNeeded": "Tu ecash aparecerá cuando el depósito se confirme en la red Bitcoin.",
"tranDetail_checkForDeposits": "Buscar depósitos", "tranDetail_checkForDeposits": "Buscar depósitos",
"tranDetail_checkStatus": "Comprobar estado",
"tranDetail_onchainStillUnconfirmed": "El pago aún está esperando la confirmación en la blockchain.",
"tranDetail_onchainNoDeposits": "Aún no se encontraron nuevos depósitos en esta dirección.", "tranDetail_onchainNoDeposits": "Aún no se encontraron nuevos depósitos en esta dirección.",
"tranDetail_onchainOutpoint": "Transacción Bitcoin", "tranDetail_onchainOutpoint": "Transacción Bitcoin",
"transactionType_topupOnchain": "Recarga onchain", "transactionType_topupOnchain": "Recarga onchain",
+20 -5
View File
@@ -181,8 +181,8 @@
"invalidRelayUrl": "URL de relay inválido.", "invalidRelayUrl": "URL de relay inválido.",
"lightningAddress": "Endereço Lightning", "lightningAddress": "Endereço Lightning",
"lightningInvoiceSharedWaiting": "A invoice Lightning foi compartilhada, aguardando pagamento.", "lightningInvoiceSharedWaiting": "A invoice Lightning foi compartilhada, aguardando pagamento.",
"lightningPayScreen_onPasteEmptyClipboard": "Primeiro copie invoice Lightning, endereço ou código de pagamento. Depois cole.", "payScreen_onPasteEmptyClipboard": "Primeiro copie uma fatura Lightning, endereço, código de pagamento ou um endereço Bitcoin para pagar. Depois cole.",
"lightningPayScreen_payHeading": "Pagar", "payScreen_payHeading": "Pagar",
"loadingPublicInfo": "Carregando informações públicas", "loadingPublicInfo": "Carregando informações públicas",
"localBackupDisabled": "Backup local desativado", "localBackupDisabled": "Backup local desativado",
"localBackupEnabled": "Backup local ativado", "localBackupEnabled": "Backup local ativado",
@@ -303,8 +303,19 @@
"payCommon_payFromWallet": "Pagar da carteira", "payCommon_payFromWallet": "Pagar da carteira",
"payCommon_payMe": "Pague-me", "payCommon_payMe": "Pague-me",
"payCommon_payNow": "Pagar agora", "payCommon_payNow": "Pagar agora",
"onchainTransferScreen_broadcastTitle": "Pagamento enviado",
"onchainTransferScreen_changeFee": "Alterar",
"onchainTransferScreen_feeHint": "Os pagamentos em Bitcoin são liquidados na blockchain. A taxa de mineração depende da rapidez com que quer o pagamento confirmado.",
"onchainTransferScreen_feeTierBlocks": "Cerca de %{blocks} blocos",
"onchainTransferScreen_feeTierReserve": "Até %{amount} %{currency}",
"onchainTransferScreen_feeTierSubtext": "Cerca de %{blocks} blocos · até %{amount} %{currency}",
"onchainTransferScreen_networkFee": "Taxa de rede",
"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",
"onchainTransferScreen_toAddress": "Endereço Bitcoin",
"payCommon_payToAddress": "Pagar para este endereço", "payCommon_payToAddress": "Pagar para este endereço",
"payCommon_payWithLightning": "Pagar com Lightning", "payCommon_payWithBitcoin": "Pagar com Bitcoin",
"payCommon_receive": "Receber", "payCommon_receive": "Receber",
"payCommon_receiveEcash": "Receber ecash", "payCommon_receiveEcash": "Receber ecash",
"payCommon_receiveInPerson": "Receber pessoalmente", "payCommon_receiveInPerson": "Receber pessoalmente",
@@ -318,7 +329,7 @@
"pos_scanToPay": "Escaneie para pagar", "pos_scanToPay": "Escaneie para pagar",
"pos_waitingForPayment": "Aguardando pagamento...", "pos_waitingForPayment": "Aguardando pagamento...",
"payerMemo": "Memorando para o pagador", "payerMemo": "Memorando para o pagador",
"payWithLightningDesc": "Insira ou cole endereço Lightning, invoice ou código de pagamento LNURL para pagar.", "payWithBitcoinDesc": "Introduza ou cole uma fatura Lightning, endereço ou código de pagamento, ou um endereço Bitcoin que pretende pagar.",
"pictureRetrieveFail": "Não foi possível recuperar imagens do servidor, tente mais tarde.", "pictureRetrieveFail": "Não foi possível recuperar imagens do servidor, tente mais tarde.",
"placeholderNpub": "npub...", "placeholderNpub": "npub...",
"placeholderRelay": "wss://...", "placeholderRelay": "wss://...",
@@ -618,6 +629,7 @@
"tranDetailScreen_sentFrom": "Enviado de", "tranDetailScreen_sentFrom": "Enviado de",
"tranDetailScreen_sentTo": "Enviado para", "tranDetailScreen_sentTo": "Enviado para",
"tranDetailScreen_status": "Status", "tranDetailScreen_status": "Status",
"tranDetailScreen_outpoint": "Transação Bitcoin",
"tranDetailScreen_successRetrieveEcashAfterRetry": "Ecash recebido com sucesso após nova tentativa. Nova transação criada.", "tranDetailScreen_successRetrieveEcashAfterRetry": "Ecash recebido com sucesso após nova tentativa. Nova transação criada.",
"tranDetailScreen_tokenTracking": "Rastreamento de token", "tranDetailScreen_tokenTracking": "Rastreamento de token",
"tranDetailScreen_tokenTrackingDesc": "Tokens ecash registrados para depuração", "tranDetailScreen_tokenTrackingDesc": "Tokens ecash registrados para depuração",
@@ -710,7 +722,7 @@
"updateScreen_updateNotAvailableDesc": "Ótimo, você está na versão mais recente do app.", "updateScreen_updateNotAvailableDesc": "Ótimo, você está na versão mais recente do app.",
"updateScreen_updateNow": "Atualizar e reiniciar", "updateScreen_updateNow": "Atualizar e reiniciar",
"updateScreen_updateSize": "Tamanho do download", "updateScreen_updateSize": "Tamanho do download",
"userErrorMissingLightningData": "Faltando invoice Lightning, código de pagamento ou endereço.", "userErrorMissingPaymentData": "Falta a fatura Lightning, código de pagamento, endereço ou endereço Bitcoin.",
"wallerScreen_mintButton": "Mint", "wallerScreen_mintButton": "Mint",
"walletAddressRecovery": "Recuperação de endereço da carteira", "walletAddressRecovery": "Recuperação de endereço da carteira",
"walletAddressRecoveryDesc": "Use sua frase seed de outro dispositivo para obter endereço original aqui. Isso não recupera saldos.", "walletAddressRecoveryDesc": "Use sua frase seed de outro dispositivo para obter endereço original aqui. Isso não recupera saldos.",
@@ -752,11 +764,14 @@
"welcomeScreen_terms_agreePrivacy": "Política de Privacidade", "welcomeScreen_terms_agreePrivacy": "Política de Privacidade",
"mintSelector_noTopupSupport": "Carregamentos não suportados para esta moeda", "mintSelector_noTopupSupport": "Carregamentos não suportados para esta moeda",
"mintSelector_noPayoutSupport": "Pagamentos não suportados para esta moeda", "mintSelector_noPayoutSupport": "Pagamentos não suportados para esta moeda",
"mintSelector_noOnchainPayoutSupport": "Esta casa da moeda não suporta pagamentos em Bitcoin",
"topupScreen_onchainAddress": "Endereço", "topupScreen_onchainAddress": "Endereço",
"topupScreen_onchainAddressToPay": "Endereço Bitcoin a pagar", "topupScreen_onchainAddressToPay": "Endereço Bitcoin a pagar",
"topupScreen_onchainAmountIsHint": "O valor é apenas uma sugestão. O seu saldo será atualizado com o que realmente chegar, seja mais ou menos.", "topupScreen_onchainAmountIsHint": "O valor é apenas uma sugestão. O seu saldo será atualizado com o que realmente chegar, seja mais ou menos.",
"topupScreen_onchainConfirmationsNeeded": "O seu ecash aparecerá assim que o depósito for confirmado na rede Bitcoin.", "topupScreen_onchainConfirmationsNeeded": "O seu ecash aparecerá assim que o depósito for confirmado na rede Bitcoin.",
"tranDetail_checkForDeposits": "Procurar depósitos", "tranDetail_checkForDeposits": "Procurar depósitos",
"tranDetail_checkStatus": "Verificar estado",
"tranDetail_onchainStillUnconfirmed": "O pagamento ainda aguarda confirmação na blockchain.",
"tranDetail_onchainNoDeposits": "Ainda não foram encontrados novos depósitos neste endereço.", "tranDetail_onchainNoDeposits": "Ainda não foram encontrados novos depósitos neste endereço.",
"tranDetail_onchainOutpoint": "Transação Bitcoin", "tranDetail_onchainOutpoint": "Transação Bitcoin",
"transactionType_topupOnchain": "Carregamento onchain", "transactionType_topupOnchain": "Carregamento onchain",
+20 -5
View File
@@ -181,8 +181,8 @@
"invalidRelayUrl": "Neplatná relay URL adresa.", "invalidRelayUrl": "Neplatná relay URL adresa.",
"lightningAddress": "Lightning adresa", "lightningAddress": "Lightning adresa",
"lightningInvoiceSharedWaiting": "Lightning invoice bola vyzdieľaná, čaká na zaplatenie príjemcom.", "lightningInvoiceSharedWaiting": "Lightning invoice bola vyzdieľaná, čaká na zaplatenie príjemcom.",
"lightningPayScreen_onPasteEmptyClipboard": "Najprv skopíruj Lightning invoice, adresu alebo platobný kód, potom vlož.", "payScreen_onPasteEmptyClipboard": "Najprv skopírujte Lightning faktúru, adresu, platobný kód alebo Bitcoin adresu, ktorej chcete zaplatiť. Potom vložte.",
"lightningPayScreen_payHeading": "Zaplať", "payScreen_payHeading": "Zaplatiť",
"loadingPublicInfo": "Načítavam verejné informácie", "loadingPublicInfo": "Načítavam verejné informácie",
"localBackupDisabled": "Lokálna záloha je vypnutá", "localBackupDisabled": "Lokálna záloha je vypnutá",
"localBackupEnabled": "Lokálna záloha je zapnutá", "localBackupEnabled": "Lokálna záloha je zapnutá",
@@ -303,8 +303,19 @@
"payCommon_payFromWallet": "Zaplať z peňaženky", "payCommon_payFromWallet": "Zaplať z peňaženky",
"payCommon_payMe": "Zaplať mi", "payCommon_payMe": "Zaplať mi",
"payCommon_payNow": "Zaplať teraz", "payCommon_payNow": "Zaplať teraz",
"onchainTransferScreen_broadcastTitle": "Platba odoslaná",
"onchainTransferScreen_changeFee": "Zmeniť",
"onchainTransferScreen_feeHint": "Bitcoinové platby sa vyrovnávajú priamo v blockchaine. Poplatok pre ťažiarov závisí od toho, ako rýchlo má byť platba potvrdená.",
"onchainTransferScreen_feeTierBlocks": "Približne %{blocks} blokov",
"onchainTransferScreen_feeTierReserve": "Až do %{amount} %{currency}",
"onchainTransferScreen_feeTierSubtext": "Približne %{blocks} blokov · až do %{amount} %{currency}",
"onchainTransferScreen_networkFee": "Sieťový poplatok",
"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",
"onchainTransferScreen_toAddress": "Bitcoin adresa",
"payCommon_payToAddress": "Zaplať na adresu", "payCommon_payToAddress": "Zaplať na adresu",
"payCommon_payWithLightning": "Zaplať cez Lightning", "payCommon_payWithBitcoin": "Zaplatiť Bitcoinom",
"payCommon_receive": "Prijmi", "payCommon_receive": "Prijmi",
"payCommon_receiveEcash": "Prijmy ecash", "payCommon_receiveEcash": "Prijmy ecash",
"payCommon_receiveInPerson": "Prijmy osobne", "payCommon_receiveInPerson": "Prijmy osobne",
@@ -318,7 +329,7 @@
"pos_scanToPay": "Naskenuj a zaplať", "pos_scanToPay": "Naskenuj a zaplať",
"pos_waitingForPayment": "Čakám na platbu...", "pos_waitingForPayment": "Čakám na platbu...",
"payerMemo": "Poznámka pre príjemcu", "payerMemo": "Poznámka pre príjemcu",
"payWithLightningDesc": "Zadaj Lightning adresu, invoice alebo LNURL kód ktorý chceš zaplatiť.", "payWithBitcoinDesc": "Zadajte alebo vložte Lightning faktúru, adresu či platobný kód, alebo Bitcoin adresu, ktorej chcete zaplatiť.",
"pictureRetrieveFail": "Nejde načítať obrázky zo servera, skús neskôr.", "pictureRetrieveFail": "Nejde načítať obrázky zo servera, skús neskôr.",
"placeholderNpub": "npub...", "placeholderNpub": "npub...",
"placeholderRelay": "wss://...", "placeholderRelay": "wss://...",
@@ -618,6 +629,7 @@
"tranDetailScreen_sentFrom": "Poslané od", "tranDetailScreen_sentFrom": "Poslané od",
"tranDetailScreen_sentTo": "Poslané komu", "tranDetailScreen_sentTo": "Poslané komu",
"tranDetailScreen_status": "Status", "tranDetailScreen_status": "Status",
"tranDetailScreen_outpoint": "Bitcoin transakcia",
"tranDetailScreen_successRetrieveEcashAfterRetry": "Ecash bol prijatý po opätovnom opakovaní. Nová transakcia vytvorená.", "tranDetailScreen_successRetrieveEcashAfterRetry": "Ecash bol prijatý po opätovnom opakovaní. Nová transakcia vytvorená.",
"tranDetailScreen_tokenTracking": "Sledovanie tokenov", "tranDetailScreen_tokenTracking": "Sledovanie tokenov",
"tranDetailScreen_tokenTrackingDesc": "Zaznamenané ecash tokeny na účely ladenia", "tranDetailScreen_tokenTrackingDesc": "Zaznamenané ecash tokeny na účely ladenia",
@@ -710,7 +722,7 @@
"updateScreen_updateNotAvailableDesc": "Fajn, máš najnovšiu verziu aplikácie.", "updateScreen_updateNotAvailableDesc": "Fajn, máš najnovšiu verziu aplikácie.",
"updateScreen_updateNow": "Aktualizuj a reštartuj", "updateScreen_updateNow": "Aktualizuj a reštartuj",
"updateScreen_updateSize": "Veľkosť balíka", "updateScreen_updateSize": "Veľkosť balíka",
"userErrorMissingLightningData": "Chýbajúca Lightning invoice, LNURL kód, alebo adresa.", "userErrorMissingPaymentData": "Chýba Lightning faktúra, platobný kód, adresa alebo Bitcoin adresa.",
"wallerScreen_mintButton": "Mint", "wallerScreen_mintButton": "Mint",
"walletAddressRecovery": "Obnovenie adresy peňaženky", "walletAddressRecovery": "Obnovenie adresy peňaženky",
"walletAddressRecoveryDesc": "Pomocou mnemonickej vety z iného mobilu prenes svoju pôvodnú adresu peňaženky na tento mobil. Táto funkcia neobnovuje zostatok ecash.", "walletAddressRecoveryDesc": "Pomocou mnemonickej vety z iného mobilu prenes svoju pôvodnú adresu peňaženky na tento mobil. Táto funkcia neobnovuje zostatok ecash.",
@@ -752,11 +764,14 @@
"welcomeScreen_terms_agreePrivacy": "Zásadami ochrany osobných údajov", "welcomeScreen_terms_agreePrivacy": "Zásadami ochrany osobných údajov",
"mintSelector_noTopupSupport": "Dobitie nie je pre túto menu podporované", "mintSelector_noTopupSupport": "Dobitie nie je pre túto menu podporované",
"mintSelector_noPayoutSupport": "Výplaty nie sú pre túto menu podporované", "mintSelector_noPayoutSupport": "Výplaty nie sú pre túto menu podporované",
"mintSelector_noOnchainPayoutSupport": "Táto mincovňa nepodporuje Bitcoin výplaty",
"topupScreen_onchainAddress": "Adresa", "topupScreen_onchainAddress": "Adresa",
"topupScreen_onchainAddressToPay": "Bitcoin adresa na zaplatenie", "topupScreen_onchainAddressToPay": "Bitcoin adresa na zaplatenie",
"topupScreen_onchainAmountIsHint": "Suma je len návrh. Váš zostatok sa aktualizuje podľa toho, čo skutočne dorazí — viac alebo menej.", "topupScreen_onchainAmountIsHint": "Suma je len návrh. Váš zostatok sa aktualizuje podľa toho, čo skutočne dorazí — viac alebo menej.",
"topupScreen_onchainConfirmationsNeeded": "Váš ecash sa objaví, keď sa vklad potvrdí v sieti Bitcoin.", "topupScreen_onchainConfirmationsNeeded": "Váš ecash sa objaví, keď sa vklad potvrdí v sieti Bitcoin.",
"tranDetail_checkForDeposits": "Skontrolovať vklady", "tranDetail_checkForDeposits": "Skontrolovať vklady",
"tranDetail_checkStatus": "Skontrolovať stav",
"tranDetail_onchainStillUnconfirmed": "Platba stále čaká na potvrdenie v blockchaine.",
"tranDetail_onchainNoDeposits": "Na tejto adrese zatiaľ neboli nájdené žiadne nové vklady.", "tranDetail_onchainNoDeposits": "Na tejto adrese zatiaľ neboli nájdené žiadne nové vklady.",
"tranDetail_onchainOutpoint": "Bitcoin transakcia", "tranDetail_onchainOutpoint": "Bitcoin transakcia",
"transactionType_topupOnchain": "Dobitie onchain", "transactionType_topupOnchain": "Dobitie onchain",
+4 -2
View File
@@ -10,7 +10,8 @@ import {
ReceiveScreen, ReceiveScreen,
SendScreen, SendScreen,
ScanScreen, ScanScreen,
LightningPayScreen, PayScreen,
OnchainTransferScreen,
ContactsScreen, ContactsScreen,
PictureScreen, PictureScreen,
ContactDetailScreen, ContactDetailScreen,
@@ -51,8 +52,9 @@ const WalletStack = createNativeStackNavigator({
Receive: ReceiveScreen, Receive: ReceiveScreen,
Send: SendScreen, Send: SendScreen,
Scan: ScanScreen, Scan: ScanScreen,
LightningPay: LightningPayScreen, Pay: PayScreen,
Transfer: TransferScreen, Transfer: TransferScreen,
TransferOnchain: OnchainTransferScreen,
Topup: TopupScreen, Topup: TopupScreen,
CashuPaymentRequest: CashuPaymentRequestScreen, CashuPaymentRequest: CashuPaymentRequestScreen,
//POS: POSScreen, //POS: POSScreen,
+848
View File
@@ -0,0 +1,848 @@
/**
* Pay a Bitcoin address by melting ecash onchain (NUT-30).
*
* Kept separate from TransferScreen rather than folded into it as a third mode. The
* lightning screen is shaped around a payment request that already carries its amount
* (an invoice, or an LNURL callback that mints one), and around a quote it can fetch
* the moment a mint is chosen. Neither holds here: the user types the amount, so a
* quote cannot exist until they have, and the quote comes back with a LIST of fee tiers
* instead of a single fee reserve. Threading that through the invoice-shaped reducer
* would have meant re-auditing every `!encodedInvoice &&` render gate on a screen where
* a mistake breaks lightning payments.
*
* The service layer underneath is NOT forked — both rails share one
* `TransferOperationApi`. This is a screen, not a second implementation.
*/
import {observer} from 'mobx-react-lite'
import React, {useCallback, useEffect, useReducer, useRef, useState} from 'react'
import {StackActions, StaticScreenProps, useNavigation} from '@react-navigation/native'
import {TextInput, TextStyle, View, ViewStyle} from 'react-native'
import numbro from 'numbro'
import {MeltQuoteOnchainResponse} from '@cashu/cashu-ts'
import {colors, spacing, typography, useThemeColor} from '../theme'
import {
AmountInput,
BottomModal,
Button,
Card,
ErrorModal,
Icon,
InfoModal,
ListItem,
Loading,
Screen,
Text,
} from '../components'
import {MemoInputCard} from '../components/MemoInputCard'
import {useStores} from '../models'
import {MintBalance} from '../models/Mint'
import {Transaction, TransactionStatus} from '../models/Transaction'
import {WalletTask} from '../services'
import {TransactionTaskResult} from '../services/wallet/types'
import {log} from '../services/logService'
import {translate} from '../i18n'
import AppError, {Err} from '../utils/AppError'
import {round, toNumber} from '../utils/number'
import useIsInternetReachable from '../utils/useIsInternetReachable'
import {MintUnit, getCurrency} from '../services/wallet/currency'
import {
OnchainFeeOption,
normalizeFeeOptions,
onchainMeltFloor,
selectDefaultFeeOption,
} from '../services/wallet/operations/onchainAmounts'
import {MintHeader} from './Mints/MintHeader'
import {MintBalanceSelector} from './Mints/MintBalanceSelector'
import {ResultModalInfo} from './Wallet/ResultModalInfo'
import {TranItem} from './TranDetailScreen'
type Props = StaticScreenProps<{
address: string
/** BIP21 amount hint, in sats. The user may change it. */
amountSat?: number
memo?: string
unit: MintUnit
mintUrl?: string
}>
// ─── State machine ───────────────────────────────────────────────────────────
type OnchainTransferState = {
address: string
meltQuote: MeltQuoteOnchainResponse | undefined
feeOptions: OnchainFeeOption[]
selectedFee: OnchainFeeOption | undefined
availableMintBalances: MintBalance[]
mintBalanceToTransferFrom: MintBalance | undefined
transaction: Transaction | undefined
transactionStatus: TransactionStatus | undefined
resultModalInfo: {status: TransactionStatus; title?: string; message: string} | undefined
finalFee: number
isLoading: boolean
isResultModalVisible: boolean
isFeeModalVisible: boolean
info: string
error: AppError | undefined
}
type OnchainTransferAction =
| {type: 'SET_MINT_BALANCE'; balance: MintBalance}
| {type: 'QUOTE_START'}
| {
type: 'QUOTE_READY'
meltQuote: MeltQuoteOnchainResponse
feeOptions: OnchainFeeOption[]
selectedFee: OnchainFeeOption
availableMintBalances: MintBalance[]
}
| {type: 'QUOTE_INSUFFICIENT'; message: string}
| {type: 'QUOTE_CLEAR'}
| {type: 'QUOTE_FINISHED'}
| {type: 'SELECT_FEE'; option: OnchainFeeOption}
| {type: 'TOGGLE_FEE_MODAL'}
| {type: 'TRANSFER_START'}
| {
type: 'TRANSFER_COMPLETE'
transaction?: Transaction
transactionStatus: TransactionStatus
finalFee: number
resultModalInfo: {status: TransactionStatus; title?: string; message: string}
}
| {type: 'TOGGLE_RESULT_MODAL'}
| {type: 'SET_INFO'; message: string}
| {type: 'SET_ERROR'; error: AppError}
| {type: 'RESET'}
const INITIAL_STATE: OnchainTransferState = {
address: '',
meltQuote: undefined,
feeOptions: [],
selectedFee: undefined,
availableMintBalances: [],
mintBalanceToTransferFrom: undefined,
transaction: undefined,
transactionStatus: undefined,
resultModalInfo: undefined,
finalFee: 0,
isLoading: false,
isResultModalVisible: false,
isFeeModalVisible: false,
info: '',
error: undefined,
}
function onchainTransferReducer(
state: OnchainTransferState,
action: OnchainTransferAction,
): OnchainTransferState {
switch (action.type) {
case 'SET_MINT_BALANCE':
// Switching mint invalidates the quote: fee tiers are the MINT's estimate of
// what the chain costs, priced from its own UTXO set. They are not portable.
return {
...state,
mintBalanceToTransferFrom: action.balance,
meltQuote: undefined,
feeOptions: [],
selectedFee: undefined,
}
case 'QUOTE_START':
return {...state, isLoading: true, info: ''}
case 'QUOTE_READY':
return {
...state,
isLoading: false,
meltQuote: action.meltQuote,
feeOptions: action.feeOptions,
selectedFee: action.selectedFee,
availableMintBalances: action.availableMintBalances,
}
case 'QUOTE_INSUFFICIENT':
return {
...state,
isLoading: false,
info: action.message,
meltQuote: undefined,
feeOptions: [],
selectedFee: undefined,
}
case 'QUOTE_CLEAR':
return {...state, meltQuote: undefined, feeOptions: [], selectedFee: undefined}
case 'QUOTE_FINISHED':
return {...state, isLoading: false}
case 'SELECT_FEE':
return {...state, selectedFee: action.option, isFeeModalVisible: false}
case 'TOGGLE_FEE_MODAL':
return {...state, isFeeModalVisible: !state.isFeeModalVisible}
case 'TRANSFER_START':
return {...state, isLoading: true}
case 'TRANSFER_COMPLETE':
return {
...state,
isLoading: false,
transaction: action.transaction,
transactionStatus: action.transactionStatus,
finalFee: action.finalFee,
resultModalInfo: action.resultModalInfo,
isResultModalVisible: true,
}
case 'TOGGLE_RESULT_MODAL':
return {...state, isResultModalVisible: !state.isResultModalVisible}
case 'SET_INFO':
return {...state, info: action.message}
case 'SET_ERROR':
return {...state, isLoading: false, error: action.error}
case 'RESET':
return {...INITIAL_STATE}
default:
return state
}
}
// ─── Component ───────────────────────────────────────────────────────────────
export const OnchainTransferScreen = observer(function OnchainTransferScreen({route}: Props) {
const navigation = useNavigation()
const amountInputRef = useRef<TextInput>(null)
const unitRef = useRef<MintUnit>('sat')
const {proofsStore, mintsStore, walletStore} = useStores()
const isInternetReachable = useIsInternetReachable()
const [state, dispatch] = useReducer(onchainTransferReducer, INITIAL_STATE)
const [amountToTransfer, setAmountToTransfer] = useState<string>('0')
const [memo, setMemo] = useState('')
const {
meltQuote,
feeOptions,
selectedFee,
availableMintBalances,
mintBalanceToTransferFrom,
transaction,
transactionStatus,
resultModalInfo,
finalFee,
isLoading,
isResultModalVisible,
isFeeModalVisible,
info,
error,
} = state
const address = route.params.address
useEffect(() => {
try {
const {unit, mintUrl, amountSat, memo: bip21Memo} = route.params
if (!unit) {
throw new AppError(Err.VALIDATION_ERROR, translate('missingMintUnitRouteParamsError'))
}
unitRef.current = unit
// BIP21 hints. Both are the payee's suggestion, not a commitment: the user can
// change the amount, and the mint prices the payment from what they confirm.
if (amountSat && amountSat > 0) {
setAmountToTransfer(
numbro(amountSat).format({thousandSeparated: true, mantissa: 0}),
)
}
if (bip21Memo) setMemo(bip21Memo)
const balance = mintUrl
? proofsStore.getMintBalance(mintUrl)
: proofsStore.getMintBalancesWithUnit(unit)[0]
if (balance) dispatch({type: 'SET_MINT_BALANCE', balance})
if (!isInternetReachable) dispatch({type: 'SET_INFO', message: translate('commonOfflinePretty')})
} catch (e: any) {
handleError(e)
}
const timer = setTimeout(() => amountInputRef.current?.focus(), 100)
return () => clearTimeout(timer)
}, [])
const handleError = (e: AppError) => dispatch({type: 'SET_ERROR', error: e})
const selectedMint = mintBalanceToTransferFrom
? mintsStore.findByUrl(mintBalanceToTransferFrom.mintUrl)
: undefined
/**
* The mint's advertised onchain melt limits, floored by our own.
*
* A mint's `min_amount` has been observed wrong two independent ways on the CDK test
* mint, so it is taken as a lower bound on OUR floor, never as the floor itself.
*/
// `AmountLike` in the cashu-ts types, but mint info is normalized straight off the
// JSON (only undefined → null), so these are plain numbers. Same cast as TopupScreen.
const meltSetting = selectedMint?.meltMethodSetting?.('onchain', unitRef.current)
const minAmount = onchainMeltFloor(
unitRef.current,
meltSetting?.min_amount as number | null,
)
const maxAmount = meltSetting?.max_amount as number | null | undefined
const onMintBalanceSelect = (balance: MintBalance) =>
dispatch({type: 'SET_MINT_BALANCE', balance})
/**
* Ask the selected mint what it would cost to send this amount to this address.
*
* Deliberately explicit rather than fired on every keystroke: a melt quote reserves
* nothing but it does cost a round-trip, and re-quoting mid-typing would flicker the
* fee under the user's finger.
*/
const requestQuote = useCallback(async () => {
try {
if (!mintBalanceToTransferFrom?.mintUrl) {
dispatch({type: 'SET_INFO', message: translate('transferScreen_selectMintFrom')})
return
}
const {precision, code: currencyCode} = getCurrency(unitRef.current)
const amountUnit = round(toNumber(amountToTransfer) * precision, 0)
if (!amountUnit || amountUnit <= 0) {
dispatch({type: 'SET_INFO', message: translate('payCommon_amountZeroOrNegative')})
return
}
if (amountUnit < minAmount) {
dispatch({
type: 'SET_INFO',
message: translate('payCommon_minimumPay', {
amount: minAmount,
currency: currencyCode,
}),
})
return
}
if (maxAmount && amountUnit > maxAmount) {
dispatch({
type: 'SET_INFO',
message: translate('payCommon_maximumPay', {
amount: maxAmount,
currency: currencyCode,
}),
})
return
}
dispatch({type: 'QUOTE_START'})
const quote: MeltQuoteOnchainResponse = await walletStore.createOnchainMeltQuote(
mintBalanceToTransferFrom.mintUrl,
unitRef.current,
address,
amountUnit,
)
const options = normalizeFeeOptions(quote.fee_options ?? [])
const defaultOption = selectDefaultFeeOption(options)
if (!defaultOption) {
// NUT-30 requires at least one tier. A quote without one cannot be executed
// — there is no fee_index to send — so this is a broken quote, not a fee of
// zero.
throw new AppError(
Err.MINT_ERROR,
translate('onchainTransferScreen_noFeeOptions'),
{quote: quote.quote},
)
}
// Balance must cover amount + the fee reserve of the tier we will actually
// send. Picking the cheapest tier's reserve here would offer mints that cannot
// afford the tier the user then selects.
const totalRequired = quote.amount.toNumber() + defaultOption.feeReserve
const availableBalances = proofsStore.getMintBalancesWithEnoughBalance(
totalRequired,
unitRef.current,
)
if (availableBalances.length === 0) {
dispatch({
type: 'QUOTE_INSUFFICIENT',
message: translate('transferScreen_insufficientFunds', {
currency: currencyCode,
amount: totalRequired,
}),
})
return
}
dispatch({
type: 'QUOTE_READY',
meltQuote: quote,
feeOptions: options,
selectedFee: defaultOption,
availableMintBalances: availableBalances,
})
} catch (e: any) {
handleError(e)
} finally {
dispatch({type: 'QUOTE_FINISHED'})
}
}, [mintBalanceToTransferFrom?.mintUrl, amountToTransfer, address, minAmount, maxAmount])
const transfer = async () => {
try {
if (!meltQuote || !selectedFee) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing quote to initiate the payment')
}
if (!mintBalanceToTransferFrom) {
dispatch({type: 'SET_INFO', message: translate('transferScreen_selectMintFrom')})
return
}
dispatch({type: 'TRANSFER_START'})
const result: TransactionTaskResult = await WalletTask.transferOnchainQueueAwaitable(
mintBalanceToTransferFrom,
meltQuote.amount.toNumber(),
unitRef.current,
meltQuote,
selectedFee.feeIndex,
memo,
new Date(meltQuote.expiry * 1000),
address,
)
handleTransferTaskResult(result)
} catch (e: any) {
handleError(e)
}
}
const handleTransferTaskResult = (result: TransactionTaskResult) => {
const {transaction: tx, error: taskError, message} = result
if (!tx && taskError) {
dispatch({
type: 'TRANSFER_COMPLETE',
transactionStatus: TransactionStatus.ERROR,
finalFee: 0,
resultModalInfo: {
status: TransactionStatus.ERROR,
title: translate('payCommon_failed'),
message: taskError.message || 'Bitcoin payment failed',
},
})
return
}
if (!tx) return
const {status} = tx
const resultInfo = taskError
? {
status,
title: taskError.params?.message ? taskError.message : translate('payCommon_failed'),
message: taskError.params?.message || taskError.message || 'Payment failed',
}
: {status, message}
dispatch({
type: 'TRANSFER_COMPLETE',
transaction: tx,
transactionStatus: status,
finalFee: tx.fee ?? 0,
resultModalInfo: resultInfo,
})
}
const gotoWallet = () => {
dispatch({type: 'RESET'})
navigation.dispatch(StackActions.popToTop())
}
const toggleResultModal = () => dispatch({type: 'TOGGLE_RESULT_MODAL'})
const toggleFeeModal = () => dispatch({type: 'TOGGLE_FEE_MODAL'})
const headerBg = useThemeColor('header')
const amountInputColor = useThemeColor('amountInput')
const iconColor = useThemeColor('textDim')
const hintText = useThemeColor('textDim')
const currencyCode = getCurrency(unitRef.current).code
const hasQuote = !!meltQuote && !!selectedFee
const isSettled =
transactionStatus === TransactionStatus.COMPLETED ||
transactionStatus === TransactionStatus.PENDING
// With one tier there is nothing to choose. Showing a picker that opens a list of one
// asks the user to make a decision that does not exist. The CDK fakewallet returns
// exactly one, so this is the common case, not the edge case.
const isFeePickable = feeOptions.length > 1
return (
<Screen preset="fixed" contentContainerStyle={$screen}>
<MintHeader mint={selectedMint} unit={unitRef.current} />
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
<View style={$amountContainer}>
<AmountInput
ref={amountInputRef}
value={amountToTransfer}
onChangeText={amount => {
setAmountToTransfer(amount)
// Any amount change invalidates the quote — it was priced for the
// old one, and paying against it would send the wrong amount.
if (meltQuote) dispatch({type: 'QUOTE_CLEAR'})
}}
selectTextOnFocus={true}
unit={unitRef.current}
editable={!hasQuote && !isSettled}
style={{color: amountInputColor}}
/>
</View>
<Text
size="xs"
tx="payCommon_amountToPayLabel"
style={{
color: amountInputColor,
textAlign: 'center',
marginTop: spacing.extraSmall,
}}
/>
</View>
<View style={$contentContainer}>
{!isSettled && (
<Card
style={$card}
ContentComponent={
<ListItem
tx="onchainTransferScreen_toAddress"
subText={address}
subTextEllipsizeMode="middle"
LeftComponent={
<Icon
containerStyle={$iconContainer}
icon="faBitcoin"
size={spacing.medium}
color={iconColor}
/>
}
style={$item}
/>
}
/>
)}
{!hasQuote && !isSettled && (
<>
<MemoInputCard
memo={memo}
setMemo={setMemo}
onMemoDone={() => {}}
disabled={false}
/>
<Text
size="xxs"
tx="onchainTransferScreen_feeHint"
style={{color: hintText, textAlign: 'center', marginTop: spacing.small}}
/>
<View style={$bottomContainer}>
<View style={$buttonContainer}>
<Button
tx="onchainTransferScreen_requestQuote"
onPress={requestQuote}
/>
</View>
</View>
</>
)}
{hasQuote && !isSettled && (
<Card
style={$card}
ContentComponent={
<ListItem
tx="onchainTransferScreen_networkFee"
subText={translate('onchainTransferScreen_feeTierSubtext', {
blocks: selectedFee!.estimatedBlocks,
amount: numbro(selectedFee!.feeReserve).format({
thousandSeparated: true,
}),
currency: currencyCode,
})}
LeftComponent={
<Icon
containerStyle={$iconContainer}
icon="faClock"
size={spacing.medium}
color={iconColor}
/>
}
RightComponent={
isFeePickable ? (
<Button
preset="tertiary"
tx="onchainTransferScreen_changeFee"
textStyle={{fontSize: 12, color: iconColor}}
onPress={toggleFeeModal}
/>
) : undefined
}
onPress={isFeePickable ? toggleFeeModal : undefined}
style={$item}
/>
}
/>
)}
{hasQuote && !isSettled && availableMintBalances.length > 0 && (
<MintBalanceSelector
mintBalances={availableMintBalances}
selectedMintBalance={mintBalanceToTransferFrom}
unit={unitRef.current}
title={translate('payCommon_payFrom')}
confirmTitle={translate('payCommon_payNow')}
// A mint that cannot melt onchain in this unit cannot pay a Bitcoin
// address. Shown disabled with the reason rather than hidden.
requiredCapability={mint => mint.supportsMelt!('onchain', unitRef.current)}
unsupportedReason={translate('mintSelector_noOnchainPayoutSupport')}
onMintBalanceSelect={onMintBalanceSelect}
onCancel={gotoWallet}
onMintBalanceConfirm={transfer}
/>
)}
{transaction && isSettled && (
<Card
style={{padding: spacing.medium}}
ContentComponent={
<>
<TranItem
label="tranDetailScreen_trasferredTo"
isFirst={true}
value={address}
/>
{!!transaction.outpoint && (
<TranItem
label="tranDetailScreen_outpoint"
value={transaction.outpoint}
/>
)}
<TranItem
label="transactionCommon_feePaid"
value={finalFee || 0}
unit={unitRef.current}
isCurrency={true}
/>
<TranItem
label="tranDetailScreen_status"
value={transaction.status as string}
/>
</>
}
/>
)}
{isSettled && (
<View style={$bottomContainer}>
<View style={$buttonContainer}>
<Button preset="secondary" tx="commonClose" onPress={gotoWallet} />
</View>
</View>
)}
</View>
{/* Fee tier picker */}
<BottomModal
isVisible={isFeeModalVisible}
headingTx="onchainTransferScreen_selectFee"
ContentComponent={
<View style={{alignSelf: 'stretch'}}>
{feeOptions.map((option, index) => (
<ListItem
key={option.feeIndex}
text={translate('onchainTransferScreen_feeTierBlocks', {
blocks: option.estimatedBlocks,
})}
subText={translate('onchainTransferScreen_feeTierReserve', {
amount: numbro(option.feeReserve).format({
thousandSeparated: true,
}),
currency: currencyCode,
})}
leftIcon={
option.feeIndex === selectedFee?.feeIndex
? 'faCheckCircle'
: 'faCircle'
}
leftIconColor={
option.feeIndex === selectedFee?.feeIndex
? colors.palette.success200
: (iconColor as string)
}
onPress={() => dispatch({type: 'SELECT_FEE', option})}
bottomSeparator={index < feeOptions.length - 1}
/>
))}
</View>
}
onBackButtonPress={toggleFeeModal}
onBackdropPress={toggleFeeModal}
/>
{/* Result */}
<BottomModal
isVisible={isResultModalVisible}
ContentComponent={
<>
{resultModalInfo && transactionStatus === TransactionStatus.PENDING && (
<>
{/*
* PENDING is the SUCCESS state here, not a warning. NUT-30 requires
* the mint to answer PENDING and broadcast in the background, so
* every onchain payment lands here — the money has left, and the
* only thing outstanding is confirmation, which takes blocks. The
* user should not be made to wait on this modal, and nothing about
* it should read as though something went wrong.
*/}
<ResultModalInfo
icon="faPaperPlane"
iconColor={colors.palette.success200}
title={translate('onchainTransferScreen_broadcastTitle')}
message={resultModalInfo.message}
/>
<View style={$buttonContainer}>
<Button preset="secondary" tx="commonClose" onPress={gotoWallet} />
</View>
</>
)}
{resultModalInfo && transactionStatus === TransactionStatus.COMPLETED && (
<>
<ResultModalInfo
icon="faCheckCircle"
iconColor={colors.palette.success200}
title={translate('payCommon_completed')}
message={resultModalInfo.message}
/>
<View style={$buttonContainer}>
<Button preset="secondary" tx="commonClose" onPress={gotoWallet} />
</View>
</>
)}
{resultModalInfo && transactionStatus === TransactionStatus.ERROR && (
<>
<ResultModalInfo
icon="faTriangleExclamation"
iconColor={colors.palette.angry500}
title={resultModalInfo.title || translate('payCommon_failed')}
message={resultModalInfo.message}
/>
<View style={$buttonContainer}>
<Button
preset="secondary"
tx="commonClose"
onPress={toggleResultModal}
/>
</View>
</>
)}
{resultModalInfo && transactionStatus === TransactionStatus.REVERTED && (
<>
<ResultModalInfo
icon="faRotate"
iconColor={colors.palette.accent300}
title={translate('transactionCommon_reverted')}
message={resultModalInfo.message}
/>
<View style={$buttonContainer}>
<Button
preset="secondary"
tx="commonClose"
onPress={toggleResultModal}
/>
</View>
</>
)}
</>
}
onBackButtonPress={toggleResultModal}
onBackdropPress={toggleResultModal}
/>
{isLoading && <Loading />}
{error && <ErrorModal error={error} />}
{info && <InfoModal message={info} />}
</Screen>
)
})
const $screen: ViewStyle = {
flex: 1,
}
const $headerContainer: TextStyle = {
alignItems: 'center',
padding: spacing.extraSmall,
paddingTop: 0,
height: spacing.screenHeight * 0.2,
}
const $amountContainer: ViewStyle = {
marginTop: -spacing.tiny,
}
const $contentContainer: TextStyle = {
flex: 1,
padding: spacing.extraSmall,
marginTop: -spacing.extraLarge * 1.5,
}
const $card: ViewStyle = {
marginBottom: spacing.small,
}
const $iconContainer: ViewStyle = {
padding: spacing.extraSmall,
alignSelf: 'center',
marginRight: spacing.medium,
}
const $item: ViewStyle = {
paddingHorizontal: spacing.small,
paddingLeft: 0,
}
const $buttonContainer: ViewStyle = {
flexDirection: 'row',
alignSelf: 'center',
}
const $bottomContainer: ViewStyle = {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
flex: 1,
justifyContent: 'flex-end',
marginBottom: spacing.medium,
alignSelf: 'stretch',
}
@@ -28,9 +28,9 @@ type Props = StaticScreenProps<{
mintUrl?: string, mintUrl?: string,
}> }>
export const LightningPayScreen = function LightningPayScreen({ route }: Props) { export const PayScreen = function PayScreen({ route }: Props) {
const navigation = useNavigation() const navigation = useNavigation()
const lightningInputRef = useRef<TextInput>(null) const paymentInputRef = useRef<TextInput>(null)
const {mintsStore} = useStores() const {mintsStore} = useStores()
const isInternetReachable = useIsInternetReachable() const isInternetReachable = useIsInternetReachable()
@@ -47,6 +47,16 @@ export const LightningPayScreen = function LightningPayScreen({ route }: Props)
setter(resultFromClipboard.encoded) setter(resultFromClipboard.encoded)
sideEffect() sideEffect()
} }
// Auto-paste the RAW clipboard for a Bitcoin destination, not the parsed
// address: a BIP21 URI carries an amount and a label the user should see
// (and which onConfirm re-parses), and silently reducing it to a bare address
// would drop both without saying so.
if (resultFromClipboard.type === IncomingDataType.BTC_ADDRESS) {
setter(clipboard)
sideEffect()
}
return return
} catch (e: any) { } catch (e: any) {
return return
@@ -73,12 +83,12 @@ export const LightningPayScreen = function LightningPayScreen({ route }: Props)
} }
setUnitAndMint() setUnitAndMint()
autoPaste(setLightningData, () => lightningInputRef.current?.blur()) autoPaste(setPaymentData, () => paymentInputRef.current?.blur())
return () => {} return () => {}
}, []) }, [])
const [lightningData, setLightningData] = useState<string | undefined>(undefined) const [paymentData, setPaymentData] = useState<string | undefined>(undefined)
const [unit, setUnit] = useState<MintUnit>('sat') const [unit, setUnit] = useState<MintUnit>('sat')
const [mint, setMint] = useState<Mint | undefined>(undefined) const [mint, setMint] = useState<Mint | undefined>(undefined)
const [error, setError] = useState<AppError | undefined>() const [error, setError] = useState<AppError | undefined>()
@@ -88,16 +98,16 @@ export const LightningPayScreen = function LightningPayScreen({ route }: Props)
const onPaste = async function() { const onPaste = async function() {
const clipboard = await Clipboard.getString() const clipboard = await Clipboard.getString()
if (clipboard.length === 0) { if (clipboard.length === 0) {
infoMessage(translate('lightningPayScreen_onPasteEmptyClipboard')) infoMessage(translate('payScreen_onPasteEmptyClipboard'))
return return
} }
setLightningData(clipboard) setPaymentData(clipboard)
lightningInputRef.current?.blur() paymentInputRef.current?.blur()
} }
const gotoScan = async function () { const gotoScan = async function () {
lightningInputRef.current?.blur() paymentInputRef.current?.blur()
//@ts-ignore //@ts-ignore
navigation.navigate('Scan', { navigation.navigate('Scan', {
mintUrl: mint?.mintUrl, unit mintUrl: mint?.mintUrl, unit
@@ -117,13 +127,13 @@ export const LightningPayScreen = function LightningPayScreen({ route }: Props)
const onConfirm = async function() { const onConfirm = async function() {
if (!lightningData) { if (!paymentData) {
setError({ code: 100, name: Err.VALIDATION_ERROR, message: translate("userErrorMissingLightningData")}) setError({ code: 100, name: Err.VALIDATION_ERROR, message: translate("userErrorMissingPaymentData")})
return return
} }
try { try {
const result = IncomingParser.findAndExtract(lightningData) const result = IncomingParser.findAndExtract(paymentData)
log.trace('[onConfirm]', {mintUrl: mint?.mintUrl, unit, resultType: result.type}) log.trace('[onConfirm]', {mintUrl: mint?.mintUrl, unit, resultType: result.type})
@@ -134,14 +144,20 @@ export const LightningPayScreen = function LightningPayScreen({ route }: Props)
if(result.type === IncomingDataType.LNURL) { if(result.type === IncomingDataType.LNURL) {
await IncomingParser.navigateWithIncomingData(result, navigation, unit, mint && mint.mintUrl) await IncomingParser.navigateWithIncomingData(result, navigation, unit, mint && mint.mintUrl)
} }
if(result.type === IncomingDataType.LNURL_ADDRESS) { if(result.type === IncomingDataType.LNURL_ADDRESS) {
await IncomingParser.navigateWithIncomingData(result, navigation, unit, mint && mint.mintUrl) await IncomingParser.navigateWithIncomingData(result, navigation, unit, mint && mint.mintUrl)
} }
// Bitcoin address / BIP21. findAndExtract has already verified the checksum
// and refused anything that is not mainnet.
if(result.type === IncomingDataType.BTC_ADDRESS) {
await IncomingParser.navigateWithIncomingData(result, navigation, unit, mint && mint.mintUrl)
}
} catch (e: any) { } catch (e: any) {
e.params = lightningData e.params = paymentData
handleError(e) handleError(e)
return return
} }
} }
@@ -157,7 +173,7 @@ export const LightningPayScreen = function LightningPayScreen({ route }: Props)
const handleError = function(e: AppError): void { const handleError = function(e: AppError): void {
// lightningInputRef.current?.blur() // paymentInputRef.current?.blur()
setError(e) setError(e)
} }
@@ -178,7 +194,7 @@ export const LightningPayScreen = function LightningPayScreen({ route }: Props)
<View style={[$headerContainer, {backgroundColor: headerBg}]}> <View style={[$headerContainer, {backgroundColor: headerBg}]}>
<Text <Text
preset="heading" preset="heading"
tx="lightningPayScreen_payHeading" tx="payScreen_payHeading"
style={{color: headerTitle}} style={{color: headerTitle}}
/> />
</View> </View>
@@ -186,9 +202,9 @@ export const LightningPayScreen = function LightningPayScreen({ route }: Props)
<Card <Card
HeadingComponent={ HeadingComponent={
<ListItem <ListItem
leftIcon='faBolt' leftIcon='faPaperPlane'
leftIconColor={colors.palette.orange400} leftIconColor={colors.palette.orange400}
tx="payCommon_payWithLightning" tx="payCommon_payWithBitcoin"
bottomSeparator={true} bottomSeparator={true}
RightComponent={ RightComponent={
<Button <Button
@@ -206,13 +222,13 @@ export const LightningPayScreen = function LightningPayScreen({ route }: Props)
<Text <Text
size='xs' size='xs'
style={{color: hintText, padding: spacing.extraSmall}} style={{color: hintText, padding: spacing.extraSmall}}
tx="payWithLightningDesc" tx="payWithBitcoinDesc"
/> />
<View style={{alignItems: 'center', marginTop: spacing.small}}> <View style={{alignItems: 'center', marginTop: spacing.small}}>
<TextInput <TextInput
ref={lightningInputRef} ref={paymentInputRef}
onChangeText={data => setLightningData(data)} onChangeText={data => setPaymentData(data)}
value={lightningData} value={paymentData}
autoCapitalize='none' autoCapitalize='none'
keyboardType='default' keyboardType='default'
maxLength={500} maxLength={500}
@@ -222,7 +238,7 @@ export const LightningPayScreen = function LightningPayScreen({ route }: Props)
style={[$addressInput, {backgroundColor: inputBg, color: inputText}]} style={[$addressInput, {backgroundColor: inputBg, color: inputText}]}
/> />
</View> </View>
{!!lightningData && lightningData?.length > 1 ? ( {!!paymentData && paymentData?.length > 1 ? (
<View style={$buttonContainer}> <View style={$buttonContainer}>
<Button <Button
preset='default' preset='default'
+26 -7
View File
@@ -23,6 +23,7 @@ import { translate } from '../i18n'
import { MintUnit } from '../services/wallet/currency' import { MintUnit } from '../services/wallet/currency'
import { Mint } from '../models/Mint' import { Mint } from '../models/Mint'
import { CashuUtils } from '../services/cashu/cashuUtils' import { CashuUtils } from '../services/cashu/cashuUtils'
import { BitcoinUtils } from '../services/bitcoin/bitcoinUtils'
import { StaticScreenProps, useNavigation } from '@react-navigation/native' import { StaticScreenProps, useNavigation } from '@react-navigation/native'
const hasAndroidCameraPermission = async () => { const hasAndroidCameraPermission = async () => {
@@ -173,7 +174,7 @@ export const ScanScreen = function ScanScreen({ route }: Props) {
handleError(e) handleError(e)
break break
} }
case 'LightningPay': case 'Pay':
try { try {
const invoiceResult = IncomingParser.findAndExtract(incoming, IncomingDataType.INVOICE) const invoiceResult = IncomingParser.findAndExtract(incoming, IncomingDataType.INVOICE)
return IncomingParser.navigateWithIncomingData(invoiceResult, navigation, unit, mint && mint.mintUrl) return IncomingParser.navigateWithIncomingData(invoiceResult, navigation, unit, mint && mint.mintUrl)
@@ -220,13 +221,13 @@ export const ScanScreen = function ScanScreen({ route }: Props) {
} }
const maybeCashuPaymentRequest = CashuUtils.findEncodedCashuPaymentRequest(incoming) const maybeCashuPaymentRequest = CashuUtils.findEncodedCashuPaymentRequest(incoming)
if(maybeCashuPaymentRequest) { if(maybeCashuPaymentRequest) {
try { try {
log.trace('Found Cashu Payment request instead of an invoice', maybeCashuPaymentRequest, 'onIncomingData') log.trace('Found Cashu Payment request instead of an invoice', maybeCashuPaymentRequest, 'onIncomingData')
const encodedPr = CashuUtils.extractEncodedCashuPaymentRequest(maybeCashuPaymentRequest) const encodedPr = CashuUtils.extractEncodedCashuPaymentRequest(maybeCashuPaymentRequest)
if(encodedPr) { if(encodedPr) {
await IncomingParser.navigateWithIncomingData({ await IncomingParser.navigateWithIncomingData({
type: IncomingDataType.CASHU_PAYMENT_REQUEST, type: IncomingDataType.CASHU_PAYMENT_REQUEST,
encoded: encodedPr encoded: encodedPr
@@ -238,11 +239,29 @@ export const ScanScreen = function ScanScreen({ route }: Props) {
break break
} }
} }
// Bitcoin address / BIP21, last in the chain so a unified QR carrying a
// lightning invoice is taken as an invoice — lightning wins.
const maybeBtcAddress = BitcoinUtils.findBitcoinAddress(incoming)
if(maybeBtcAddress) {
try {
log.trace('Found Bitcoin address instead of an invoice', 'onIncomingData')
// Throws (and reports) on a non-mainnet address, which is the
// likely mistake: fakewallet topup addresses are regtest.
const btcResult = IncomingParser.findAndExtract(maybeBtcAddress, IncomingDataType.BTC_ADDRESS)
await IncomingParser.navigateWithIncomingData(btcResult, navigation, unit, mint && mint.mintUrl)
return
} catch (e4: any) {
handleError(e4)
break
}
}
e.params = incoming e.params = incoming
handleError(e) handleError(e)
break break
} }
default: default:
try { try {
// generic scan button on wallet screen // generic scan button on wallet screen
+171
View File
@@ -63,6 +63,7 @@ import { CommonActions, StaticScreenProps, useFocusEffect, useNavigation } from
import { QRCodeBlock } from './Wallet/QRCode' import { QRCodeBlock } from './Wallet/QRCode'
import { Database } from '../services' import { Database } from '../services'
import { OnchainOperationService } from '../services/wallet/operations/onchainOperations' import { OnchainOperationService } from '../services/wallet/operations/onchainOperations'
import { MeltOperationService } from '../services/wallet/operations/meltOperations'
import { buildBip21Uri } from '../services/wallet/operations/onchainAmounts' import { buildBip21Uri } from '../services/wallet/operations/onchainAmounts'
import { MintListItem } from './Mints/MintListItem' import { MintListItem } from './Mints/MintListItem'
import { Token, TokenMetadata, getDecodedToken, getTokenMetadata } from '@cashu/cashu-ts' import { Token, TokenMetadata, getDecodedToken, getTokenMetadata } from '@cashu/cashu-ts'
@@ -401,6 +402,12 @@ export const TranDetailScreen = observer(function TranDetailScreen({ route }: Pr
navigation={navigation} navigation={navigation}
/> />
)} )}
{transaction.type === TransactionType.TRANSFER_ONCHAIN && (
<OnchainTransferInfoBlock
transaction={transaction}
mint={mint}
/>
)}
{transaction.type === TransactionType.TRANSFER && ( {transaction.type === TransactionType.TRANSFER && (
<TransferInfoBlock <TransferInfoBlock
transaction={transaction} transaction={transaction}
@@ -1556,6 +1563,170 @@ const OnchainTopupInfoBlock = function (props: {
) )
} }
/**
* Detail block for an onchain melt (NUT-30).
*
* The two things worth surfacing here are the ones the user cannot get anywhere else:
* the address their money went to, and the `outpoint` (txid:vout) once the mint has
* broadcast. The outpoint is the only handle they have on the payment that does not
* depend on the mint — with it they can watch it confirm on any block explorer, which
* is exactly what they will want if the mint goes quiet.
*
* "Check status" is the manual counterpart to the pending sweep. Onchain settlement is
* measured in blocks, so the wallet only checks on the ~60s pending cadence; a user
* staring at an unconfirmed payment should not have to wait for it to come round.
*/
const OnchainTransferInfoBlock = function (props: {
transaction: Transaction
mint?: Mint
}) {
const {transaction, mint} = props
const {transactionsStore} = useStores()
const isInternetReachable = useIsInternetReachable()
const [isChecking, setIsChecking] = useState(false)
const [checkResult, setCheckResult] = useState<string | undefined>()
const labelColor = useThemeColor('textDim')
const onCheckStatus = async function () {
if (!isInternetReachable) return
setIsChecking(true)
setCheckResult(undefined)
try {
// Through the queue, NOT straight to refresh. Reconstructing NUT-08 melt change
// reads the keyset counter, and the pending sweep can be doing the same thing at
// the same moment; SyncQueue (concurrency 1) is what serialises them.
await MeltOperationService.enqueuePendingOnchainTransferCheck(transaction.id)
const refreshed = transactionsStore.findById(transaction.id)
if (refreshed?.status === TransactionStatus.PENDING) {
setCheckResult(translate('tranDetail_onchainStillUnconfirmed'))
}
// Anything else settled the transaction; the observer re-renders it.
} catch (e: any) {
setCheckResult(e.message)
} finally {
setIsChecking(false)
}
}
return (
<>
<Card
label='Transaction data'
style={$dataCard}
ContentComponent={
<>
<TranItem
label="tranDetailScreen_amount"
value={transaction.amount}
unit={transaction.unit}
isCurrency={true}
isFirst={true}
/>
<TranItem
label="transactionCommon_feePaid"
value={transaction.fee || 0}
unit={transaction.unit}
isCurrency={true}
/>
{transaction.memo && transaction.memo.length > 0 && (
<TranItem label="receiverMemo" value={transaction.memo as string} />
)}
<TranItem
label="tranDetailScreen_type"
value={transaction.type as string}
/>
<View style={{flexDirection: 'row', justifyContent: 'space-between'}}>
<TranItem
label="tranDetailScreen_status"
value={transaction.status as string}
/>
{isInternetReachable &&
transaction.status === TransactionStatus.PENDING && (
<Button
style={{marginTop: spacing.medium}}
preset="secondary"
tx="tranDetail_checkStatus"
onPress={onCheckStatus}
disabled={isChecking}
/>
)}
</View>
{checkResult && (
<Text
text={checkResult}
preset="formHelper"
style={{color: labelColor}}
/>
)}
{transaction.status === TransactionStatus.COMPLETED && (
<TranItem
label="tranDetailScreen_balanceAfter"
value={transaction.balanceAfter || 0}
unit={transaction.unit}
isCurrency={true}
/>
)}
<TranItem
label="tranDetailScreen_createdAt"
value={(transaction.createdAt as Date).toLocaleString()}
/>
<TranItem label="tranDetailScreen_id" value={`${transaction.id}`} />
</>
}
/>
<Card
labelTx='tranDetailScreen_trasferredTo'
style={$dataCard}
ContentComponent={
<>
{mint ? (
<MintListItem mint={mint} isSelectable={false} isUnitVisible={false} />
) : (
<Text text={transaction.mint} />
)}
<PaymentRequestItem transaction={transaction} />
{!!transaction.outpoint && (
<OutpointItem outpoint={transaction.outpoint} />
)}
</>
}
/>
</>
)
}
/**
* The onchain transaction the mint broadcast, as `txid:vout`.
*
* Copyable, because what a user does with it is paste it into a block explorer.
*/
const OutpointItem = function (props: {outpoint: string}) {
const labelColor = useThemeColor('textDim')
return (
<ListItem
leftIcon='faCubes'
leftIconColor={labelColor as string}
tx="tranDetailScreen_outpoint"
subText={props.outpoint}
subTextEllipsizeMode='middle'
topSeparator={true}
RightComponent={
<Button
preset='tertiary'
onPress={() => Clipboard.setString(props.outpoint)}
tx='commonCopy'
textStyle={{fontSize: 12, color: labelColor}}
/>
}
/>
)
}
const TopupInfoBlock = function (props: { const TopupInfoBlock = function (props: {
transaction: Transaction transaction: Transaction
isDataParsable: boolean isDataParsable: boolean
+7 -7
View File
@@ -540,19 +540,19 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
} }
const gotoLightningPay = function (mintUrl?: string) { const gotoPay = function (mintUrl?: string) {
log.trace({mintUrl}) log.trace({mintUrl})
if(mintUrl) { if(mintUrl) {
setIsMintsModalVisible(false) setIsMintsModalVisible(false)
// @ts-ignore // @ts-ignore
navigation.navigate('LightningPay', { navigation.navigate('Pay', {
mintUrl, mintUrl,
unit: currentUnit unit: currentUnit
}) })
} else { } else {
setIsSendModalVisible(false) setIsSendModalVisible(false)
// @ts-ignore // @ts-ignore
navigation.navigate('LightningPay', { navigation.navigate('Pay', {
unit: currentUnit unit: currentUnit
}) })
} }
@@ -915,7 +915,7 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
mintsByUnit={groupedMints} mintsByUnit={groupedMints}
currentUnit={currentUnit} currentUnit={currentUnit}
onTopup={gotoTopup} onTopup={gotoTopup}
onLightningPay={gotoLightningPay} onPay={gotoPay}
onMintInfo={gotoMintInfo} onMintInfo={gotoMintInfo}
/> />
} }
@@ -952,7 +952,7 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
leftIcon='faBitcoin' leftIcon='faBitcoin'
tx="walletScreen_optionBitcoin" tx="walletScreen_optionBitcoin"
subTx="walletScreen_optionBitcoinSendDesc" subTx="walletScreen_optionBitcoinSendDesc"
onPress={() => gotoLightningPay()} onPress={() => gotoPay()}
//bottomSeparator={true} //bottomSeparator={true}
/> />
{/*<ListItem {/*<ListItem
@@ -1099,7 +1099,7 @@ const MintsByUnitList = observer(function (props: {
mintsByUnit: MintsByUnit[] mintsByUnit: MintsByUnit[]
currentUnit: MintUnit currentUnit: MintUnit
onTopup: Function onTopup: Function
onLightningPay: Function onPay: Function
onMintInfo: Function onMintInfo: Function
}) { }) {
@@ -1193,7 +1193,7 @@ const MintsByUnitList = observer(function (props: {
)} )}
textStyle={{fontSize: 14, color}} textStyle={{fontSize: 14, color}}
preset='secondary' preset='secondary'
onPress={() => props.onLightningPay(mint.mintUrl)} onPress={() => props.onPay(mint.mintUrl)}
style={{ style={{
minHeight: verticalScale(40), minHeight: verticalScale(40),
paddingVertical: verticalScale(spacing.tiny), paddingVertical: verticalScale(spacing.tiny),
+2 -1
View File
@@ -14,8 +14,9 @@ export * from './ScanScreen'
export * from './TranDetailScreen' export * from './TranDetailScreen'
export * from './TranHistoryScreen' export * from './TranHistoryScreen'
export * from './TransferScreen' export * from './TransferScreen'
export * from './OnchainTransferScreen'
export * from './TopupScreen' export * from './TopupScreen'
export * from './LightningPayScreen' export * from './PayScreen'
export * from './TokenReceiveScreen' export * from './TokenReceiveScreen'
export * from './CashuPaymentRequestScreen' export * from './CashuPaymentRequestScreen'
export * from './NfcPayScreen' export * from './NfcPayScreen'
+104 -2
View File
@@ -1,6 +1,7 @@
import { ReceiveOption, SendOption, TransferOption } from '../screens' import { ReceiveOption, SendOption, TransferOption } from '../screens'
import AppError, { Err } from '../utils/AppError' import AppError, { Err } from '../utils/AppError'
import { log } from './logService' import { log } from './logService'
import { BitcoinUtils } from './bitcoin/bitcoinUtils'
import { CashuUtils } from './cashu/cashuUtils' import { CashuUtils } from './cashu/cashuUtils'
import { LightningUtils } from './lightning/lightningUtils' import { LightningUtils } from './lightning/lightningUtils'
import { LnurlUtils } from './lnurl/lnurlUtils' import { LnurlUtils } from './lnurl/lnurlUtils'
@@ -17,7 +18,53 @@ export enum IncomingDataType {
LNURL = 'LNURL', LNURL = 'LNURL',
LNURL_ADDRESS = 'LNURL_ADDRESS', LNURL_ADDRESS = 'LNURL_ADDRESS',
MINT_URL = 'MINT_URL', MINT_URL = 'MINT_URL',
NPUB_OR_HEX = 'NPUB_OR_HEX', NPUB_OR_HEX = 'NPUB_OR_HEX',
/** A Bitcoin address or BIP21 URI, to be paid by an onchain melt (NUT-30). */
BTC_ADDRESS = 'BTC_ADDRESS',
}
/**
* What a scanned/pasted Bitcoin destination resolved to.
*
* The address is always normalized (lowercased bech32), because QR encoders uppercase
* bech32 to stay in alphanumeric mode and this string goes to the mint.
*/
export type BtcAddressData = {
address: string
/** BIP21 `amount`, in sats. A hint from the payee — the user may change it. */
amountSat?: number
/** BIP21 `label`/`message`, offered as the transaction memo. */
memo?: string
}
/**
* Refuse a Bitcoin address we are not willing to pay.
*
* Mainnet only, and checksum-verified. Both failures get their own message, because
* "that is not a Bitcoin address" and "that is a Bitcoin address on the wrong network"
* are completely different mistakes — and the second one is the likely one: the CDK
* fakewallet hands out REGTEST addresses (`bcrt1q…`) for onchain topup quotes, so anyone
* testing this wallet has one sitting in their clipboard.
*/
const assertPayableBtcAddress = function (address: string): string {
const decoded = BitcoinUtils.decodeBitcoinAddress(address)
if (!decoded) {
throw new AppError(Err.VALIDATION_ERROR, 'This is not a valid Bitcoin address.', {
caller: 'assertPayableBtcAddress',
address,
})
}
if (decoded.network !== 'mainnet') {
throw new AppError(
Err.VALIDATION_ERROR,
`This is a ${decoded.network} Bitcoin address. Minibits can only pay to mainnet addresses.`,
{caller: 'assertPayableBtcAddress', address, network: decoded.network},
)
}
return decoded.address
} }
const findAndExtract = function ( const findAndExtract = function (
@@ -67,6 +114,27 @@ const findAndExtract = function (
type: expectedType, type: expectedType,
encoded encoded
} }
case IncomingDataType.BTC_ADDRESS: {
const bip21 = BitcoinUtils.parseBip21(incomingData)
if (bip21) {
return {
type: expectedType,
encoded: {
address: assertPayableBtcAddress(bip21.address), // throws
amountSat: bip21.amountSat,
memo: bip21.label || bip21.message,
} as BtcAddressData
}
}
return {
type: expectedType,
encoded: {
address: assertPayableBtcAddress(incomingData), // throws
} as BtcAddressData
}
}
case IncomingDataType.MINT_URL: case IncomingDataType.MINT_URL:
const url = new URL(incomingData) // throws const url = new URL(incomingData) // throws
@@ -172,6 +240,22 @@ const findAndExtract = function (
} }
} }
// Bitcoin address / BIP21, checked AFTER lightning.
//
// LIGHTNING WINS on a unified QR. A `bitcoin:` URI carrying a `lightning=` invoice
// offers the payee's preference in both rails, and lightning is the cheaper and
// faster of the two for everyone involved — so if the payload contains an invoice
// we take it, and the onchain address is simply not used. `findEncodedLightningInvoice`
// above already matched it out of the URI, which is why this ordering is the whole
// implementation of the rule.
const maybeBtcAddress = BitcoinUtils.findBitcoinAddress(incomingData)
if(maybeBtcAddress) {
log.trace('[findAndExtract] Got maybeBtcAddress')
return findAndExtract(maybeBtcAddress, IncomingDataType.BTC_ADDRESS) // throws
}
const maybeMintUrl = new URL(incomingData) // throws const maybeMintUrl = new URL(incomingData) // throws
if(incomingData.startsWith('http')) { if(incomingData.startsWith('http')) {
@@ -296,13 +380,31 @@ const navigateWithIncomingData = async function (
}) })
} }
case IncomingDataType.BTC_ADDRESS: {
const btc = incoming.encoded as BtcAddressData
//@ts-ignore
return navigation.navigate('WalletNavigator', {
screen: 'TransferOnchain',
params: {
address: btc.address,
// BIP21 amount and label are HINTS from the payee. They pre-fill the
// screen; the user stays free to change both, and the mint prices the
// payment from what is actually confirmed.
amountSat: btc.amountSat,
memo: btc.memo,
unit,
mintUrl
}
})
}
case IncomingDataType.MINT_URL: case IncomingDataType.MINT_URL:
//@ts-ignore //@ts-ignore
return navigation.navigate('WalletNavigator', { return navigation.navigate('WalletNavigator', {
screen: 'Wallet', screen: 'Wallet',
params: { params: {
scannedMintUrl: incoming.encoded, scannedMintUrl: incoming.encoded,
} }
}) })
case IncomingDataType.NPUB_OR_HEX: case IncomingDataType.NPUB_OR_HEX: