Add POC of NFC pay

This commit is contained in:
minibits-cash
2025-12-04 12:42:23 +01:00
parent 4f44c63a69
commit 6c036114ce
20 changed files with 1610 additions and 364 deletions
+1
View File
@@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<uses-permission android:name="android.permission.NFC" />
<application
android:name=".MainApplication"
android:label="@string/app_name"
+1
View File
@@ -69,6 +69,7 @@
"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",
+2 -25
View File
@@ -1,11 +1,4 @@
import React, { useState } from 'react'
import * as Sentry from '@sentry/react-native'
import {
APP_ENV,
SENTRY_DSN,
JS_BUNDLE_VERSION,
ANDROID_VERSION_NAME,
} from '@env'
import FlashMessage from "react-native-flash-message"
import {
SafeAreaProvider,
@@ -27,29 +20,13 @@ import { spacing, typography } from './theme'
import { displayName } from '../app.json'
import { Text } from './components/Text'
import useIsInternetReachable from './utils/useIsInternetReachable'
import * as Sentry from '@sentry/react-native'
import { ANDROID_VERSION_NAME, APP_ENV, JS_BUNDLE_VERSION, SENTRY_DSN } from '@env'
/* Set default size ratio for styling */
setSizeMattersBaseWidth(375)
setSizeMattersBaseHeight(812)
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
},
enableTracing: false
})
}
function App() {
const {userSettingsStore, relaysStore, authStore, walletStore, walletProfileStore} = useStores()
+4 -1
View File
@@ -23,6 +23,7 @@ import { faTelegramPlane } from "@fortawesome/free-brands-svg-icons/faTelegramPl
import { faDiscord } from "@fortawesome/free-brands-svg-icons/faDiscord"
import { faGithub } from "@fortawesome/free-brands-svg-icons/faGithub"
import { faReddit } from "@fortawesome/free-brands-svg-icons/faReddit"
import { faNfcSymbol } from '@fortawesome/free-brands-svg-icons/faNfcSymbol'
import { faWallet } from '@fortawesome/free-solid-svg-icons/faWallet'
import { faAddressCard } from '@fortawesome/free-solid-svg-icons/faAddressCard'
@@ -99,13 +100,15 @@ import { faHeartPulse } from "@fortawesome/free-solid-svg-icons/faHeartPulse"
import { faSeedling } from "@fortawesome/free-solid-svg-icons/faSeedling"
import { faChevronLeft } from '@fortawesome/free-solid-svg-icons/faChevronLeft'
import { faArrowRightArrowLeft } from "@fortawesome/free-solid-svg-icons"
import { faWifi } from "@fortawesome/free-solid-svg-icons"
export type IconTypes = keyof typeof iconRegistry
// TODO remove need for manual iconregistry?
// would be best to just import all of them, i guess, or figure out something smart
export const iconRegistry = { faAddressCard, faAddressBook, faWallet, faQrcode, faClipboard, faSliders, faCoins, faEllipsisVertical, faEllipsis, faArrowUp, faArrowDown, faArrowLeft, faXmark, faInfoCircle, faBug, faCheckCircle, faArrowTurnUp, faArrowTurnDown, faPencil, faTags, faShareFromSquare, faRotate, faCode, faBan, faCircle, faPaperPlane, faBolt, faArrowUpFromBracket, faArrowRightToBracket, faPlus, faShieldHalved, faCloudArrowUp, faPaintbrush, faCopy, faBurst, faUserShield, faLock, faLockOpen, faTriangleExclamation, faDownload, faUpload, faRecycle, faListUl, faExpand, faFingerprint, faWandMagicSparkles, faCircleUser, faComment, faKey, faCircleNodes, faBullseye, faEyeSlash, faUpRightFromSquare, faShareNodes, faPaste, faKeyboard, faMoneyBill1, faGears, faTag, faBank, faChevronDown, faChevronUp, faCircleExclamation, faCircleQuestion, faEnvelope, faTwitter, faTelegramPlane, faDiscord, faGithub, faReddit, faCircleArrowUp, faCircleArrowDown, faGlobe, faCubes, faClock, faArrowRotateLeft, faHeartPulse, faSeedling, faChevronLeft, faArrowRightArrowLeft }
export const iconRegistry = { faWifi, faNfcSymbol, faAddressCard, faAddressBook, faWallet, faQrcode, faClipboard, faSliders, faCoins, faEllipsisVertical, faEllipsis, faArrowUp, faArrowDown, faArrowLeft, faXmark, faInfoCircle, faBug, faCheckCircle, faArrowTurnUp, faArrowTurnDown, faPencil, faTags, faShareFromSquare, faRotate, faCode, faBan, faCircle, faPaperPlane, faBolt, faArrowUpFromBracket, faArrowRightToBracket, faPlus, faShieldHalved, faCloudArrowUp, faPaintbrush, faCopy, faBurst, faUserShield, faLock, faLockOpen, faTriangleExclamation, faDownload, faUpload, faRecycle, faListUl, faExpand, faFingerprint, faWandMagicSparkles, faCircleUser, faComment, faKey, faCircleNodes, faBullseye, faEyeSlash, faUpRightFromSquare, faShareNodes, faPaste, faKeyboard, faMoneyBill1, faGears, faTag, faBank, faChevronDown, faChevronUp, faCircleExclamation, faCircleQuestion, faEnvelope, faTwitter, faTelegramPlane, faDiscord, faGithub, faReddit, faCircleArrowUp, faCircleArrowDown, faGlobe, faCubes, faClock, faArrowRotateLeft, faHeartPulse, faSeedling, faChevronLeft, faArrowRightArrowLeft }
interface IconProps extends TouchableOpacityProps {
+2 -2
View File
@@ -83,8 +83,8 @@ const getConnectionRelays = function () {
export const LISTEN_FOR_NWC_EVENTS = 'listenForNwcEvents'
const MIN_LIGHTNING_FEE = 2 // sats
const LIGHTNING_FEE_PERCENT = 1
export const MIN_LIGHTNING_FEE = 2 // sats
export const LIGHTNING_FEE_PERCENT = 1
const MAX_MULTI_PAY_INVOICES = 5
const getSupportedMethods = function () {
+8 -12
View File
@@ -17,13 +17,9 @@ import {
import * as Sentry from '@sentry/react-native'
import type { RootStore } from '../RootStore'
import { MMKVStorage } from '../../services'
import {Database} from '../../services'
import { log } from '../../services/logService'
import { rootStoreModelVersion } from '../RootStore'
import AppError, { Err } from '../../utils/AppError'
import { LogLevel } from '../../services/log/logTypes'
import { CurrencyCode } from '../../services/wallet/currency'
import { ThemeCode } from '../../theme'
/**
@@ -42,8 +38,6 @@ export async function setupRootStore(rootStore: RootStore) {
// let latestSnapshot: any
try {
log.trace('[setupRootStore]', `start`)
// load the last known state from storage
const start = performance.now()
restoredState = MMKVStorage.load(ROOT_STORAGE_KEY) || {}
@@ -51,7 +45,7 @@ export async function setupRootStore(rootStore: RootStore) {
const dataSize = Buffer.byteLength(JSON.stringify(restoredState), 'utf8')
// log.trace({restoredState})
log.trace('[setupRootStore]', `Loading ${dataSize.toLocaleString()} bytes of state from MMKV took ${(mmkvLoaded - start).toLocaleString()} ms.`)
log.trace(`Loading ${dataSize.toLocaleString()} bytes of state from MMKV took ${(mmkvLoaded - start).toLocaleString()} ms.`, {caller: 'setupRootStore'})
// temp dirty migration of proofStore from array to map
if(restoredState?.proofsStore?.proofs && Array.isArray(restoredState.proofsStore.proofs)) {
@@ -61,7 +55,7 @@ export async function setupRootStore(rootStore: RootStore) {
applySnapshot(rootStore, restoredState)
const stateHydrated = performance.now()
log.trace(`[setupRootStore] Hydrating rooStoreModel took ${stateHydrated - mmkvLoaded} ms.`)
log.trace(`Hydrating rooStoreModel took ${stateHydrated - mmkvLoaded} ms.`, {caller: 'setupRootStore'})
const {proofsStore, walletProfileStore, authStore, userSettingsStore, transactionsStore} = rootStore
@@ -80,10 +74,12 @@ export async function setupRootStore(rootStore: RootStore) {
await transactionsStore.loadRecentFromDatabase()
const proofsLoaded = performance.now()
log.trace(`[setupRootStore] Loading proofs and transactions from DB and hydrating took ${proofsLoaded - stateHydrated} ms.`)
log.trace(`Loading proofs and transactions from DB and hydrating took ${proofsLoaded - stateHydrated} ms.`, {
caller: 'setupRootStore'
})
} catch (e: any) {
log.error('[setupRootStore]', Err.STORAGE_ERROR, {message: e.message, params: e.params})
log.error(Err.STORAGE_ERROR, {message: e.message, params: e.params, caller: 'setupRootStore'})
}
// stop tracking state changes if we've already setup
@@ -97,7 +93,7 @@ export async function setupRootStore(rootStore: RootStore) {
// run migrations if needed, needs to be after onSnapshot to be persisted
try {
log.info('[setupRootStore]', `RootStore loaded from MMKV, version is: ${rootStore.version}`)
log.info(`RootStore loaded from MMKV, version is: ${rootStore.version}`, {caller: 'setupRootStore'})
if(rootStore.version < rootStoreModelVersion) {
await _runMigrations(rootStore)
@@ -134,7 +130,7 @@ async function _runMigrations(rootStore: RootStore) {
transactionsStore.addRecentByUnit()
rootStore.setVersion(rootStoreModelVersion)
log.info(`Completed rootStore migrations to the version v${rootStoreModelVersion}`)
log.info(`Completed rootStore migrations to the version v${rootStoreModelVersion}`, {caller: '_runMigrations'} )
}
} catch (e: any) {
throw new AppError(
+3 -1
View File
@@ -12,6 +12,7 @@ import {
SendScreen,
ScanScreen,
LightningPayScreen,
NfcPayScreen,
ContactsScreen,
PictureScreen,
ContactDetailScreen,
@@ -68,7 +69,8 @@ const WalletStack = createNativeStackNavigator({
Receive: ReceiveScreen,
Send: SendScreen,
Scan: ScanScreen,
LightningPay: LightningPayScreen,
LightningPay: LightningPayScreen,
NfcPay: NfcPayScreen,
Transfer: TransferScreen,
Topup: TopupScreen,
CashuPaymentRequest: CashuPaymentRequestScreen,
+1 -1
View File
@@ -165,7 +165,7 @@ export const LightningPayScreen = function LightningPayScreen({ route }: Props)
const scanIcon = useThemeColor('text')
const inputBg = useThemeColor('background')
const inputText = useThemeColor('text')
const contactIcon = useThemeColor('headerTitle')
const contactIcon = useThemeColor('textDim')
const headerBg = useThemeColor('header')
const headerTitle = useThemeColor('headerTitle')
+922
View File
@@ -0,0 +1,922 @@
import React, { FC, useState, useEffect, useRef, useCallback } from 'react'
import {
ViewStyle,
View,
TextStyle,
Alert,
Platform,
DeviceEventEmitter,
} from 'react-native'
import { PaymentRequest as CashuPaymentRequest, MeltQuoteResponse, PaymentRequestTransport, PaymentRequestTransportType, decodePaymentRequest, getDecodedToken } from '@cashu/cashu-ts'
import NfcManager, { NfcTech, Ndef, NfcEvents } from 'react-native-nfc-manager'
import { colors, spacing, typography, useThemeColor } from '../theme'
import EventEmitter from '../utils/eventEmitter'
import { log } from '../services/logService'
import { IncomingDataType, IncomingParser } from '../services/incomingParser'
import AppError, { Err } from '../utils/AppError'
import { BottomModal, Button, Card, ErrorModal, Icon, InfoModal, ListItem, ScanIcon, Screen, Text } from '../components'
import { infoMessage } from '../utils/utils'
import { SvgXml } from 'react-native-svg'
import { formatCurrency, getCurrency, MintUnit, MintUnits } from '../services/wallet/currency'
import { useStores } from '../models'
import { MintHeader } from './Mints/MintHeader'
import { verticalScale } from '@gocodingnow/rn-size-matters'
import useIsInternetReachable from '../utils/useIsInternetReachable'
import { translate } from '../i18n'
import { StackActions, StaticScreenProps, useNavigation } from '@react-navigation/native'
import { observer } from 'mobx-react-lite'
import { Mint, MintBalance } from '../models/Mint'
import { SYNC_STATE_WITH_MINT_TASK, SyncStateTaskResult, TransactionTaskResult, WalletTask } from '../services'
import { SendOption } from './SendScreen'
import { TransferOption } from './TransferScreen'
import { Transaction, TransactionStatus } from '../models/Transaction'
import { Proof } from '../models/Proof'
import { SEND_TASK } from '../services/wallet/sendTask'
import { round, toNumber } from '../utils/number'
import numbro from 'numbro'
import { DecodedLightningInvoice, LightningUtils } from '../services/lightning/lightningUtils'
import { addSeconds } from 'date-fns/addSeconds'
import { CashuUtils } from '../services/cashu/cashuUtils'
import { LIGHTNING_FEE_PERCENT, MIN_LIGHTNING_FEE } from '../models/NwcStore'
import { ResultModalInfo } from './Wallet/ResultModalInfo'
import { TRANSFER_TASK } from '../services/wallet/transferTask'
type Props = StaticScreenProps<{
unit: MintUnit
}>
export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
const navigation = useNavigation<any>()
const { mintsStore, walletStore, proofsStore } = useStores()
const unitRef = useRef<MintUnit>('sat')
const isInternetReachable = useIsInternetReachable()
const [paymentOption, setPaymentOption] = useState<SendOption | TransferOption>(TransferOption.PASTE_OR_SCAN_INVOICE)
const [encodedTokenToSend, setEncodedTokenToSend] = useState<string | undefined>()
const [amountToPay, setAmountToPay] = useState<string | undefined>()
const [encodedCashuPaymentRequest, setEncodedCashuPaymentRequest] = useState<string | undefined>()
const [decodedCashuPaymentRequest, setDecodedCashuPaymentRequest] = useState<CashuPaymentRequest | undefined>()
const [encodedInvoice, setEncodedInvoice] = useState<string>('')
const [invoice, setInvoice] = useState<DecodedLightningInvoice | undefined>()
const [invoiceExpiry, setInvoiceExpiry] = useState<Date | undefined>()
const [meltQuote, setMeltQuote] = useState<MeltQuoteResponse | undefined>()
const [memo, setMemo] = useState('')
const [mintBalanceToSendFrom, setMintBalanceToSendFrom] = useState<MintBalance | undefined>()
const [transactionStatus, setTransactionStatus] = useState<TransactionStatus | undefined>()
const [transaction, setTransaction] = useState<Transaction | undefined>()
const [transactionId, setTransactionId] = useState<number | undefined>()
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<string | undefined>()
const [readNfcData, setReadNfcData] = useState<string | undefined>()
const [mint, setMint] = useState<Mint | undefined>()
const [error, setError] = useState<AppError | undefined>()
const [info, setInfo] = useState<string>('')
// 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<MintBalance[]> => {
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 (
<Screen preset="fixed" contentContainerStyle={$screen}>
<MintHeader mint={mint} unit={unitRef.current} />
<View style={[$headerContainer, { backgroundColor: headerBg }]}>
<Icon
icon='faNfcSymbol'
size={verticalScale(35)}
color={headerTitle}
/>
</View>
<View style={$contentContainer}>
<Card
ContentComponent={
<>
<Icon
icon='faWifi'
size={verticalScale(80)}
color={isNfcEnabled ? colors.palette.success200 : colors.palette.neutral400}
containerStyle={{
alignSelf: 'center',
marginVertical: spacing.large,
transform: [{ rotate: '-90deg' }]
}}
/>
<Text
text={
nfcInfo ? nfcInfo : ''
}
style={{
textAlign: 'center',
marginBottom: spacing.medium,
//fontFamily: typography.code,
color: isNfcEnabled ? colors.palette.success200 : colors.palette.neutral500,
fontSize: 18
}}
/>
</>
}
/>
<View style={$bottomContainer}>
<View style={$buttonContainer}>
<Button
preset='tertiary'
LeftAccessory={() => (
<SvgXml
width={spacing.medium}
height={spacing.medium}
xml={ScanIcon}
fill={scanIcon}
style={{marginHorizontal: spacing.extraSmall}}
/>
)}
onPress={gotoScan}
//style={{backgroundColor: scanButtonColor}}
text='Scan QR instead'
/>
</View>
</View>
</View>
{info && !isProcessing && <InfoModal message={info} />}
{error && <ErrorModal error={error} />}
<BottomModal
isVisible={isResultModalVisible}
ContentComponent={
<>
{resultModalInfo &&
transactionStatus === TransactionStatus.COMPLETED && (
<>
<ResultModalInfo
icon="faCheckCircle"
iconColor={colors.palette.success200}
title={translate('payCommon_completed')}
message={resultModalInfo?.message}
/>
<View style={$buttonContainer}>
<Button
preset="secondary"
tx={'commonClose'}
onPress={gotoWallet}
/>
</View>
</>
)}
{resultModalInfo &&
transactionStatus === TransactionStatus.REVERTED && (
<>
<ResultModalInfo
icon="faRotate"
iconColor={colors.palette.accent300}
title={translate('transactionCommon_reverted')}
message={resultModalInfo?.message}
/>
<View style={$buttonContainer}>
<Button
preset="secondary"
tx={'commonClose'}
onPress={toggleResultModal}
/>
</View>
</>
)}
{resultModalInfo &&
transactionStatus === TransactionStatus.ERROR && (
<>
<ResultModalInfo
icon="faTriangleExclamation"
iconColor={colors.palette.angry500}
title={
resultModalInfo?.title || translate('payCommon_failed')
}
message={resultModalInfo?.message}
/>
<View style={$buttonContainer}>
<Button
preset="secondary"
tx={'commonClose'}
onPress={toggleResultModal}
/>
</View>
</>
)}
{resultModalInfo &&
transactionStatus === TransactionStatus.PENDING && (
<>
<ResultModalInfo
icon="faTriangleExclamation"
iconColor={colors.palette.iconYellow300}
title={translate('payCommon_isPending')}
message={resultModalInfo?.message}
/>
<View style={$buttonContainer}>
<Button
preset="secondary"
tx={'commonClose'}
onPress={gotoWallet}
/>
</View>
</>
)}
</>
}
onBackButtonPress={toggleResultModal}
onBackdropPress={toggleResultModal}
/>
</Screen>
)
})
// 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',
}
+155 -82
View File
@@ -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)
+11 -8
View File
@@ -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
+243 -138
View File
@@ -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<AbortController | null>(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')
+7 -3
View File
@@ -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)
+30 -7
View File
@@ -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) {
<View style={[
$headerContainer, {
backgroundColor: headerBg,
paddingTop: spacing.small,
//paddingTop: spacing.tiny,
//borderWidth: 1
}
]}>
<UnitBalanceBlock
@@ -569,7 +581,11 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
const renderTabBar = (props: any) => {
return(
<View style={{backgroundColor: headerBg, marginTop: -spacing.small}}>
<View style={{
backgroundColor: headerBg,
marginTop: -spacing.small,
//borderWidth: 1,
}}>
<View style={{width: routes.length * tabWidth, alignSelf: 'center', backgroundColor: headerBg}}>
<TabBar
{...props}
@@ -578,7 +594,7 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
<CurrencySign
mintUnit={route.key as MintUnit}
textStyle={{color: 'white'}}
containerStyle={{padding: spacing.medium, width: tabWidth}}
containerStyle={{padding: spacing.small, width: tabWidth}}
/>
)}
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}
/>
<ListItem
leftIcon='faNfcSymbol'
text="Pay with NFC"
subText="Tap to pay using NFC-enabled wallets or POS"
onPress={() => 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}}
/>
<View style={{height: verticalScale(40)}}>
{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 = {
+1
View File
@@ -18,6 +18,7 @@ export * from './TopupScreen'
export * from './LightningPayScreen'
export * from './TokenReceiveScreen'
export * from './CashuPaymentRequestScreen'
export * from './NfcPayScreen'
// ContactsNavigator
export * from './ContactsScreen'
+167 -52
View File
@@ -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, number> = {
[LogLevel.TRACE]: 0,
[LogLevel.DEBUG]: 1,
[LogLevel.INFO]: 2,
[LogLevel.WARN]: 3,
[LogLevel.ERROR]: 4,
}
const levelToSentry: Record<LogLevel, Sentry.SeverityLevel> = {
[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<string, any> } => {
const strings: string[] = []
const objects: Record<string, any>[] = []
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<TransportOptions> = async (props) => {
if (!userSettingsStore.isLoggerOn) return true
const level = props.level.text as LogLevel
const rawMessage = props.rawMsg
let message = ''
let params: Record<string, any> = {}
// === 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 }
+1 -1
View File
@@ -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
+2 -4
View File
@@ -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)
+36 -27
View File
@@ -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<string, any>
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<string, any>,
) {
// 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
+13
View File
@@ -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"