diff --git a/__tests__/transferMethods.test.ts b/__tests__/transferMethods.test.ts new file mode 100644 index 00000000..e62f9b46 --- /dev/null +++ b/__tests__/transferMethods.test.ts @@ -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) + }) +}) diff --git a/src/i18n_messages/en.json b/src/i18n_messages/en.json index bb6fbb03..5b71d29c 100644 --- a/src/i18n_messages/en.json +++ b/src/i18n_messages/en.json @@ -181,8 +181,8 @@ "invalidRelayUrl": "Invalid relay URL.", "lightningAddress": "Lightning address", "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.", - "lightningPayScreen_payHeading": "Pay", + "payScreen_onPasteEmptyClipboard": "First copy a Lightning invoice, address, pay code or a Bitcoin address to pay to. Then paste.", + "payScreen_payHeading": "Pay", "loadingPublicInfo": "Loading public info", "localBackupDisabled": "Local backup is off", "localBackupEnabled": "Local backup is on", @@ -303,8 +303,19 @@ "payCommon_payFromWallet": "Pay from wallet", "payCommon_payMe": "Pay me", "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_payWithLightning": "Pay with Lightning", + "payCommon_payWithBitcoin": "Pay with Bitcoin", "payCommon_receive": "Receive", "payCommon_receiveEcash": "Receive ecash", "payCommon_receiveInPerson": "Receive in person", @@ -318,7 +329,7 @@ "pos_scanToPay": "Scan to pay", "pos_waitingForPayment": "Waiting for payment...", "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.", "placeholderNpub": "npub...", "placeholderRelay": "wss://...", @@ -618,6 +629,7 @@ "tranDetailScreen_sentFrom": "Sent from", "tranDetailScreen_sentTo": "Sent to", "tranDetailScreen_status": "Status", + "tranDetailScreen_outpoint": "Bitcoin transaction", "tranDetailScreen_successRetrieveEcashAfterRetry": "Ecash has been successfully received after retry. New transaction has been created.", "tranDetailScreen_tokenTracking": "Token tracking", "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_updateNow": "Update and restart", "updateScreen_updateSize": "Download size", - "userErrorMissingLightningData": "Missing Lightning invoice, paycode or address.", + "userErrorMissingPaymentData": "Missing Lightning invoice, paycode, address or Bitcoin address.", "wallerScreen_mintButton": "Mint", "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.", @@ -752,12 +764,15 @@ "welcomeScreen_terms_agreePrivacy": "Privacy Policy", "mintSelector_noTopupSupport": "Top up 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_ligtningInvoice": "Invoice", "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_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_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_onchainOutpoint": "Bitcoin transaction", "transactionType_topupOnchain": "Topup onchain", diff --git a/src/i18n_messages/es.json b/src/i18n_messages/es.json index dd8db427..e8b5ae62 100644 --- a/src/i18n_messages/es.json +++ b/src/i18n_messages/es.json @@ -180,8 +180,8 @@ "invalidRelayUrl": "URL de retransmisión no válida.", "lightningAddress": "Dirección de Lightning", "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.", - "lightningPayScreen_payHeading": "Pagar", + "payScreen_onPasteEmptyClipboard": "Primero copia una factura Lightning, dirección, código de pago o una dirección Bitcoin a la que pagar. Luego pega.", + "payScreen_payHeading": "Pagar", "loadingPublicInfo": "Cargando información pública", "localBackupDisabled": "La copia de seguridad local está desactivada", "localBackupEnabled": "La copia de seguridad local está activada", @@ -302,8 +302,19 @@ "payCommon_payFromWallet": "Pagar desde la billetera", "payCommon_payMe": "Pagame", "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_payWithLightning": "Pagar con Lightning", + "payCommon_payWithBitcoin": "Pagar con Bitcoin", "payCommon_receive": "Recibir", "payCommon_receiveEcash": "Recibir ecash", "payCommon_receiveInPerson": "Recibir en persona", @@ -317,7 +328,7 @@ "pos_scanToPay": "Escanea para pagar", "pos_waitingForPayment": "Esperando el pago...", "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.", "placeholderNpub": "npub...", "placeholderRelay": "wss://...", @@ -617,6 +628,7 @@ "tranDetailScreen_sentFrom": "Enviado desde", "tranDetailScreen_sentTo": "Enviado a", "tranDetailScreen_status": "Estado", + "tranDetailScreen_outpoint": "Transacción Bitcoin", "tranDetailScreen_successRetrieveEcashAfterRetry": "El eCash se recibió correctamente tras reintentarlo. Se creó una nueva transacción.", "tranDetailScreen_tokenTracking": "Seguimiento de tokens", "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_updateNow": "Actualizar y reiniciar", "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", "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.", @@ -751,11 +763,14 @@ "welcomeScreen_terms_agreePrivacy": "Política de Privacidad", "mintSelector_noTopupSupport": "Recargas no soportadas 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_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_onchainConfirmationsNeeded": "Tu ecash aparecerá cuando el depósito se confirme en la red Bitcoin.", "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_onchainOutpoint": "Transacción Bitcoin", "transactionType_topupOnchain": "Recarga onchain", diff --git a/src/i18n_messages/pt.json b/src/i18n_messages/pt.json index cc2f99a0..5a2733aa 100644 --- a/src/i18n_messages/pt.json +++ b/src/i18n_messages/pt.json @@ -181,8 +181,8 @@ "invalidRelayUrl": "URL de relay inválido.", "lightningAddress": "Endereço Lightning", "lightningInvoiceSharedWaiting": "A invoice Lightning foi compartilhada, aguardando pagamento.", - "lightningPayScreen_onPasteEmptyClipboard": "Primeiro copie invoice Lightning, endereço ou código de pagamento. Depois cole.", - "lightningPayScreen_payHeading": "Pagar", + "payScreen_onPasteEmptyClipboard": "Primeiro copie uma fatura Lightning, endereço, código de pagamento ou um endereço Bitcoin para pagar. Depois cole.", + "payScreen_payHeading": "Pagar", "loadingPublicInfo": "Carregando informações públicas", "localBackupDisabled": "Backup local desativado", "localBackupEnabled": "Backup local ativado", @@ -303,8 +303,19 @@ "payCommon_payFromWallet": "Pagar da carteira", "payCommon_payMe": "Pague-me", "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_payWithLightning": "Pagar com Lightning", + "payCommon_payWithBitcoin": "Pagar com Bitcoin", "payCommon_receive": "Receber", "payCommon_receiveEcash": "Receber ecash", "payCommon_receiveInPerson": "Receber pessoalmente", @@ -318,7 +329,7 @@ "pos_scanToPay": "Escaneie para pagar", "pos_waitingForPayment": "Aguardando pagamento...", "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.", "placeholderNpub": "npub...", "placeholderRelay": "wss://...", @@ -618,6 +629,7 @@ "tranDetailScreen_sentFrom": "Enviado de", "tranDetailScreen_sentTo": "Enviado para", "tranDetailScreen_status": "Status", + "tranDetailScreen_outpoint": "Transação Bitcoin", "tranDetailScreen_successRetrieveEcashAfterRetry": "Ecash recebido com sucesso após nova tentativa. Nova transação criada.", "tranDetailScreen_tokenTracking": "Rastreamento de token", "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_updateNow": "Atualizar e reiniciar", "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", "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.", @@ -752,11 +764,14 @@ "welcomeScreen_terms_agreePrivacy": "Política de Privacidade", "mintSelector_noTopupSupport": "Carregamentos 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_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_onchainConfirmationsNeeded": "O seu ecash aparecerá assim que o depósito for confirmado na rede Bitcoin.", "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_onchainOutpoint": "Transação Bitcoin", "transactionType_topupOnchain": "Carregamento onchain", diff --git a/src/i18n_messages/sk.json b/src/i18n_messages/sk.json index 2e8e2372..27e58adb 100644 --- a/src/i18n_messages/sk.json +++ b/src/i18n_messages/sk.json @@ -181,8 +181,8 @@ "invalidRelayUrl": "Neplatná relay URL adresa.", "lightningAddress": "Lightning adresa", "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ž.", - "lightningPayScreen_payHeading": "Zaplať", + "payScreen_onPasteEmptyClipboard": "Najprv skopírujte Lightning faktúru, adresu, platobný kód alebo Bitcoin adresu, ktorej chcete zaplatiť. Potom vložte.", + "payScreen_payHeading": "Zaplatiť", "loadingPublicInfo": "Načítavam verejné informácie", "localBackupDisabled": "Lokálna záloha je vypnutá", "localBackupEnabled": "Lokálna záloha je zapnutá", @@ -303,8 +303,19 @@ "payCommon_payFromWallet": "Zaplať z peňaženky", "payCommon_payMe": "Zaplať mi", "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_payWithLightning": "Zaplať cez Lightning", + "payCommon_payWithBitcoin": "Zaplatiť Bitcoinom", "payCommon_receive": "Prijmi", "payCommon_receiveEcash": "Prijmy ecash", "payCommon_receiveInPerson": "Prijmy osobne", @@ -318,7 +329,7 @@ "pos_scanToPay": "Naskenuj a zaplať", "pos_waitingForPayment": "Čakám na platbu...", "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.", "placeholderNpub": "npub...", "placeholderRelay": "wss://...", @@ -618,6 +629,7 @@ "tranDetailScreen_sentFrom": "Poslané od", "tranDetailScreen_sentTo": "Poslané komu", "tranDetailScreen_status": "Status", + "tranDetailScreen_outpoint": "Bitcoin transakcia", "tranDetailScreen_successRetrieveEcashAfterRetry": "Ecash bol prijatý po opätovnom opakovaní. Nová transakcia vytvorená.", "tranDetailScreen_tokenTracking": "Sledovanie tokenov", "tranDetailScreen_tokenTrackingDesc": "Zaznamenané ecash tokeny na účely ladenia", @@ -710,7 +722,7 @@ "updateScreen_updateNotAvailableDesc": "Fajn, máš najnovšiu verziu aplikácie.", "updateScreen_updateNow": "Aktualizuj a reštartuj", "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", "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.", @@ -752,11 +764,14 @@ "welcomeScreen_terms_agreePrivacy": "Zásadami ochrany osobných údajov", "mintSelector_noTopupSupport": "Dobitie nie je 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_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_onchainConfirmationsNeeded": "Váš ecash sa objaví, keď sa vklad potvrdí v sieti Bitcoin.", "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_onchainOutpoint": "Bitcoin transakcia", "transactionType_topupOnchain": "Dobitie onchain", diff --git a/src/navigation/TabsNavigator.tsx b/src/navigation/TabsNavigator.tsx index 2d4a6e51..25eface3 100644 --- a/src/navigation/TabsNavigator.tsx +++ b/src/navigation/TabsNavigator.tsx @@ -10,7 +10,8 @@ import { ReceiveScreen, SendScreen, ScanScreen, - LightningPayScreen, + PayScreen, + OnchainTransferScreen, ContactsScreen, PictureScreen, ContactDetailScreen, @@ -51,8 +52,9 @@ const WalletStack = createNativeStackNavigator({ Receive: ReceiveScreen, Send: SendScreen, Scan: ScanScreen, - LightningPay: LightningPayScreen, + Pay: PayScreen, Transfer: TransferScreen, + TransferOnchain: OnchainTransferScreen, Topup: TopupScreen, CashuPaymentRequest: CashuPaymentRequestScreen, //POS: POSScreen, diff --git a/src/screens/OnchainTransferScreen.tsx b/src/screens/OnchainTransferScreen.tsx new file mode 100644 index 00000000..63190616 --- /dev/null +++ b/src/screens/OnchainTransferScreen.tsx @@ -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(null) + const unitRef = useRef('sat') + + const {proofsStore, mintsStore, walletStore} = useStores() + const isInternetReachable = useIsInternetReachable() + + const [state, dispatch] = useReducer(onchainTransferReducer, INITIAL_STATE) + const [amountToTransfer, setAmountToTransfer] = useState('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 ( + + + + + { + 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}} + /> + + + + + + {!isSettled && ( + + } + style={$item} + /> + } + /> + )} + + {!hasQuote && !isSettled && ( + <> + {}} + disabled={false} + /> + + + +