diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 4eb7d413..1970cc99 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
+
+
+export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
+ const navigation = useNavigation()
+ const { mintsStore, walletStore, proofsStore } = useStores()
+ const unitRef = useRef('sat')
+ const isInternetReachable = useIsInternetReachable()
+
+ const [paymentOption, setPaymentOption] = useState(TransferOption.PASTE_OR_SCAN_INVOICE)
+ const [encodedTokenToSend, setEncodedTokenToSend] = useState()
+ const [amountToPay, setAmountToPay] = useState()
+ const [encodedCashuPaymentRequest, setEncodedCashuPaymentRequest] = useState()
+ const [decodedCashuPaymentRequest, setDecodedCashuPaymentRequest] = useState()
+ const [encodedInvoice, setEncodedInvoice] = useState('')
+ const [invoice, setInvoice] = useState()
+ const [invoiceExpiry, setInvoiceExpiry] = useState()
+ const [meltQuote, setMeltQuote] = useState()
+ const [memo, setMemo] = useState('')
+ const [mintBalanceToSendFrom, setMintBalanceToSendFrom] = useState()
+
+ const [transactionStatus, setTransactionStatus] = useState()
+ const [transaction, setTransaction] = useState()
+ const [transactionId, setTransactionId] = useState()
+ const [resultModalInfo, setResultModalInfo] = useState<{status: TransactionStatus, title?: string, message: string} | undefined>()
+ const [isLoading, setIsLoading] = useState(false)
+
+ const [isSendTaskSentToQueue, setIsSendTaskSentToQueue] = useState(false)
+ const [isTransferTaskSentToQueue, setIsTransferTaskSentToQueue] = useState(false)
+ const [isResultModalVisible, setIsResultModalVisible] = useState(false)
+ // NFC state
+ const [isNfcEnabled, setIsNfcEnabled] = useState(false)
+ const [isNfcSupported, setIsNfcSupported] = useState(false)
+ const [isProcessing, setIsProcessing] = useState(false)
+ const [nfcInfo, setNfcInfo] = useState()
+ const [readNfcData, setReadNfcData] = useState()
+ const [mint, setMint] = useState()
+
+ const [error, setError] = useState()
+ const [info, setInfo] = useState('')
+
+ // Start NFC only when this screen is visible
+ useEffect(() => {
+ const initNfc = async () => {
+ try {
+ const supported = await NfcManager.isSupported()
+ setIsNfcSupported(supported)
+ if (!supported) {
+ setNfcInfo('NFC is not supported on this device')
+ return
+ }
+
+ await NfcManager.start()
+
+ const enabled = await NfcManager.isEnabled()
+ if (!enabled) {
+ setNfcInfo('Please enable NFC in your device settings')
+ return
+ }
+
+ setIsNfcEnabled(true)
+
+ // Start listening for tags immediately
+ startNfcSession()
+ } catch (e: any) {
+ log.error('Failed to initialize NFC', e.message)
+ setNfcInfo('Failed to initialize NFC: ' + e.message)
+ // setError(new AppError(Err.NFC_ERROR, 'Failed to initialize NFC', e.message))
+ }
+ }
+
+ initNfc()
+
+ return () => {
+ NfcManager.cancelTechnologyRequest().catch(() => {})
+ NfcManager.setEventListener(NfcEvents.DiscoverTag, null)
+ }
+ }, [])
+
+ // Auto-restart session when screen comes into focus
+ useEffect(() => {
+ const unsubscribe = navigation.addListener('focus', () => {
+ if (isNfcEnabled && !isProcessing) {
+ startNfcSession()
+ }
+ })
+ return unsubscribe
+ }, [navigation, isNfcEnabled, isProcessing])
+
+
+ const sendTaskListenerRef = useRef<((result: TransactionTaskResult) => void) | null>(null);
+
+ const handleSendTaskResult = useCallback(
+ async (result: TransactionTaskResult) => {
+ log.trace('[NfcScreen] handleSendTaskResult triggered', { result })
+
+ try {
+ const { transaction, error, encodedTokenToSend } = result
+
+ // Always update core transaction state
+ if (transaction) {
+ setTransactionStatus(transaction.status)
+ setTransaction(transaction)
+ setTransactionId(transaction.id)
+
+ // Optional: link back to original payment request
+ if (decodedCashuPaymentRequest?.id || encodedCashuPaymentRequest) {
+ transaction.update({
+ paymentId: decodedCashuPaymentRequest?.id,
+ paymentRequest: encodedCashuPaymentRequest,
+ });
+ }
+ }
+
+ // ———————— Error Cases ————————
+ if (error || !transaction) {
+ const message = error?.params?.message || error?.message || 'Unknown error'
+ const title = error ? 'Payment failed' : 'Internal error'
+
+ setResultModalInfo({
+ status: (transaction?.status || TransactionStatus.ERROR) as TransactionStatus,
+ title,
+ message,
+ });
+ toggleResultModal()
+ return
+ }
+
+ // ———————— Missing Token ————————
+ if (!encodedTokenToSend) {
+ setResultModalInfo({
+ status: TransactionStatus.ERROR,
+ title: 'Internal error',
+ message: 'Failed to generate ecash token',
+ });
+ toggleResultModal()
+ return
+ }
+
+ // ———————— Success Path ————————
+ setEncodedTokenToSend(encodedTokenToSend)
+ // Send over NFC to payee's POS or wallet
+ await writeTokenToNfcTag(encodedTokenToSend)
+
+ } catch (err: any) {
+ log.error('Unexpected error in handleSendTaskResult', err)
+
+ setResultModalInfo({
+ status: TransactionStatus.ERROR,
+ title: 'Unexpected error',
+ message: err.message || 'Something went wrong',
+ })
+
+ toggleResultModal()
+ }
+ },
+ [
+ decodedCashuPaymentRequest?.id,
+ encodedCashuPaymentRequest,
+ ]
+ )
+
+
+ useEffect(() => {
+ if (!isSendTaskSentToQueue) {
+ // optional: make sure no stray listener exists
+ if (sendTaskListenerRef.current) {
+ EventEmitter.off(`ev_${SEND_TASK}_result`, sendTaskListenerRef.current)
+ sendTaskListenerRef.current = null
+ }
+ return
+ }
+
+ const eventName = `ev_${SEND_TASK}_result`
+
+ // Remove previous (defensive)
+ if (sendTaskListenerRef.current) {
+ EventEmitter.off(eventName, sendTaskListenerRef.current)
+ }
+
+ const oneTimeHandler = (result: TransactionTaskResult) => {
+ // Immediately detach so it can never fire twice
+ EventEmitter.off(eventName, oneTimeHandler)
+ sendTaskListenerRef.current = null
+
+ // Forward to the stable callback
+ handleSendTaskResult(result)
+ }
+
+ sendTaskListenerRef.current = oneTimeHandler
+ EventEmitter.on(eventName, oneTimeHandler)
+
+ return () => {
+ if (sendTaskListenerRef.current) {
+ EventEmitter.off(eventName, sendTaskListenerRef.current)
+ sendTaskListenerRef.current = null
+ }
+ }
+ }, [isSendTaskSentToQueue, handleSendTaskResult])
+
+
+
+
+ // Stable handler — only recreated when dependencies actually change
+ const handleSyncStateResult = useCallback(
+ async (result: SyncStateTaskResult) => {
+ log.trace('[NfcScreen] handleSyncStateResult triggered', { result, transactionId });
+
+ if (!transactionId) return;
+
+ const { completedTransactionIds, errorTransactionIds, transactionStateUpdates } = result;
+
+ // ——— SUCCESS: Receiver claimed the ecash ———
+ if (completedTransactionIds?.includes(transactionId)) {
+ log.debug('[NfcScreen] Ecash claimed successfully by the payee.', { transactionId });
+
+ const amountSentInt = Math.round(
+ (toNumber(amountToPay || '0') * getCurrency(unitRef.current).precision)
+ );
+
+ const currency = getCurrency(unitRef.current);
+
+ setResultModalInfo({
+ status: TransactionStatus.COMPLETED,
+ title: 'That was fast!',
+ message: `${formatCurrency(amountSentInt, currency.code)} ${currency.code} were received by the payee.`,
+ });
+ setTransactionStatus(TransactionStatus.COMPLETED);
+ setIsResultModalVisible(true);
+ return;
+ }
+
+ // ——— ERROR: Sync detected mismatch or failure ———
+ if (errorTransactionIds?.includes(transactionId)) {
+ log.trace('[NfcScreen] Sync error for transaction', { transactionId });
+
+ const update = transactionStateUpdates?.find(u => u.tId === transactionId);
+ const message = update?.message || 'Transaction failed to complete on the mint.'
+
+ setResultModalInfo({
+ status: TransactionStatus.ERROR,
+ title: 'Send failed',
+ message,
+ });
+ setTransactionStatus(TransactionStatus.ERROR)
+ setIsResultModalVisible(true)
+ }
+ },
+ [
+ transactionId,
+ amountToPay,
+ ],
+ );
+
+ // One-time listener with auto-cleanup using ref
+ const syncStateListenerRef = useRef<((r: SyncStateTaskResult) => void) | null>(null)
+
+ useEffect(() => {
+ // If no transactionId → nothing to listen for
+ if (!transactionId) {
+ // Clean up any dangling listener
+ if (syncStateListenerRef.current) {
+ EventEmitter.off(`ev_${SYNC_STATE_WITH_MINT_TASK}_result`, syncStateListenerRef.current)
+ syncStateListenerRef.current = null
+ }
+ return
+ }
+
+ const eventName = `ev_${SYNC_STATE_WITH_MINT_TASK}_result`
+
+ // Remove previous listener (defensive)
+ if (syncStateListenerRef.current) {
+ EventEmitter.off(eventName, syncStateListenerRef.current)
+ }
+
+ // One-time wrapper — auto-removes itself after first fire
+ const oneTimeHandler = (result: SyncStateTaskResult) => {
+ // Immediately detach to prevent double execution
+ EventEmitter.off(eventName, oneTimeHandler)
+ syncStateListenerRef.current = null
+
+ // Forward to stable callback
+ handleSyncStateResult(result)
+ };
+
+ syncStateListenerRef.current = oneTimeHandler
+ EventEmitter.on(eventName, oneTimeHandler)
+
+ // Cleanup on unmount or when transactionId changes/becomes null
+ return () => {
+ if (syncStateListenerRef.current) {
+ EventEmitter.off(eventName, syncStateListenerRef.current)
+ syncStateListenerRef.current = null
+ }
+ }
+ }, [
+ handleSyncStateResult, // ← stable thanks to useCallback
+ ])
+
+
+ // ====================== Lightning Payment (Transfer Task) Result Handler ======================
+
+ const handleTransferTaskResult = useCallback(
+ async (result: TransactionTaskResult) => {
+ log.trace('[NfcScreen] handleTransferTaskResult triggered', {result})
+
+ setIsLoading(false)
+
+ const { transaction, error, message, finalFee } = result
+
+ // ——— Early error before transaction exists ———
+ if (!transaction && error) {
+ setTransactionStatus(TransactionStatus.ERROR)
+
+ setResultModalInfo({
+ status: TransactionStatus.ERROR,
+ title: translate('payCommon_failed'),
+ message: error.message || 'Lightning payment failed',
+ })
+
+ toggleResultModal()
+ return;
+ }
+
+ // ——— Transaction exists ———
+ if (transaction) {
+ const { status } = transaction;
+ setTransactionStatus(status);
+ setTransaction(transaction);
+
+ if (error) {
+ // Pending but timed out / failed
+ if (status === TransactionStatus.PENDING) {
+ setResultModalInfo({
+ status,
+ message,
+ })
+ } else {
+ setResultModalInfo({
+ status,
+ title: error.params?.message ? error.message : translate('payCommon_failed'),
+ message: error.params?.message || error.message || 'Lightning payment failed',
+ })
+ }
+ } else {
+ // Success or settled pending
+ setResultModalInfo({
+ status,
+ message: message || 'Lightning payment successful!',
+ title: status === TransactionStatus.COMPLETED ? 'Payment sent!' : undefined,
+ });
+ }
+ } else {
+ // Fallback (shouldn't happen)
+ setResultModalInfo({
+ status: TransactionStatus.ERROR,
+ title: 'Unknown error',
+ message: 'No transaction data received',
+ })
+ }
+
+ toggleResultModal()
+ },
+ [isTransferTaskSentToQueue],
+ )
+
+ // One-time listener with auto-cleanup
+ const transferTaskListenerRef = useRef<((r: TransactionTaskResult) => void) | null>(null);
+
+ useEffect(() => {
+ if (!isTransferTaskSentToQueue) {
+ if (transferTaskListenerRef.current) {
+ EventEmitter.off(`ev_${TRANSFER_TASK}_result`, transferTaskListenerRef.current)
+ transferTaskListenerRef.current = null
+ }
+ return
+ }
+
+ const eventName = `ev_${TRANSFER_TASK}_result`
+
+ // Remove any previous listener (defensive)
+ if (transferTaskListenerRef.current) {
+ EventEmitter.off(eventName, transferTaskListenerRef.current)
+ }
+
+ const oneTimeHandler = (result: TransactionTaskResult) => {
+ // Immediately detach — this event should fire only once per payment
+ EventEmitter.off(eventName, oneTimeHandler)
+ transferTaskListenerRef.current = null
+
+ handleTransferTaskResult(result)
+ }
+
+ transferTaskListenerRef.current = oneTimeHandler
+ EventEmitter.on(eventName, oneTimeHandler)
+
+ // Cleanup on unmount or when task flag resets
+ return () => {
+ if (transferTaskListenerRef.current) {
+ EventEmitter.off(eventName, transferTaskListenerRef.current)
+ transferTaskListenerRef.current = null
+ }
+ }
+ }, [handleTransferTaskResult])
+
+
+ const startNfcSession = async () => {
+ try {
+ await NfcManager.requestTechnology(NfcTech.Ndef, {
+ alertMessage: 'Hold phone near the device...',
+ //invalidateAfterFirstRead: false,
+ })
+
+ // Listen for tag discovery
+ NfcManager.setEventListener(NfcEvents.DiscoverTag, async (tag: any) => {
+ if (isProcessing) return
+ setIsProcessing(true)
+ setNfcInfo('Reading payment request...')
+ log.debug('NFC tag discovered, reading...', { tag })
+
+ try {
+ const ndefMessage = tag.ndefMessage?.[0]
+ if (!ndefMessage) {
+ throw new AppError(Err.VALIDATION_ERROR, 'No NDEF message found')
+ }
+
+ let payload: string
+
+ // Handle both Text and URI records
+ if (ndefMessage.type === Ndef.RTD_TEXT) {
+ const decoded = Ndef.text.decodePayload(ndefMessage.payload)
+ payload = decoded
+ } else if (ndefMessage.type === Ndef.RTD_URI) {
+ const decoded = Ndef.uri.decodePayload(ndefMessage.payload)
+ payload = decoded
+ } else {
+ payload = new TextDecoder().decode(ndefMessage.payload)
+ }
+
+ log.debug('NFC tag read', { payload })
+ setReadNfcData(payload)
+ await handlePaymentRequest(payload)
+
+ } catch (e: any) {
+ handleError(e)
+ } finally {
+ setIsProcessing(false)
+ }
+ })
+ } catch (ex: any) {
+ if (ex.message !== 'cancelled') {
+ setInfo('Tap terminal to pay')
+ }
+ }
+ }
+
+
+ // handlePaymentRequest.ts (main entry)
+ const handlePaymentRequest = async (data: string) => {
+ try {
+ Alert.alert(data)
+
+ const result = IncomingParser.findAndExtract(data)
+ if (!result) {
+ throw new AppError(Err.VALIDATION_ERROR, 'Unsupported or invalid payment request')
+ }
+
+ if (result.type === IncomingDataType.CASHU_PAYMENT_REQUEST) {
+ await handleCashuPaymentRequest(result.encoded)
+ } else if (result.type === IncomingDataType.INVOICE) {
+ await handleLightningInvoice(result.encoded)
+ }
+ } catch (e: any) {
+ handleError(e)
+ }
+ }
+
+ const handleCashuPaymentRequest = async (encoded: string) => {
+ const pr = decodePaymentRequest(encoded)
+ log.trace('[handlePaymentRequest] decoded Cashu payment request', { pr })
+
+ // Validate basics
+ if (!pr.amount || pr.amount <= 0) {
+ throw new AppError(Err.VALIDATION_ERROR, 'Payment request has no valid amount')
+ }
+
+ const unit = validateAndNormalizeUnit(pr.unit)
+ unitRef.current = unit
+
+ // Store for UI
+ setDecodedCashuPaymentRequest(pr)
+ setEncodedCashuPaymentRequest(encoded)
+ if (pr.description) setMemo(pr.description)
+
+ const requiredAmount = pr.amount
+ const eligibleBalances = await getEligibleMintBalancesForCashu(pr, requiredAmount, unit)
+
+ if (eligibleBalances.length === 0) {
+ throw new AppError(Err.NOTFOUND_ERROR, 'Wallet has no mint with enough balance accepted by this payment request')
+ }
+
+ const { selectedBalance, selectedProofs } = await selectMintBalance(
+ eligibleBalances,
+ requiredAmount,
+ unit,
+ )
+
+ if(!selectedBalance) { // we might not find suitable balance if offline
+ setInfo('Wallet do')
+ return
+ }
+
+ setMintBalanceToSendFrom(selectedBalance)
+ setAmountToPay(formatDisplayAmount(requiredAmount, unit))
+ setIsSendTaskSentToQueue(true);
+
+ WalletTask.sendQueue(
+ selectedBalance,
+ requiredAmount,
+ unit,
+ pr.description ?? '',
+ selectedProofs.length > 0 ? selectedProofs : [],
+ undefined, // p2pk not supported in current Cashu PR spec
+ undefined
+ )
+ }
+
+
+ const handleLightningInvoice = async (encodedInvoice: string) => {
+ if (!isInternetReachable) {
+ setInfo(translate('commonOfflinePretty'));
+ return
+ }
+
+ const decoded = LightningUtils.decodeInvoice(encodedInvoice)
+ const { amount: invoiceAmount, description, expiry, timestamp } = LightningUtils.getInvoiceData(decoded)
+
+ if (!invoiceAmount || invoiceAmount <= 0) {
+ throw new AppError(Err.VALIDATION_ERROR, 'Lightning invoice has no amount')
+ }
+
+ const expiresAt = addSeconds(new Date((timestamp as number) * 1000), expiry as number)
+ const feeReserve = Math.max(MIN_LIGHTNING_FEE, Math.round(invoiceAmount * LIGHTNING_FEE_PERCENT / 100))
+ const totalRequired = invoiceAmount + feeReserve;
+ const balances = proofsStore.getMintBalancesWithEnoughBalance(totalRequired, unitRef.current)
+
+ if (balances.length === 0) {
+ setInfo(
+ translate('transferScreen_insufficientFunds', {
+ currency: getCurrency(unitRef.current).code,
+ amount: totalRequired,
+ })
+ )
+ return
+ }
+
+ const selectedBalance = balances[0]
+ setMintBalanceToSendFrom(selectedBalance)
+
+ const quote = await walletStore.createLightningMeltQuote(
+ selectedBalance.mintUrl,
+ unitRef.current,
+ encodedInvoice
+ )
+
+ // Update UI
+ setEncodedInvoice(encodedInvoice)
+ setInvoice(decoded)
+ setInvoiceExpiry(expiresAt)
+ setMeltQuote(quote)
+ setAmountToPay(formatDisplayAmount(quote.amount, unitRef.current))
+ if (description) setMemo(description)
+ setIsTransferTaskSentToQueue(true)
+
+ WalletTask.transferQueue(
+ selectedBalance,
+ quote.amount,
+ unitRef.current,
+ quote,
+ description ?? '',
+ expiresAt,
+ encodedInvoice,
+ undefined,
+ undefined
+ )
+ }
+
+
+ const validateAndNormalizeUnit = (unit?: string): MintUnit => {
+ if (!unit) return 'sat'
+ if (MintUnits.includes(unit as MintUnit)) {
+ return unit as MintUnit;
+ }
+ throw new AppError(Err.VALIDATION_ERROR, `Unsupported unit: ${unit}`);
+ }
+
+ const getEligibleMintBalancesForCashu = async (
+ pr: CashuPaymentRequest,
+ amount: number,
+ unit: MintUnit
+ ): Promise => {
+ if (pr.mints && pr.mints.length > 0) {
+ const balances: MintBalance[] = []
+ for (const mintUrl of pr.mints) {
+ if (mintsStore.mintExists(mintUrl)) {
+ const balance = proofsStore.getMintBalance(mintUrl)
+ if (balance &&
+ balance.balances[unit] &&
+ balance.balances[unit] >= amount
+ ) {
+ balances.push(balance)
+ }
+ }
+ }
+ return balances
+ }
+
+ // No mint restriction → all mints with enough balance
+ return proofsStore.getMintBalancesWithEnoughBalance(amount, unit);
+ };
+
+ const selectMintBalance = async (
+ balances: MintBalance[],
+ amount: number,
+ unit: MintUnit,
+ ): Promise<{ selectedBalance: MintBalance | undefined; selectedProofs: Proof[] }> => {
+ let selectedProofs: Proof[] = [];
+
+ if (!isInternetReachable) {
+ // Try to pay exactly with available proofs (avoid swap)
+ for (const balance of balances) {
+ const proofs = proofsStore.getByMint(balance.mintUrl, { isPending: false, unit })
+ const exactProofs = CashuUtils.getProofsToSend(amount, proofs)
+
+ if (CashuUtils.getProofsAmount(exactProofs) === amount) {
+ selectedProofs = exactProofs;
+ return { selectedBalance: balance, selectedProofs }
+ }
+ }
+
+ return { selectedBalance: undefined, selectedProofs: [] }
+ }
+
+ // Default: just pick first mint with enough balance
+ return { selectedBalance: balances[0], selectedProofs };
+ }
+
+ const formatDisplayAmount = (amount: number, unit: MintUnit): string => {
+ const currency = getCurrency(unit);
+ return numbro(amount / currency.precision).format({
+ thousandSeparated: true,
+ mantissa: currency.mantissa,
+ })
+ }
+
+ const writeTokenToNfcTag = async (token: string) => {
+ try {
+ // Re-request tech to write back
+ await NfcManager.requestTechnology(NfcTech.Ndef)
+
+ const textRecord = Ndef.textRecord('en', token)
+ const bytes = Ndef.encodeMessage([textRecord])
+
+ await NfcManager.ndefHandler.writeNdefMessage(bytes)
+ } catch (e: any) {
+ throw new AppError(Err.NFC_ERROR, 'Failed to send token', e.message)
+ } finally {
+ NfcManager.cancelTechnologyRequest().catch(() => {})
+ }
+ }
+
+ const handleError = (e: AppError) => {
+ setError(e)
+ setInfo('')
+ setIsProcessing(false)
+ }
+
+ const gotoScan = () => {
+ navigation.navigate('Scan', { unit: unitRef.current })
+ }
+
+ const gotoWallet = function() {
+ resetState()
+ navigation.dispatch(
+ StackActions.popToTop()
+ )
+ }
+
+ const resetState = function () {
+ setEncodedInvoice('')
+ setInvoice(undefined)
+ setInvoiceExpiry(undefined)
+ setMeltQuote(undefined)
+ setMemo('')
+ setTransactionStatus(undefined)
+ setInfo('')
+ setError(undefined)
+ setIsLoading(false)
+ setIsTransferTaskSentToQueue(false)
+ setIsResultModalVisible(false)
+ setResultModalInfo(undefined)
+ }
+
+ const toggleResultModal = () => setIsResultModalVisible(previousState => !previousState)
+
+ // Theme
+ const hintText = useThemeColor('textDim')
+ const headerBg = useThemeColor('header')
+ const headerTitle = useThemeColor('headerTitle')
+ const scanIcon = useThemeColor('text')
+ const scanButtonColor = useThemeColor('card')
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ >
+ }
+ />
+
+
+
+
+
+
+
+ {info && !isProcessing && }
+ {error && }
+
+ {resultModalInfo &&
+ transactionStatus === TransactionStatus.COMPLETED && (
+ <>
+
+
+
+
+ >
+ )}
+ {resultModalInfo &&
+ transactionStatus === TransactionStatus.REVERTED && (
+ <>
+
+
+
+
+ >
+ )}
+ {resultModalInfo &&
+ transactionStatus === TransactionStatus.ERROR && (
+ <>
+
+
+
+
+ >
+ )}
+ {resultModalInfo &&
+ transactionStatus === TransactionStatus.PENDING && (
+ <>
+
+
+
+
+ >
+ )}
+ >
+ }
+ onBackButtonPress={toggleResultModal}
+ onBackdropPress={toggleResultModal}
+ />
+
+ )
+})
+
+// Styles remain unchanged
+const $screen: ViewStyle = { flex: 1 }
+const $contentContainer: ViewStyle = { flex: 1, marginTop: -spacing.extraLarge * 2, padding: spacing.extraSmall }
+const $headerContainer: TextStyle = { alignItems: 'center', paddingBottom: spacing.medium, height: spacing.screenHeight * 0.15 }
+const $buttonContainer: ViewStyle = { marginTop: spacing.large, 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',
+}
\ No newline at end of file
diff --git a/src/screens/SendScreen.tsx b/src/screens/SendScreen.tsx
index 7466f91c..0d627385 100644
--- a/src/screens/SendScreen.tsx
+++ b/src/screens/SendScreen.tsx
@@ -453,51 +453,66 @@ export const SendScreen = observer(function SendScreen({ route }: Props) {
}, [isInternetReachable])
- useEffect(() => {
- const handleSendTaskResult = async (result: TransactionTaskResult) => {
- log.trace('handleSendTaskResult event handler triggered')
-
+ // ====================== 1. Send Task Result Listener ======================
+
+ const handleSendTaskResult = useCallback(
+ async (result: TransactionTaskResult) => {
+ log.trace('[SendScreen] handleSendTaskResult triggered', result)
+
setIsLoading(false)
- const {transaction} = result
+ const { transaction, error, encodedTokenToSend } = result
- setTransactionStatus(transaction?.status)
+ // Update transaction state
+ if (transaction) {
+ setTransactionStatus(transaction.status)
setTransaction(transaction)
- setTransactionId(transaction?.id)
-
- if (result.encodedTokenToSend) {
- setEncodedTokenToSend(result.encodedTokenToSend)
+ setTransactionId(transaction.id)
+
+ // Link transaction to Cashu payment request + contacts (only for Cashu PR flow)
+ if (
+ paymentOption === SendOption.PAY_CASHU_PAYMENT_REQUEST &&
+ decodedCashuPaymentRequest?.id &&
+ encodedCashuPaymentRequest
+ ) {
+ transaction.update({
+ paymentId: decodedCashuPaymentRequest.id,
+ paymentRequest: encodedCashuPaymentRequest,
+ profile: contactToSendFrom ? JSON.stringify(contactToSendFrom) : undefined,
+ sentTo: contactToSendTo
+ ? contactToSendTo.nip05 || contactToSendTo.name || null
+ : null,
+ sentFrom: contactToSendFrom
+ ? contactToSendFrom.nip05 || contactToSendFrom.name || null
+ : null,
+ })
+ }
}
- if(paymentOption === SendOption.PAY_CASHU_PAYMENT_REQUEST) {
- if(decodedCashuPaymentRequest && decodedCashuPaymentRequest.id && transaction) {
-
- transaction.update({
- paymentId: decodedCashuPaymentRequest.id,
- paymentRequest: encodedCashuPaymentRequest,
- profile: JSON.stringify(contactToSendFrom),
- sentTo: contactToSendTo ? contactToSendTo.nip05 || contactToSendTo.name : null, // payee
- sentFrom: contactToSendFrom ? contactToSendFrom.nip05 || contactToSendFrom.name : null // payer
- })
- }
+ // Save token if present (e.g. for manual copy/share)
+ if (encodedTokenToSend) {
+ setEncodedTokenToSend(encodedTokenToSend);
}
- if (result.error) {
+ // ——— Error Handling ———
+ if (error || !transaction) {
+ const message = error?.params?.message || error?.message || 'Unknown error';
setResultModalInfo({
- status: result.transaction?.status as TransactionStatus,
- title: result.error.params?.message ? result.error.message : 'Send failed',
- message: result.error.params?.message || result.error.message,
+ status: (transaction?.status || TransactionStatus.ERROR) as TransactionStatus,
+ title: error?.params?.message ? error.message : 'Send failed',
+ message,
})
setIsResultModalVisible(true)
- return
+ return;
}
-
- setIsMintSelectorVisible(false)
-
+
+ // ——— Success Path ———
+ setIsMintSelectorVisible(false);
+
if (paymentOption === SendOption.SEND_TOKEN) {
toggleNostrDMModal()
}
-
+
if (paymentOption === SendOption.PAY_CASHU_PAYMENT_REQUEST) {
if (relaysToShareTo.length > 0) {
toggleNostrDMModal()
@@ -505,79 +520,137 @@ export const SendScreen = observer(function SendScreen({ route }: Props) {
togglePostModal()
}
}
- }
-
- // Subscribe to the 'sendCompleted' event
- if(isSendTaskSentToQueue) {
- EventEmitter.on(`ev_${SEND_TASK}_result`, handleSendTaskResult)
- }
-
- // Unsubscribe from the 'sendCompleted' event on component unmount
- return () => {
- EventEmitter.off(`ev_${SEND_TASK}_result`, handleSendTaskResult)
- }
- }, [isSendTaskSentToQueue])
+ },
+ [
+ isSendTaskSentToQueue,
+ encodedCashuPaymentRequest,
+ decodedCashuPaymentRequest
+ ],
+ )
+ const sendTaskListenerRef = useRef<((r: TransactionTaskResult) => void) | null>(null);
useEffect(() => {
- const handleSendCompleted = async (result: SyncStateTaskResult) => {
- log.trace('handleSendCompleted event handler triggered')
+ if (!isSendTaskSentToQueue) {
+ if (sendTaskListenerRef.current) {
+ EventEmitter.off(`ev_${SEND_TASK}_result`, sendTaskListenerRef.current)
+ sendTaskListenerRef.current = null
+ }
+ return
+ }
- if (!transactionId) return
- // Filter and handle event related only to this transactionId
- if (result.completedTransactionIds && result.completedTransactionIds.includes(transactionId)) {
- log.trace(
- 'Sent ecash has been claimed by the receiver for tx',
- transactionId,
- )
+ const eventName = `ev_${SEND_TASK}_result`
- const amountSentInt = round(toNumber(amountToSend) * getCurrency(unitRef.current).precision, 0)
+ // Remove any previous
+ if (sendTaskListenerRef.current) {
+ EventEmitter.off(eventName, sendTaskListenerRef.current);
+ }
+
+ const oneTimeHandler = (result: TransactionTaskResult) => {
+ EventEmitter.off(eventName, oneTimeHandler)
+ sendTaskListenerRef.current = null
+ handleSendTaskResult(result)
+ }
+
+ sendTaskListenerRef.current = oneTimeHandler
+ EventEmitter.on(eventName, oneTimeHandler)
+
+ return () => {
+ if (sendTaskListenerRef.current) {
+ EventEmitter.off(eventName, sendTaskListenerRef.current);
+ sendTaskListenerRef.current = null
+ }
+ }
+ }, [handleSendTaskResult])
+
+ // ====================== 2. Sync State Result Listener ======================
+
+ const handleSyncStateResult = useCallback(
+ async (result: SyncStateTaskResult) => {
+ log.trace('[SendScreen] handleSyncStateResult triggered', { result, transactionId });
+
+ if (!transactionId) return;
+
+ const { completedTransactionIds, errorTransactionIds, transactionStateUpdates } = result;
+
+ if (completedTransactionIds?.includes(transactionId)) {
+ log.trace('[SendScreen] Ecash claimed successfully', { transactionId });
+
+ const amountSentInt = Math.round(
+ toNumber(amountToSend || '0') * getCurrency(unitRef.current).precision
+ );
+ const currency = getCurrency(unitRef.current);
+
+ setIsNostrDMModalVisible(false);
+ setIsProofSelectorModalVisible(false);
- setIsNostrDMModalVisible(false)
- setIsProofSelectorModalVisible(false)
setResultModalInfo({
status: TransactionStatus.COMPLETED,
- title: '🚀 That was fast!',
- message: `${formatCurrency(amountSentInt, getCurrency(unitRef.current).code)} ${getCurrency(unitRef.current).code} were received by the payee.`,
- })
- setTransactionStatus(TransactionStatus.COMPLETED)
- setIsResultModalVisible(true)
+ title: '🚀 That was fast!',
+ message: `${formatCurrency(amountSentInt, currency.code)} ${currency.code} were received by the payee.`,
+ });
+ setTransactionStatus(TransactionStatus.COMPLETED);
+ setIsResultModalVisible(true);
+ return;
}
- // sync check might end with error in case tx proofs spentAmount !== tx amount
- if (result.errorTransactionIds &&
- result.errorTransactionIds.includes(transactionId)) {
+ if (errorTransactionIds?.includes(transactionId)) {
+ log.trace('[SendScreen] Sync error detected', { transactionId });
- log.trace(
- 'Error when completing the send tx',
- {transactionId},
- )
+ const update = transactionStateUpdates?.find(u => u.tId === transactionId);
+ const message = update?.message || 'Transaction failed to complete on the mint.';
- const statusUpdate = result.transactionStateUpdates.find(update => update.tId === transactionId)
- const message = statusUpdate?.message || 'Error when completing the transaction.'
+ setIsNostrDMModalVisible(false);
+ setIsProofSelectorModalVisible(false);
- setIsNostrDMModalVisible(false)
- setIsProofSelectorModalVisible(false)
setResultModalInfo({
status: TransactionStatus.ERROR,
- title: 'Send failed',
+ title: 'Send failed',
message,
- })
- setTransactionStatus(TransactionStatus.ERROR)
- setIsResultModalVisible(true)
- }
+ });
+ setTransactionStatus(TransactionStatus.ERROR);
+ setIsResultModalVisible(true);
+ }
+ },
+ [
+ transactionId,
+ amountToSend,
+ ],
+ );
+
+ const syncStateListenerRef = useRef<((r: SyncStateTaskResult) => void) | null>(null);
+
+ useEffect(() => {
+ if (!transactionId) {
+ if (syncStateListenerRef.current) {
+ EventEmitter.off(`ev_${SYNC_STATE_WITH_MINT_TASK}_result`, syncStateListenerRef.current);
+ syncStateListenerRef.current = null;
+ }
+ return;
}
- // Subscribe to the '_syncStateWithMintTask' event
- if(transactionId) {
- EventEmitter.on(`ev_${SYNC_STATE_WITH_MINT_TASK}_result`, handleSendCompleted)
+ const eventName = `ev_${SYNC_STATE_WITH_MINT_TASK}_result`;
+
+ if (syncStateListenerRef.current) {
+ EventEmitter.off(eventName, syncStateListenerRef.current);
}
- // Unsubscribe from the '_syncStateWithMintTask' event on component unmount
+ const oneTimeHandler = (result: SyncStateTaskResult) => {
+ EventEmitter.off(eventName, oneTimeHandler);
+ syncStateListenerRef.current = null;
+ handleSyncStateResult(result);
+ };
+
+ syncStateListenerRef.current = oneTimeHandler;
+ EventEmitter.on(eventName, oneTimeHandler);
+
return () => {
- EventEmitter.off(`ev_${SYNC_STATE_WITH_MINT_TASK}_result`, handleSendCompleted)
- }
- }, [transactionId])
+ if (syncStateListenerRef.current) {
+ EventEmitter.off(eventName, syncStateListenerRef.current);
+ syncStateListenerRef.current = null;
+ }
+ };
+ }, [handleSyncStateResult])
const toggleNostrDMModal = () => setIsNostrDMModalVisible(previousState => !previousState)
const toggleProofSelectorModal = () => setIsProofSelectorModalVisible(previousState => !previousState)
diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx
index 0167c29d..67f90001 100644
--- a/src/screens/SettingsScreen.tsx
+++ b/src/screens/SettingsScreen.tsx
@@ -69,18 +69,21 @@ export const SettingsScreen = observer(function SettingsScreen({ route }: Props)
if (!updateInfo) {
return
}
-
+
if(!__DEV__) {
- setIsUpdateAvailable(true)
- setUpdateDescription(updateInfo.message || '')
-
- if (updateInfo.shouldForceUpdate) {
- // apply emergency update immediately
- await HotUpdater.updateBundle(updateInfo.id, updateInfo.fileUrl)
- HotUpdater.reload()
+ setIsUpdateAvailable(true)
+ setUpdateDescription(updateInfo.message || '')
+
+ if (updateInfo.shouldForceUpdate) {
+ // apply emergency update immediately
+ const isDownloaded = await updateInfo.updateBundle()
+ if(isDownloaded) {
+ await HotUpdater.reload()
}
+ }
}
+
} catch (e: any) {
log.error(e)
return false
diff --git a/src/screens/TransferScreen.tsx b/src/screens/TransferScreen.tsx
index 9211dcb3..3f1b1025 100644
--- a/src/screens/TransferScreen.tsx
+++ b/src/screens/TransferScreen.tsx
@@ -294,155 +294,266 @@ useFocusEffect(
}, [route.params?.paymentOption]),
)
+const handleError = function(e: AppError): void {
+ setIsLoading(false)
+ setError(e)
+}
-useEffect(() => {
- const createMeltQuote = async function () {
- try {
- log.trace('[createMeltQuote]', {mintBalanceToTransferFrom})
- if (!mintBalanceToTransferFrom || !encodedInvoice) {
- log.trace(
- '[createMeltQuote]',
- 'Not yet ready to request melt quote or melt quote already exists... exiting',
- {mintBalanceToTransferFrom,
- encodedInvoice}
- )
- return
- }
-
- setIsLoading(true)
- const quote = await walletStore.createLightningMeltQuote(
- mintBalanceToTransferFrom.mintUrl,
- unitRef.current,
- encodedInvoice,
- )
-
- setIsLoading(false)
- setMeltQuote(quote)
- setAmountToTransfer(`${numbro(quote.amount / getCurrency(unitRef.current).precision)
- .format({
- thousandSeparated: true,
- mantissa: getCurrency(unitRef.current).mantissa
- })}`
- )
-
- const totalAmount = quote.amount + quote.fee_reserve
-
- let availableBalances = proofsStore.getMintBalancesWithEnoughBalance(totalAmount, unitRef.current)
-
- if (availableBalances.length === 0) {
- setInfo(translate("transferScreen_insufficientFunds", {
- currency: getCurrency(unitRef.current).code,
- amount: amountToTransfer
- }))
- setIsNotEnoughFunds(true)
- return
- }
-
- setAvailableMintBalances(availableBalances)
-
- } catch (e: any) {
- handleError(e)
- }
+
+// Ref to track if a quote request is in progress
+const quoteRequestRef = useRef(null);
+
+// Stable function to create melt quote
+const createMeltQuote = useCallback(async () => {
+ // Cancel any previous in-flight request
+ if (quoteRequestRef.current) {
+ quoteRequestRef.current.abort()
+ }
+
+ const controller = new AbortController()
+ quoteRequestRef.current = controller
+
+ try {
+ log.trace('[TransferScreen] createMeltQuote triggered', {
+ mintBalanceToTransferFrom,
+ encodedInvoice,
+ hasExistingQuote: !!meltQuote,
+ })
+
+ // Guard: required data not ready yet
+ if (!mintBalanceToTransferFrom?.mintUrl || !encodedInvoice) {
+ log.trace('[createMeltQuote] Missing required data, skipping quote request')
+ return
}
- createMeltQuote()
+ // Optional: prevent double quote if we already have one for the same invoice + mint
+ if (meltQuote && meltQuote.quote) {
+ log.trace('[createMeltQuote] Melt quote already exists, skipping')
+ return
+ }
-}, [mintBalanceToTransferFrom])
+ setIsLoading(true)
+ setIsNotEnoughFunds(false)
+ setInfo('')
+ const quote = await walletStore.createLightningMeltQuote(
+ mintBalanceToTransferFrom.mintUrl,
+ unitRef.current,
+ encodedInvoice,
+ )
+ // Ignore result if request was aborted (e.g. user switched mint)
+ if (controller.signal.aborted) {
+ log.trace('[createMeltQuote] Request was aborted, ignoring stale result');
+ return;
+ }
+
+ setMeltQuote(quote);
+
+ // Format amount for display
+ const displayAmount = numbro(quote.amount / getCurrency(unitRef.current).precision).format({
+ thousandSeparated: true,
+ mantissa: getCurrency(unitRef.current).mantissa,
+ });
+ setAmountToTransfer(displayAmount)
+
+ // Check total required balance (amount + fee reserve)
+ const totalRequired = quote.amount + quote.fee_reserve
+ const availableBalances = proofsStore.getMintBalancesWithEnoughBalance(totalRequired, unitRef.current)
+
+ if (availableBalances.length === 0) {
+ setInfo(
+ translate('transferScreen_insufficientFunds', {
+ currency: getCurrency(unitRef.current).code,
+ amount: totalRequired,
+ })
+ );
+ setIsNotEnoughFunds(true);
+ } else {
+ setAvailableMintBalances(availableBalances);
+ setIsNotEnoughFunds(false);
+ }
+ } catch (e: any) {
+ // Ignore abort errors
+ if (e.name === 'AbortError') {
+ log.trace('[createMeltQuote] Aborted');
+ return
+ }
+
+ log.error('[createMeltQuote] Failed to create melt quote', e)
+ handleError(e)
+
+ } finally {
+ // Only reset loading if this was the active request
+ if (quoteRequestRef.current === controller) {
+ setIsLoading(false)
+ quoteRequestRef.current = null
+ }
+ }
+}, [
+ mintBalanceToTransferFrom?.mintUrl,
+ encodedInvoice,
+ meltQuote, // to avoid refetching same quote
+ unitRef.current,
+])
+
+// Trigger quote creation when mint changes
useEffect(() => {
- const handleTransferTaskResult = async (result: TransactionTaskResult) => {
- log.trace('handleTransferTaskResult event handler triggered', {isInvoiceDonation})
+ createMeltQuote()
+}, [createMeltQuote])
- /* if(result.meltQuote && meltQuote && result.meltQuote.quote !== meltQuote.quote) {
- log.error('[handleTransferTaskResult] Event belongs to a different meltquote, skipping...')
- return
- } */
-
- setIsLoading(false)
- const {transaction, message, error, finalFee} = result
+// Cleanup on unmount
+useEffect(() => {
+ return () => {
+ if (quoteRequestRef.current) {
+ quoteRequestRef.current.abort()
+ quoteRequestRef.current = null
+ }
+ };
+}, [])
- // handle errors before transaction is created
- if (!transaction && error) {
- setTransactionStatus(TransactionStatus.ERROR)
- setResultModalInfo({
- status: TransactionStatus.ERROR,
- message: error.message,
- })
-
- setIsLoading(false)
- toggleResultModal()
- return
- }
-
- const { status } = transaction as Transaction
- setTransactionStatus(status)
- setTransaction(transaction)
-
- if(transaction && lnurlPayParams && lnurlPayParams.address) {
- const profile = contactsStore.findByLud16(lnurlPayParams.address)
-
- if (profile) {
- transaction.update({
- sentTo: profile.nip05 || profile.name,
- profile: JSON.stringify(profile)
- })
- } else {
- transaction.update({
- sentTo: lnurlPayParams.address
- })
- }
- }
-
- if (error) { // This handles timed out pending payments
- if(status === TransactionStatus.PENDING) {
- setResultModalInfo({
- status,
- message,
- })
- } else {
- setResultModalInfo({
- status,
- title: error.params?.message ? error.message : translate('payCommon_failed'),
- message: error.params?.message || error.message,
- })
- }
-
+// ====================== TransferScreen – Lightning Payment Result Handler ======================
+
+const handleTransferTaskResult = useCallback(
+ async (result: TransactionTaskResult) => {
+ log.trace('[TransferScreen] handleTransferTaskResult triggered', {
+ result,
+ isInvoiceDonation,
+ donationForName,
+ })
+
+ setIsLoading(false)
+
+ const { transaction, error, message, finalFee } = result
+
+ // ——— Early error: no transaction object yet ———
+ if (!transaction && error) {
+ setTransactionStatus(TransactionStatus.ERROR)
+
+ setResultModalInfo({
+ status: TransactionStatus.ERROR,
+ title: translate('payCommon_failed'),
+ message: error.message || 'Lightning payment failed',
+ });
+
+ toggleResultModal()
+ return
+ }
+
+ // ——— Transaction exists ———
+ if (transaction) {
+ const { status } = transaction
+ setTransactionStatus(status)
+ setTransaction(transaction)
+
+ // ——— Link transaction to LNURL / contact profile ———
+ if (lnurlPayParams?.address) {
+ const profile = contactsStore.findByLud16(lnurlPayParams.address)
+
+ if (profile) {
+ transaction.update({
+ sentTo: profile.nip05 || profile.name || lnurlPayParams.address,
+ profile: JSON.stringify(profile),
+ })
} else {
- if(isInvoiceDonation && donationForName) {
-
- await walletProfileStore.updateName(donationForName)
-
- setResultModalInfo({
- status,
- message: translate('transferScreen_donationSuccessMessage', {donationForName}),
- })
-
- } else {
- setResultModalInfo({
- status,
- message,
- })
- }
+ transaction.update({
+ sentTo: lnurlPayParams.address,
+ })
}
-
- if (finalFee) {
- setFinalFee(finalFee)
+ }
+
+ // ——— Final fee reporting ———
+ if (finalFee != null) {
+ setFinalFee(finalFee)
+ }
+
+ // ——— Success / Pending / Error message logic ———
+ if (error) {
+ if (status === TransactionStatus.PENDING) {
+ setResultModalInfo({
+ status,
+ message,
+ })
+ } else {
+ setResultModalInfo({
+ status,
+ title: error.params?.message ? error.message : translate('payCommon_failed'),
+ message: error.params?.message || error.message || 'Payment failed',
+ })
}
-
- toggleResultModal()
+ } else {
+ // Success path
+ if (isInvoiceDonation && donationForName) {
+ // Update profile donation counter
+ await walletProfileStore.updateName(donationForName);
+
+ setResultModalInfo({
+ status,
+ message: translate('transferScreen_donationSuccessMessage', { donationForName }),
+ })
+ } else {
+ setResultModalInfo({
+ status,
+ message,
+ })
+ }
+ }
}
- // Subscribe to the task result event
- if(isTransferTaskSentToQueue) {
- EventEmitter.on(`ev_${TRANSFER_TASK}_result`, handleTransferTaskResult)
- }
+ toggleResultModal()
+ },
+ [
+ isInvoiceDonation,
+ donationForName,
+ lnurlPayParams?.address,
+ setIsLoading,
+ setTransaction,
+ setTransactionStatus,
+ setFinalFee,
+ setResultModalInfo,
+ translate,
+
+ ],
+)
- // Unsubscribe from the task result event on component unmount
- return () => {
- EventEmitter.off(`ev_${TRANSFER_TASK}_result`, handleTransferTaskResult)
+// One-time listener with auto-cleanup
+const transferTaskListenerRef = useRef<((r: TransactionTaskResult) => void) | null>(null)
+
+useEffect(() => {
+ if (!isTransferTaskSentToQueue) {
+ if (transferTaskListenerRef.current) {
+ EventEmitter.off(`ev_${TRANSFER_TASK}_result`, transferTaskListenerRef.current)
+ transferTaskListenerRef.current = null
}
+ return
+ }
+
+ const eventName = `ev_${TRANSFER_TASK}_result`
+
+ // Remove any stale listener
+ if (transferTaskListenerRef.current) {
+ EventEmitter.off(eventName, transferTaskListenerRef.current)
+ }
+
+ const oneTimeHandler = (result: TransactionTaskResult) => {
+ // Detach immediately — this event fires once per payment
+ EventEmitter.off(eventName, oneTimeHandler)
+ transferTaskListenerRef.current = null
+
+ // Forward to stable handler
+ handleTransferTaskResult(result)
+ }
+
+ transferTaskListenerRef.current = oneTimeHandler
+ EventEmitter.on(eventName, oneTimeHandler)
+
+ return () => {
+ if (transferTaskListenerRef.current) {
+ EventEmitter.off(eventName, transferTaskListenerRef.current)
+ transferTaskListenerRef.current = null
+ }
+ }
}, [isTransferTaskSentToQueue])
@@ -702,12 +813,6 @@ const retryAfterSpentCleaned = async function () {
}
}
-
-const handleError = function(e: AppError): void {
- setIsLoading(false)
- setError(e)
-}
-
const headerBg = useThemeColor('header')
const iconColor = useThemeColor('textDim')
const amountInputColor = useThemeColor('amountInput')
diff --git a/src/screens/UpdateScreen.tsx b/src/screens/UpdateScreen.tsx
index fb98d21b..7f8bd9ad 100644
--- a/src/screens/UpdateScreen.tsx
+++ b/src/screens/UpdateScreen.tsx
@@ -48,7 +48,7 @@ export const UpdateScreen = observer(function UpdateScreen({ route }: Props) {
prevScreen
} = route.params
- const { progress, isBundleUpdated } = useHotUpdaterStore()
+ const { progress } = useHotUpdaterStore()
useHeader({
leftIcon: 'faArrowLeft',
@@ -102,8 +102,12 @@ export const UpdateScreen = observer(function UpdateScreen({ route }: Props) {
throw new AppError(Err.NETWORK_ERROR, translate('updateScreen_couldNotRetrieveUpdate'))
}
- await HotUpdater.updateBundle(updateInfo.id, updateInfo.fileUrl)
- HotUpdater.reload()
+ const isDownloaded = await updateInfo.updateBundle()
+ if(isDownloaded) {
+ await HotUpdater.reload()
+ } else {
+ throw new AppError(Err.NETWORK_ERROR, 'Could not download update bundle')
+ }
} catch(e: any) {
handleError(e)
diff --git a/src/screens/WalletScreen.tsx b/src/screens/WalletScreen.tsx
index 281c3532..e0360e8f 100644
--- a/src/screens/WalletScreen.tsx
+++ b/src/screens/WalletScreen.tsx
@@ -132,8 +132,10 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
if (updateInfo.shouldForceUpdate) {
// apply emergency update immediately
- await HotUpdater.updateBundle(updateInfo.id, updateInfo.fileUrl)
- HotUpdater.reload()
+ const isDownloaded = await updateInfo.updateBundle()
+ if(isDownloaded) {
+ await HotUpdater.reload()
+ }
}
}
@@ -445,6 +447,15 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
}
+ const gotoNfcPay = function () {
+ setIsSendModalVisible(false)
+ // @ts-ignore
+ navigation.navigate('NfcPay', {
+ unit: currentUnit
+ })
+ }
+
+
const gotoLightningPay = function (mintUrl?: string) {
log.trace({mintUrl})
if(mintUrl) {
@@ -496,7 +507,8 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
{
return(
-
+
)}
indicatorStyle={{backgroundColor: getActiveTabColor(props.navigationState)}}
@@ -804,6 +820,13 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
tx="walletScreen_payWithLightning"
subTx="walletScreen_payWithLightningDesc"
onPress={() => gotoLightningPay()}
+ bottomSeparator={true}
+ />
+ gotoNfcPay()}
/>
>
}
@@ -864,7 +887,7 @@ const UnitBalanceBlock = observer(function (props: {
mintUnit={unitBalance.unit}
symbolStyle={{display: 'none'}}
amountStyle={[$unitBalance, {color: balanceColor}]}
- containerStyle={{marginTop: spacing.medium}}
+ containerStyle={{marginTop: spacing.small}}
/>
{walletStore.exchangeRate
@@ -1092,7 +1115,7 @@ const $screen: ViewStyle = {
const $headerContainer: TextStyle = {
alignItems: 'center',
// padding: spacing.tiny,
- height: spacing.screenHeight * 0.27,
+ height: spacing.screenHeight * 0.25,
}
const $tabContainer: TextStyle = {
diff --git a/src/screens/index.ts b/src/screens/index.ts
index 3f617293..acf4ca30 100644
--- a/src/screens/index.ts
+++ b/src/screens/index.ts
@@ -18,6 +18,7 @@ export * from './TopupScreen'
export * from './LightningPayScreen'
export * from './TokenReceiveScreen'
export * from './CashuPaymentRequestScreen'
+export * from './NfcPayScreen'
// ContactsNavigator
export * from './ContactsScreen'
diff --git a/src/services/logService.ts b/src/services/logService.ts
index 28d20177..56bf7f59 100644
--- a/src/services/logService.ts
+++ b/src/services/logService.ts
@@ -1,60 +1,182 @@
+// logService.ts
import {
logger,
- mapConsoleTransport,
+ consoleTransport,
transportFunctionType,
} from "react-native-logs"
-import {lightFormat} from 'date-fns'
+import {
+ APP_ENV,
+ SENTRY_DSN,
+ JS_BUNDLE_VERSION,
+ ANDROID_VERSION_NAME,
+} from '@env'
+import { lightFormat } from 'date-fns'
import * as Sentry from '@sentry/react-native'
-import {rootStoreInstance} from '../models'
-import {LogLevel} from './log/logTypes'
+import { rootStoreInstance } from '../models'
+import { LogLevel } from './log/logTypes'
+import { Platform } from "react-native"
+import AppError, { Err } from "../utils/AppError"
-// refresh
+const { userSettingsStore } = rootStoreInstance
-const {
- userSettingsStore
-} = rootStoreInstance
+if (!__DEV__) {
+ Sentry.init({
+ dsn: SENTRY_DSN,
+ environment: APP_ENV,
+ release: Platform.OS === 'android' ? `minibits_wallet_android@${JS_BUNDLE_VERSION}` : `minibits_wallet_ios@${JS_BUNDLE_VERSION}`,
+ dist: ANDROID_VERSION_NAME,
+ beforeSend: function (event, hint) {
+ const exception = hint.originalException
+ if (exception instanceof AppError && exception.name) {
+ event.fingerprint = [exception.name.toString()]
+ }
+ return event
+ },
+ enableLogs: true,
+ })
+}
-const customSentryTransport: transportFunctionType<{SENTRY: any}> = props => {
+// === Options interface (used in transportOptions) ===
+interface TransportOptions {
+ SENTRY?: typeof Sentry
+}
- if (!props.options?.SENTRY) {
- throw Error('No sentry instance provided')
+// === Type-safe mappings ===
+const levelPriority: Record = {
+ [LogLevel.TRACE]: 0,
+ [LogLevel.DEBUG]: 1,
+ [LogLevel.INFO]: 2,
+ [LogLevel.WARN]: 3,
+ [LogLevel.ERROR]: 4,
+}
+
+const levelToSentry: Record = {
+ [LogLevel.TRACE]: 'debug',
+ [LogLevel.DEBUG]: 'debug',
+ [LogLevel.INFO]: 'info',
+ [LogLevel.WARN]: 'warning',
+ [LogLevel.ERROR]: 'error',
+}
+
+const safeStringify = (msg: any): string => {
+ try {
+ if (msg === null || msg === undefined) return 'null'
+ if (typeof msg === 'string') return msg
+ if (msg instanceof Error) return `${msg.name}: ${msg.message}\n${msg.stack || ''}`
+ return JSON.stringify(msg, (_, v) => (typeof v === 'bigint' ? v.toString() : v), 2)
+ } catch {
+ return '[Unserializable]'
+ }
+}
+
+const redactSensitive = (text: string): string => {
+ if (!text) return text
+ return text
+ .replace(/lnurl\w{50,}/gi, 'lnurl[redacted]')
+ .replace(/nsec1[ac-hj-np-z02-9]{58,}/g, 'nsec1[redacted]')
+ .replace(/cashuB[ac-hj-np-z02-9]{58,}/g, 'nsec1[redacted]')
+}
+
+
+const extractFromArray = (arr: any[]): { message: string; params: Record } => {
+ const strings: string[] = []
+ const objects: Record[] = []
+
+ for (const item of arr) {
+ if (typeof item === 'string') strings.push(redactSensitive(item))
}
- // Log to Sentry only if user setting is switched on
- if (!userSettingsStore.isLoggerOn) {
- return true
- }
- if (props.level.text === 'error') {
- // Capture error log
- const {stack, message} = props.rawMsg as Error
- if (stack && message) {
- // this is probably a JS error
- props.options.SENTRY.captureException(props.rawMsg)
- } else {
- props.options.SENTRY.captureException(props.msg)
+
+ return {
+ message: strings.length > 0 ? strings.join(' ') : '[No message]',
+ params: arr[1] && typeof arr[1] === 'object' ? arr[1] : {},
+ }
+}
+
+
+const customSentryTransport: transportFunctionType = async (props) => {
+ if (!userSettingsStore.isLoggerOn) return true
+
+ const level = props.level.text as LogLevel
+ const rawMessage = props.rawMsg
+
+ let message = ''
+ let params: Record = {}
+
+ // === Extract message + params safely ===
+ if (typeof rawMessage === 'string') {
+
+ message = redactSensitive(rawMessage)
+
+ } else if (Array.isArray(rawMessage)) {
+
+ const { message: msg, params: p } = extractFromArray(rawMessage)
+ message = msg
+ params = p
+
+ } else if (rawMessage instanceof Error) {
+
+ message = redactSensitive(rawMessage.message)
+ if (rawMessage instanceof AppError) {
+ params = rawMessage.params ?? {}
}
+ // For native JS Errors: preserve stack, don't override
+ if ('stack' in rawMessage) {
+ params.stack = rawMessage.stack
+ }
+ } else if (rawMessage && typeof rawMessage === 'object') {
+ message = '[Object logged]'
+ params = rawMessage
+ }
+
+ //console.log(`[${level}] ${message}`, params)
+
+ // === ERROR LEVEL: Send real exception with full context ===
+ if (level === LogLevel.ERROR) {
+ let errorToSend: Error
+
+ if (rawMessage instanceof Error) {
+ // Preserve original error (AppError or native JS Error)
+ errorToSend = rawMessage
+ } else {
+ // Only fallback if no real error was passed
+ errorToSend = new Error(message)
+ }
+
+ Sentry.captureException(errorToSend, {
+ contexts:{ params },
+ tags: { source: 'logger' },
+ // Optional: improve grouping if needed
+ // fingerprint: rawMessage instanceof AppError ? [rawMessage.code] : undefined,
+ })
+
return true
- }
-
- // Capture log based on user setting for log level
- if (userSettingsStore.logLevel === LogLevel.DEBUG && [LogLevel.WARN, LogLevel.INFO, LogLevel.DEBUG].includes(props.level.text as LogLevel)) {
- props.options.SENTRY.captureMessage(props.msg)
- }
-
- if (userSettingsStore.logLevel === LogLevel.INFO && [LogLevel.WARN, LogLevel.INFO].includes(props.level.text as LogLevel)) {
- props.options.SENTRY.captureMessage(props.msg)
}
- if (userSettingsStore.logLevel === LogLevel.WARN && props.level.text === LogLevel.WARN) {
- props.options.SENTRY.captureMessage(props.msg)
+ // === Non-error levels: structured breadcrumbs via logger (or addBreadcrumb) ===
+ const currentPriority = levelPriority[level]
+ const minPriority = levelPriority[userSettingsStore.logLevel] ?? levelPriority[LogLevel.WARN]
+ if (currentPriority < minPriority) return true
+
+ switch (level) {
+ case LogLevel.TRACE:
+ case LogLevel.DEBUG:
+ Sentry.logger.debug(message, params)
+ break
+ case LogLevel.INFO:
+ Sentry.logger.info(message, params)
+ break
+ case LogLevel.WARN:
+ Sentry.logger.warn(message, params)
+ break
}
-
+
return true
}
-
-const log = logger.createLogger({
+// === Create logger ===
+const log = logger.createLogger({
severity: __DEV__ ? LogLevel.TRACE : LogLevel.DEBUG,
levels: {
trace: 0,
@@ -63,21 +185,14 @@ const log = logger.createLogger({
warn: 3,
error: 4,
},
- stringifyFunc: (msg: any) => {return JSON.stringify(msg, undefined, 4)},
- transport: __DEV__ ? mapConsoleTransport : customSentryTransport,
- transportOptions: {
+ transport: __DEV__ ? consoleTransport : customSentryTransport,
+ transportOptions: {
SENTRY: Sentry,
- mapLevels: {
- trace: "log",
- debug: "debug",
- info: "info",
- warn: "warn",
- error: "error",
- }
- },
- dateFormat: (date: Date) => {
- return `${lightFormat(new Date(), 'HH:mm:ss:SSS')} | `
- },
+ } as TransportOptions,
+ dateFormat: () => `${lightFormat(new Date(), 'HH:mm:ss.SSS')} | `,
+ stringifyFunc: safeStringify,
+ async: true,
+ asyncFunc: (transport: any) => setTimeout(transport, 0),
})
-export {log}
+export { log }
\ No newline at end of file
diff --git a/src/services/minibitsService.ts b/src/services/minibitsService.ts
index cf840d54..36fddaa5 100644
--- a/src/services/minibitsService.ts
+++ b/src/services/minibitsService.ts
@@ -370,7 +370,7 @@ const getExchangeRate = async function (currency: CurrencyCode) {
const fetchApi = async (url: string, options: MinibitsRequestOptions, timeout = 15000) => { //ms
- log.info('[fetchApi] start', url)
+ log.info('Start', {url, caller: 'fetchApi'})
const controller = new AbortController()
const body = options.body ? JSON.stringify(options.body) : undefined
diff --git a/src/services/walletService.ts b/src/services/walletService.ts
index 208fb83c..59b104c3 100644
--- a/src/services/walletService.ts
+++ b/src/services/walletService.ts
@@ -799,11 +799,9 @@ const syncStateWithMintTask = async function (
// 1. Proofs now SPENT at mint → transaction succeeded
// ─────────────────────────────────────────────────────────────
if (secrets.spent.size > 0) {
- const spentProofs = proofsToSync.filter(p => secrets.spent.has(p.secret))
-
- proofsStore.moveToSpent(spentProofs) // sets isSpent = true, isPending = false + clean if they were in pendingByMintSecrets
-
+ const spentProofs = proofsToSync.filter(p => secrets.spent.has(p.secret))
const spentByTx = groupByTId(spentProofs)
+ proofsStore.moveToSpent(spentProofs) // sets isSpent = true, isPending = false + clean if they were in pendingByMintSecrets
for (const [tId, { amount: spentAmount }] of spentByTx) {
const tx = transactionsStore.findById(tId)
diff --git a/src/utils/AppError.ts b/src/utils/AppError.ts
index 3f6607bd..1376c784 100644
--- a/src/utils/AppError.ts
+++ b/src/utils/AppError.ts
@@ -1,5 +1,5 @@
-
-import {log} from '../services/logService'
+// AppError.ts
+import { log } from '../services/logService'
export enum Err {
CONNECTION_ERROR = 'CONNECTION_ERROR',
@@ -20,39 +20,48 @@ export enum Err {
SERVER_ERROR = 'SERVER_ERROR',
LOCKED_ERROR = 'LOCKED_ERROR',
SCAN_ERROR = 'SCAN_ERROR',
- AUTH_ERROR = "AUTH_ERROR"
+ AUTH_ERROR = 'AUTH_ERROR',
+ NFC_ERROR = 'NFC_ERROR',
}
-export interface IAppError {
- code?: string
- name: Err
- message: string
- params?: any
-}
+export class AppError extends Error {
+ public readonly code: Err
+ public readonly params?: Record
+ public readonly caller?: string
-class AppError extends Error {
- public code?: number
- public name: Err
- public message: string
- public params?: { caller?: string, message?: string, [key: string]: any }
+ constructor(
+ name: Err = Err.UNKNOWN_ERROR,
+ message: string = 'An unknown error occurred',
+ params?: Record,
+ ) {
+ // This is the actual error message shown in Sentry
+ super(message)
- constructor(name: Err = Err.UNKNOWN_ERROR, message: string, params?: any) {
- super(name)
- this.code = params && params.code ? params.code : undefined
- this.name = name
- this.message = message
- this.params = params
+ this.name = name
+ this.code = params?.code
+ this.params = params // Structured data (sent to Sentry)
+ this.caller = params?.caller // Optional: who threw it
- let callerFunctionName = 'unknown'
+ // Ensure proper prototype chain (important for instanceof)
+ Object.setPrototypeOf(this, AppError.prototype)
- if (params && params.caller) {
- callerFunctionName = params.caller
+ // Preserve correct stack trace (V8 only — safe no-op elsewhere)
+ if (typeof Error.captureStackTrace === 'function') {
+ Error.captureStackTrace(this, this.constructor)
}
- log.error(`[${callerFunctionName}]`, name, message, JSON.stringify(params))
-
- Object.setPrototypeOf(this, new.target.prototype)
+ // === SAFE LOGGING: Only log if logger is on and avoid recursion ===
+ if (log && typeof log.error === 'function') {
+ try {
+ let msg = this.caller ? `[${this.caller}] ${this.message}` : this.message
+ log.error(`${msg}`, { ...params, name })
+ } catch (e) {
+ // Never let logging crash the app
+ console.error('Failed to log AppError:', e)
+ }
+ }
}
}
-export default AppError
+
+export default AppError
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index da9b36a5..b98086e9 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -13460,6 +13460,7 @@ __metadata:
react-native-logs: ^5.5.0
react-native-mmkv: ^3.3.3
react-native-modal: ^14.0.0-rc.1
+ react-native-nfc-manager: ^3.17.2
react-native-pager-view: ^6.9.1
react-native-qrcode-svg: ^6.3.15
react-native-quick-crypto: ^0.7.17
@@ -15412,6 +15413,18 @@ __metadata:
languageName: node
linkType: hard
+"react-native-nfc-manager@npm:^3.17.2":
+ version: 3.17.2
+ resolution: "react-native-nfc-manager@npm:3.17.2"
+ peerDependencies:
+ "@expo/config-plugins": "*"
+ peerDependenciesMeta:
+ "@expo/config-plugins":
+ optional: true
+ checksum: 7435840e0ddd190b442dbe4972e318da2423c953648f917ba515d99a92aba27a477d738fb0f75db89bc64fc52201b9e6880d74e5c7f800d75195c77f4c20cde3
+ languageName: node
+ linkType: hard
+
"react-native-pager-view@npm:^6.9.1":
version: 6.9.1
resolution: "react-native-pager-view@npm:6.9.1"