mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
Add POC of NFC pay
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user