Finalize initial NFC implementation, fix wallet won't start when offline

This commit is contained in:
minibits-cash
2025-12-15 13:43:52 +01:00
parent 989e46bf03
commit 91f437ed1b
21 changed files with 486 additions and 156 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ notifee.registerForegroundService(async (notification) => {
if(notification.data.task === SYNC_STATE_WITH_ALL_MINTS_TASK) {
log.debug(`[registerForegroundService] Submitting task ${SYNC_STATE_WITH_ALL_MINTS_TASK} to the queue.`)
WalletTask.syncStateWithAllMintsQueue({isPending: false})
await WalletTask.syncStateWithAllMintsQueueAwaitable({isPending: false})
}
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "minibits_wallet",
"version": "0.3.4-beta.4",
"version": "0.3.4-beta.5",
"private": true,
"scripts": {
"android:clean": "cd android && ./gradlew clean",
@@ -63,6 +63,7 @@
"react-native-fast-image": "^8.6.3",
"react-native-flash-message": "^0.4.2",
"react-native-gesture-handler": "^2.28.0",
"react-native-indicators": "^0.17.0",
"react-native-json-tree": "^1.5.0",
"react-native-keychain": "^10.0.0",
"react-native-localize": "^3.5.2",
@@ -0,0 +1,86 @@
diff --git a/node_modules/react-native-indicators/src/components/indicator/index.js b/node_modules/react-native-indicators/src/components/indicator/index.js
index b2de0a8..feb3c21 100644
--- a/node_modules/react-native-indicators/src/components/indicator/index.js
+++ b/node_modules/react-native-indicators/src/components/indicator/index.js
@@ -175,6 +175,7 @@ export default class Indicator extends PureComponent {
render() {
let { hideAnimation } = this.state;
let { count, hidesWhenStopped, ...props } = this.props;
+ const { key, ...rest } = props;
if (hidesWhenStopped) {
props.style = []
@@ -182,7 +183,7 @@ export default class Indicator extends PureComponent {
}
return (
- <Animated.View {...props}>
+ <Animated.View key={key} {...rest}>
{Array.from(new Array(count), this.renderComponent, this)}
</Animated.View>
);
diff --git a/node_modules/react-native-indicators/src/components/material-indicator/index.js b/node_modules/react-native-indicators/src/components/material-indicator/index.js
index bf7edc4..0bde608 100644
--- a/node_modules/react-native-indicators/src/components/material-indicator/index.js
+++ b/node_modules/react-native-indicators/src/components/material-indicator/index.js
@@ -107,7 +107,7 @@ export default class MaterialIndicator extends PureComponent {
};
return (
- <Animated.View style={styles.layer} {...{ key: index }}>
+ <Animated.View style={styles.layer} key={index}>
<Animated.View style={layerStyle}>
<Animated.View style={[containerStyle, offsetStyle]} collapsable={false}>
<Animated.View style={viewportStyle}>
diff --git a/node_modules/react-native-indicators/src/components/pulse-indicator/index.js b/node_modules/react-native-indicators/src/components/pulse-indicator/index.js
index 53bcf92..e452df8 100644
--- a/node_modules/react-native-indicators/src/components/pulse-indicator/index.js
+++ b/node_modules/react-native-indicators/src/components/pulse-indicator/index.js
@@ -51,7 +51,7 @@ export default class PulseIndicator extends PureComponent {
};
return (
- <Animated.View style={styles.layer} {...{ key: index }}>
+ <Animated.View style={styles.layer} key={index}>
<Animated.View style={pulseStyle} />
</Animated.View>
);
diff --git a/node_modules/react-native-indicators/src/components/skype-indicator/index.js b/node_modules/react-native-indicators/src/components/skype-indicator/index.js
index 8a988df..1a0304f 100644
--- a/node_modules/react-native-indicators/src/components/skype-indicator/index.js
+++ b/node_modules/react-native-indicators/src/components/skype-indicator/index.js
@@ -68,7 +68,7 @@ export default class SkypeIndicator extends PureComponent {
};
return (
- <Animated.View style={[styles.layer, layerStyle]} {...{ key: index }}>
+ <Animated.View style={[styles.layer, layerStyle]} key={index}>
<Animated.View style={ballStyle} />
</Animated.View>
);
diff --git a/node_modules/react-native-indicators/src/components/ui-activity-indicator/index.js b/node_modules/react-native-indicators/src/components/ui-activity-indicator/index.js
index dcde1e5..468e560 100644
--- a/node_modules/react-native-indicators/src/components/ui-activity-indicator/index.js
+++ b/node_modules/react-native-indicators/src/components/ui-activity-indicator/index.js
@@ -61,7 +61,7 @@ export default class UIActivityIndicator extends PureComponent {
};
return (
- <Animated.View style={[styles.layer, layerStyle]} {...{ key: index }}>
+ <Animated.View style={[styles.layer, layerStyle]} key={index}>
<Animated.View style={barStyle} />
</Animated.View>
);
diff --git a/node_modules/react-native-indicators/src/components/wave-indicator/index.js b/node_modules/react-native-indicators/src/components/wave-indicator/index.js
index 2de37d5..1fbe5ce 100644
--- a/node_modules/react-native-indicators/src/components/wave-indicator/index.js
+++ b/node_modules/react-native-indicators/src/components/wave-indicator/index.js
@@ -64,7 +64,7 @@ export default class WaveIndicator extends PureComponent {
};
return (
- <Animated.View style={styles.layer} {...{ key: index }}>
+ <Animated.View style={styles.layer} key={index}>
<Animated.View style={waveStyle} />
</Animated.View>
);
+3 -6
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React, { useEffect, useState } from 'react'
import FlashMessage from "react-native-flash-message"
import {
SafeAreaProvider,
@@ -20,15 +20,12 @@ 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)
function App() {
const {userSettingsStore, relaysStore, authStore, walletStore, walletProfileStore} = useStores()
const [isUserAuthenticated, setIsUserAuthenticated] = useState(false)
const [isDeviceAuthenticated, setIsDeviceAuthenticated] = useState(false)
@@ -65,8 +62,8 @@ function App() {
}
// re-enroll device for JWT authentication if refresh token expired
if(authStore.isRefreshTokenExpired && isInternetReachable && userSettingsStore.isOnboarded) {
log.trace('[useInitialRootStore]', 'Re-enrolling device for JWT authentication')
if(authStore.isRefreshTokenExpired && isInternetReachable === true && userSettingsStore.isOnboarded) {
log.trace('[useInitialRootStore]', 'Re-enrolling device for JWT authentication', {isInternetReachable})
try {
const walletKeys: WalletKeys = await walletStore.getCachedWalletKeys()
const deviceId = walletProfileStore.device
+4 -1
View File
@@ -7,6 +7,7 @@ import { isObj } from '@cashu/cashu-ts/src/utils'
import JSONTree from 'react-native-json-tree'
import Clipboard from '@react-native-clipboard/clipboard'
import { useStores } from '../models'
import useIsInternetReachable from '../utils/useIsInternetReachable'
type ErrorModalProps = {
error: AppError
@@ -41,9 +42,11 @@ export const ErrorModal: FC<ErrorModalProps> = function ({ error }) {
}
}
const isInternetReachable = useIsInternetReachable()
const enrollDevice = async () => {
const keys = await walletStore.getCachedWalletKeys()
if (keys.NOSTR) {
if (keys.NOSTR && isInternetReachable === true) {
authStore.enrollDevice(keys.NOSTR, walletProfileStore.device)
.then(() => {
setIsErrorVisible(false)
+1 -1
View File
@@ -168,7 +168,7 @@ function FocusAwareStatusBar(props: StatusBarProps) {
* - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-Header.md)
*/
export function Header(props: HeaderProps) {
const isInternetReachable = useIsInternetReachable()
//const isInternetReachable = useIsInternetReachable()
const {
backgroundColor = useThemeColor('header'),
+1 -1
View File
@@ -42,7 +42,7 @@ type Props = StaticScreenProps<{
}>
export const CashuPaymentRequestScreen = observer(function CashuPaymentRequestScreen({ route }: Props) {
const isInternetReachable = useIsInternetReachable()
//const isInternetReachable = useIsInternetReachable()
const navigation = useNavigation()
const {
proofsStore,
+1 -1
View File
@@ -34,7 +34,7 @@ import RNExitApp from 'react-native-exit-app'
import { TransactionStatus } from '../models/Transaction'
import { maxTransactionsInHistory } from '../models/TransactionsStore'
import { StaticScreenProps, useNavigation } from '@react-navigation/native'
// refresh // refresh
// refresh // refresh // refresh
type Props = StaticScreenProps<undefined>
+254 -64
View File
@@ -7,6 +7,17 @@ import {
ColorValue,
Platform,
} from 'react-native'
import {
BallIndicator,
BarIndicator,
DotIndicator,
MaterialIndicator,
PacmanIndicator,
PulseIndicator,
SkypeIndicator,
UIActivityIndicator,
WaveIndicator,
} from 'react-native-indicators'
import { PaymentRequest as CashuPaymentRequest, MeltQuoteResponse, PaymentRequestTransport, PaymentRequestTransportType, decodePaymentRequest, getDecodedToken } from '@cashu/cashu-ts'
import NfcManager, { NfcTech, Ndef, NfcEvents, TagEvent } from 'react-native-nfc-manager'
import { colors, spacing, typography, useThemeColor } from '../theme'
@@ -14,7 +25,7 @@ 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 { AmountInput, 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'
@@ -48,6 +59,7 @@ import Animated, {
Easing,
} from 'react-native-reanimated';
import { NfcService } from '../services/nfcService'
import { TranItem } from './TranDetailScreen'
//import Animated from 'react-native-reanimated'
const ContactlessIcon = (color: ColorValue | string) => `<?xml version="1.0" encoding="utf-8"?>
@@ -110,9 +122,11 @@ type Props = StaticScreenProps<{
unit: MintUnit
}>
const SYNC_STATE_WITH_MINT_TIMEOUT = 10 * 1000
export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
const navigation = useNavigation<any>()
const { mintsStore, walletStore, proofsStore } = useStores()
const { mintsStore, walletStore, proofsStore, transactionsStore } = useStores()
const unitRef = useRef<MintUnit>('sat')
const isInternetReachable = useIsInternetReachable()
@@ -139,6 +153,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
const [isNfcEnabled, setIsNfcEnabled] = useState(false)
const [isNfcSupported, setIsNfcSupported] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [isPaid, setIsPaid] = useState(false)
const [nfcInfo, setNfcInfo] = useState<string | undefined>()
const [readNfcData, setReadNfcData] = useState<string | undefined>()
const [mint, setMint] = useState<Mint | undefined>()
@@ -164,7 +179,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
return
}
setNfcInfo('Tap your device to the NFC reader to pay')
setNfcInfo('Hold your device close to the NFC reader.')
setIsNfcEnabled(true)
// Start listening for tags immediately
@@ -196,6 +211,39 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
}, [navigation, isNfcEnabled, isProcessing])
const showSuccessModal = function () {
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);
//setNfcInfo('Payment completed successfully!');
}
const showPendingModal = function () {
const amountSentInt = Math.round(
(toNumber(amountToPay || '0') * getCurrency(unitRef.current).precision)
);
const currency = getCurrency(unitRef.current);
setResultModalInfo({
status: TransactionStatus.PENDING,
//title: 'Payment is pending',
message: `${formatCurrency(amountSentInt, currency.code)} ${currency.code} were sent successfully, but the payee has not yet claimed the ecash . You can wait for confirmation or revert the transaction.`,
});
setTransactionStatus(TransactionStatus.PENDING);
setIsResultModalVisible(true)
}
// Stable handler — only recreated when dependencies actually change
const handleSyncStateResult = useCallback(async (result: SyncStateTaskResult) => {
@@ -208,21 +256,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
// ——— SUCCESS: Receiver claimed the ecash ———
if (completedTransactionIds?.includes(transactionId)) {
log.debug('[NfcScreen.handleSyncStateResult] 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);
setNfcInfo('Payment completed successfully!');
showSuccessModal()
return;
}
@@ -250,8 +284,10 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
// One-time listener with auto-cleanup using ref
const syncStateListenerRef = useRef<((r: SyncStateTaskResult) => void) | null>(null)
const fallbackTimeoutRef = useRef<number>(null)
useEffect(() => {
log.trace('[useEffect] Triggered to set syncStateListenerRef', {transactionId})
// If no transactionId → nothing to listen for
if (!transactionId) {
// Clean up any dangling listener
@@ -259,10 +295,33 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
EventEmitter.off(`ev_${SYNC_STATE_WITH_MINT_TASK}_result`, syncStateListenerRef.current)
syncStateListenerRef.current = null
}
if (fallbackTimeoutRef.current) {
clearTimeout(fallbackTimeoutRef.current)
fallbackTimeoutRef.current = null
}
return
}
const checkFallback = () => {
const currentTx = transactionsStore.findById(transactionId)
log.trace('[checkFallback] Check after timeout returned transaction', {status: currentTx?.status})
if (currentTx?.status === TransactionStatus.COMPLETED) {
showSuccessModal()
}
if(currentTx?.status === TransactionStatus.PENDING) {
showPendingModal()
}
return
// If still PENDING or ERROR → do nothing, user can close manually
}
const eventName = `ev_${SYNC_STATE_WITH_MINT_TASK}_result`
// Start fallback timer — 15 seconds after token write. Skip if offline.
if(isInternetReachable) {
fallbackTimeoutRef.current = setTimeout(checkFallback, SYNC_STATE_WITH_MINT_TIMEOUT)
}
// Remove previous listener (defensive)
if (syncStateListenerRef.current) {
@@ -270,7 +329,13 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
}
const handler = (result: SyncStateTaskResult) => {
handleSyncStateResult(result);
handleSyncStateResult(result)
// Clear fallback — event won
if (fallbackTimeoutRef.current) {
clearTimeout(fallbackTimeoutRef.current)
fallbackTimeoutRef.current = null
}
// "keep listening" for poller if our transaction is still pending
const shouldStaySubscribed = (() => {
@@ -297,13 +362,15 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
// Cleanup on unmount or when transactionId changes/becomes null
return () => {
if (syncStateListenerRef.current) {
EventEmitter.off(eventName, syncStateListenerRef.current)
syncStateListenerRef.current = null
EventEmitter.off(eventName, syncStateListenerRef.current)
syncStateListenerRef.current = null
}
if (fallbackTimeoutRef.current) {
clearTimeout(fallbackTimeoutRef.current)
fallbackTimeoutRef.current = null
}
}
}, [
handleSyncStateResult, // ← stable thanks to useCallback
])
}, [transactionId, handleSyncStateResult])
// ====================== Lightning Payment (Transfer Task) Result Handler ======================
@@ -319,6 +386,8 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
return
}
setIsProcessing(true)
//log.info('NFC Tag found', {tag});
const bytes = new Uint8Array(tag.ndefMessage[0]?.payload ?? [])
decoded = Ndef.text.decodePayload(bytes)
@@ -337,7 +406,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
return
}
setNfcInfo('Processing payment request...')
setNfcInfo('Processing, keep your device still...')
log.info('NFC tag decoded', { decoded })
@@ -352,7 +421,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
})
handleError(e)
} finally {
setIsProcessing(false)
}
}
@@ -408,7 +477,13 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
)
if(!selectedBalance) { // we might not find suitable balance if offline
setInfo('Wallet do')
setResultModalInfo({
status: (transaction?.status || TransactionStatus.ERROR) as TransactionStatus,
title: 'Offline payment failed',
message: 'Wallet is offline and does not have ecash denominations matching requested amount',
});
toggleResultModal()
setNfcInfo('Can not pay requested amount')
return
}
@@ -489,7 +564,19 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
log.trace('[NfcScreen] handleSendTaskResult: Write completed.')
setNfcInfo('Ecash token sent successfully.')
setIsPaid(true)
expandHeader()
// Show explanatory modal immediately when offline
if (!isInternetReachable) {
setResultModalInfo({
status: TransactionStatus.PENDING,
title: translate('commonOfflinePretty'),
message: 'Your wallet sent this payment while offline. Consult the payee that the funds have been claimed.',
})
toggleResultModal()
}
} catch (err: any) {
log.error('Error in handleSendTaskResult', {error: err, caller: 'NfcScreen.handleSendTaskResult'})
@@ -500,6 +587,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
})
toggleResultModal()
}
}
@@ -507,7 +595,13 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
const handleLightningInvoice = async (encodedInvoice: string) => {
if (!isInternetReachable) {
setInfo(translate('commonOfflinePretty'))
setResultModalInfo({
status: TransactionStatus.ERROR,
title: translate('commonOfflinePretty'),
message: 'Can not use Lightning payment method while offline.',
})
toggleResultModal()
return
}
@@ -524,19 +618,24 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
const balances = proofsStore.getMintBalancesWithEnoughBalance(totalRequired, unitRef.current)
if (balances.length === 0) {
setInfo(
translate('transferScreen_insufficientFunds', {
setResultModalInfo({
status: TransactionStatus.ERROR,
title: 'Not enough funds',
message: translate('transferScreen_insufficientFunds', {
currency: getCurrency(unitRef.current).code,
amount: totalRequired,
})
)
}),
})
toggleResultModal()
return
}
const selectedBalance = balances[0]
setMintBalanceToSendFrom(selectedBalance)
setInfo('Creating Lightning payment quote...')
setNfcInfo('Creating Lightning payment quote...')
const quote = await walletStore.createLightningMeltQuote(
selectedBalance.mintUrl,
unitRef.current,
@@ -552,8 +651,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
if (description) setMemo(description)
try {
setInfo('Paying Lightning invoice...')
setIsLoading(true)
setNfcInfo('Paying Lightning invoice...')
const result = await WalletTask.transferQueueAwaitable(
selectedBalance,
@@ -601,12 +699,15 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
})
}
} else {
// Success or settled pending
setResultModalInfo({
status,
message: message || 'Lightning payment successful!',
title: status === TransactionStatus.COMPLETED ? 'Payment sent!' : undefined,
});
// Success or settled pending
setResultModalInfo({
status,
message: message || 'Lightning payment successful!',
title: status === TransactionStatus.COMPLETED ? 'Payment sent!' : undefined,
});
setIsPaid(true)
expandHeader()
setNfcInfo(transaction.memo || 'Lightning payment settled.')
}
} else {
// Fallback (shouldn't happen)
@@ -616,11 +717,10 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
message: 'No transaction data received',
})
}
toggleResultModal()
} finally {
setIsLoading(false)
setInfo('')
} catch (e: any) {
handleError(e)
}
}
@@ -720,36 +820,123 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
setInfo('')
setError(undefined)
setIsLoading(false)
setIsProcessing(false)
setIsResultModalVisible(false)
setResultModalInfo(undefined)
setIsPaid(false)
}
const toggleResultModal = () => setIsResultModalVisible(previousState => !previousState)
const headerHeight = useSharedValue(spacing.screenHeight * 0.15); // Initial height
const collapseHeader = () => {
headerHeight.value = spacing.screenHeight * 0.15
}
const expandHeader = () => {
headerHeight.value = spacing.screenHeight * 0.20
}
const animatedHeader = useAnimatedStyle(() => {
return {
height: withTiming(headerHeight.value, { duration: 300 }),
// opacity: withTiming(isVisible.value ? 1 : 0, { duration: 300 }),
}
})
// Theme
const hintText = useThemeColor('textDim')
const headerBg = useThemeColor('header')
const headerTitle = useThemeColor('headerTitle')
const scanIcon = useThemeColor('text')
const scanButtonColor = useThemeColor('card')
const indicatorStandby = useThemeColor('headerTitle')
const indicatorProcessing = useThemeColor('button')
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>
<Animated.View style={[animatedHeader, $headerContainer, {backgroundColor: headerBg}]}>
<View style={{}}>
{isPaid && (
<AmountInput
//ref={amountInputRef}
value={`${amountToPay}`}
onChangeText={() => {}}
unit={unitRef.current}
editable={false}
/>
)}
</View>
</Animated.View>
<View style={$contentContainer}>
<Card
ContentComponent={
{isPaid && transaction ? (
<Card
style={{padding: spacing.medium}}
ContentComponent={
<>
<TranItem
label="tranDetailScreen_trasferredTo"
isFirst={true}
value={
mintsStore.findByUrl(transaction.mint)
?.shortname as string
}
/>
{transaction?.memo && (
<TranItem
label="tranDetailScreen_memoFromInvoice"
value={transaction.memo as string}
/>
)}
<TranItem
label="transactionCommon_feePaid"
value={transaction.fee || 0}
unit={unitRef.current}
isCurrency={true}
/>
<TranItem
label="tranDetailScreen_status"
value={transaction.status as string}
/>
</>
}
/>
) : (
<Card
ContentComponent={
<>
<PulsingContactlessIcon isNfcEnabled={isNfcEnabled} />
<Text
{isProcessing ? (
<WaveIndicator
color={indicatorProcessing}
size={verticalScale(120)}
animating={resultModalInfo ? false : true}
animationDuration={2000}
/>
) : isNfcEnabled ? (
<PulseIndicator
color={indicatorStandby}
size={verticalScale(120)}
animating={resultModalInfo ? false : true}
hidesWhenStopped={false}
animationDuration={2000}
/>
) : (
<PulseIndicator
color={hintText}
size={verticalScale(120)}
animating={false}
useNativeDriver={true}
hidesWhenStopped={false}
//animationDuration={2000}
/>
)}
</>
}
FooterComponent={
<Text
text={
nfcInfo ? nfcInfo : ''
}
@@ -757,17 +944,20 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
textAlign: 'center',
marginBottom: spacing.medium,
//fontFamily: typography.code,
color: isNfcEnabled ? colors.palette.success200 : colors.palette.neutral500,
color: isProcessing ? indicatorProcessing : isNfcEnabled ? indicatorStandby : hintText,
fontSize: 18,
}}
/>
</>
/>
}
style={{height: spacing.screenHeight * 0.3}}
/>
)}
<View style={$bottomContainer}>
<View style={$buttonContainer}>
<Button
{!isProcessing && (<Button
preset='tertiary'
LeftAccessory={() => (
<SvgXml
@@ -781,7 +971,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
onPress={gotoScan}
//style={{backgroundColor: scanButtonColor}}
text='Scan QR instead'
/>
/>)}
</View>
</View>
</View>
@@ -854,7 +1044,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
<ResultModalInfo
icon="faTriangleExclamation"
iconColor={colors.palette.iconYellow300}
title={translate('payCommon_isPending')}
title={resultModalInfo?.title || translate('payCommon_isPending')}
message={resultModalInfo?.message}
/>
<View style={$buttonContainer}>
+23 -16
View File
@@ -433,23 +433,30 @@ export const SendScreen = observer(function SendScreen({ route }: Props) {
// Offline send
useEffect(() => {
if(isInternetReachable) return
log.trace('[Offline send]')
// if offline we set all non-zero mint balances as available to allow ecash selection
const availableBalances = proofsStore.getMintBalancesWithEnoughBalance(1, unitRef.current)
if (availableBalances.length === 0) {
setInfo('Not enough funds to send')
return
useEffect(() => {
log.trace('Offline send effect', { isInternetReachable })
if (isInternetReachable === true) {
// We are definitively online → force online mode
log.trace('[Online send] Switching to online mode')
setIsOfflineSend(false)
setAvailableMintBalances([]) // or whatever your online default is
setInfo('') // clear any offline-specific messages
} else if (isInternetReachable === false) {
// We are definitively offline → enable offline send
log.trace('[Offline send] Enabling offline mode')
const availableBalances = proofsStore.getMintBalancesWithEnoughBalance(1, unitRef.current)
if (availableBalances.length === 0) {
setInfo('Not enough funds to send')
return // keep isOfflineSend false or as-is
}
setIsOfflineSend(true)
setAvailableMintBalances(availableBalances)
}
log.trace('[Offline send] Setting availableBalances')
setIsOfflineSend(true)
setAvailableMintBalances(availableBalances)
// If isInternetReachable is null (still checking), do nothing
}, [isInternetReachable])
+4 -3
View File
@@ -1143,7 +1143,8 @@ const SendInfoBlock = function (props: {
throw new AppError(Err.VALIDATION_ERROR, message)
}
const pendingProofs = proofsStore.getByTransactionId(transaction.id)
const allProofs = proofsStore.getByTransactionId(transaction.id)
const pendingProofs = allProofs.filter(p => p.isPending === true)
if(pendingProofs.length === 0) {
const message = 'Could not get proofs related to the transaction from wallet state.'
@@ -1160,9 +1161,10 @@ const SendInfoBlock = function (props: {
})
}
setIsLoading(true)
const result = await WalletTask.revertQueueAwaitable(transaction)
await handleRevertTaskResult(result)
setIsLoading(true)
} catch (e: any) {
setResultModalInfo({status: TransactionStatus.ERROR, message: e.message})
@@ -1186,7 +1188,6 @@ const SendInfoBlock = function (props: {
message: result.message,
})
}
setIsLoading(false)
toggleResultModal()
}
+1 -1
View File
@@ -634,7 +634,7 @@ const transfer = async function () {
)
await handleTransferTaskResult(result)
} catch (e: any) {
handleError(e)
}
+12 -6
View File
@@ -684,17 +684,23 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
/>}
TitleActionComponent={<HeaderTitle />}
RightActionComponent={
<>
<View style={{justifyContent: 'flex-end', flexDirection: 'row'}}>
{pendingCount > 0 && (
<Pressable
style={{flexDirection: 'row', alignItems:'center', marginRight: spacing.medium}}
style={{flexDirection: 'row', alignItems:'center', marginRight: spacing.small}}
onPress={gotoPendingTransactions}
>
<Icon icon='faClock' color={headerTitleColor}/>
<Text text={`${pendingCount}`} style={{color: headerTitleColor}} />
</Pressable>
)}
</>
<Pressable
style={{marginRight: spacing.medium}}
onPress={gotoNfcPay}
>
<Icon icon='faNfcSymbol' color={headerTitleColor}/>
</Pressable>
</View>
}
/>
{groupedMints.length > 0 && (
@@ -846,14 +852,14 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
tx="walletScreen_payWithLightning"
subTx="walletScreen_payWithLightningDesc"
onPress={() => gotoLightningPay()}
bottomSeparator={true}
//bottomSeparator={true}
/>
<ListItem
{/*<ListItem
leftIcon='faNfcSymbol'
text="Pay with NFC"
subText="Tap NFC-enabled wallet or POS to pay"
onPress={() => gotoNfcPay()}
/>
/>*/}
</>
}
onBackButtonPress={toggleSendModal}
+1
View File
@@ -291,6 +291,7 @@ export const WelcomeScreen = function ({ route }: Props) {
const $dotsContainer: ViewStyle ={
height: 50,
justifyContent: 'space-evenly',
marginBottom: 50
}
const $dotContainer: ViewStyle ={
+25 -20
View File
@@ -3,6 +3,7 @@ import NfcManager, { NfcTech, Ndef, NfcEvents, TagEvent } from 'react-native-nfc
import { log } from './logService'
import AppError, { Err } from '../utils/AppError'
const NFC_STALE_DELAY_MS = 120
const init = async function () {
@@ -26,7 +27,7 @@ const goToNfcSetting = function () {
* Ensures a fresh NFC connection by cancelling any previous session
* and adding a tiny delay on Android
*/
const withFreshNfcConnection = async <T>(action: () => Promise<T>): Promise<T> => {
/* const withFreshNfcConnection = async <T>(action: () => Promise<T>): Promise<T> => {
// Cancel any previous session
await NfcManager.cancelTechnologyRequest().catch(() => {})
log.trace('[withFreshNfcConnection] Pre-cancel completed')
@@ -36,37 +37,41 @@ const withFreshNfcConnection = async <T>(action: () => Promise<T>): Promise<T> =
}
try {
return await action() // Only requestTechnology + getTag / writeNdef
return await action()
} finally {
// This now runs IMMEDIATELY after read/write
await NfcManager.cancelTechnologyRequest().catch(() => {})
log.trace('[withFreshNfcConnection] Post-cancel completed')
}
}*/
const readNdefTag = async () => {
log.trace('[readNdefTag] start')
try {
await NfcManager.requestTechnology(NfcTech.Ndef) // needs to run only once per read/write session
log.trace('[readNdefTag] requestTechnology completed')
const tag = await NfcManager.getTag()
log.trace('[readNdefTag] tag read') // do not close session
return tag
} catch(e: any) {
throw new AppError(Err.NFC_ERROR, e.message, {caller: 'readNdefTag', error: String(e)})
}
}
const readNdefTag = async () => {
return await withFreshNfcConnection(async () => {
log.trace('[readNdefTag] requesting technology')
await NfcManager.requestTechnology(NfcTech.Ndef)
log.trace('[readNdefTag] requestTechnology completed')
const tag = await NfcManager.getTag()
return tag
})
}
const writeNdefMessage = async (text: string) => {
return await withFreshNfcConnection(async () => {
await NfcManager.requestTechnology(NfcTech.Ndef)
log.trace('[writeNdefMessage] requestTechnology completed')
try {
log.trace('[writeNdefMessage] start') // no more requestTechnology, it requires second tap then
const bytes = Ndef.encodeMessage([Ndef.textRecord('en', text)])
await NfcManager.ndefHandler.writeNdefMessage(bytes)
})
log.trace('[writeNdefMessage] write completed')
await NfcManager.cancelTechnologyRequest().catch(() => {}) // close session
} catch(e: any) {
throw new AppError(Err.NFC_ERROR, e.message, {caller: 'writeNdefMessage', error: String(e)})
}
}
export const NfcService = {
init,
isEnabled,
+5 -1
View File
@@ -611,7 +611,6 @@ const getLastTransactionBy = function (
throw new AppError(
Err.DATABASE_ERROR,
'Exactly one of paymentId, quote, or paymentRequest must be provided',
'Invalid criteria'
)
}
@@ -888,6 +887,11 @@ const addOrUpdateProofs = function (
throw new Error('Conflicting proof states')
}
if(proofs.length === 0) {
log.error('[addOrUpdateProofs] Empty proof array passed')
return 0
}
for (const proof of proofs) {
insertQueries.push([
+2 -1
View File
@@ -35,7 +35,8 @@ const unit = transaction.unit as MintUnit
try {
const pendingProofs = proofsStore.getByTransactionId(transaction.id)
const allProofs = proofsStore.getByTransactionId(transaction.id)
const pendingProofs = allProofs.filter(p => p.isPending === true)
if(pendingProofs.length === 0) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing proofs to swap')
+9 -8
View File
@@ -118,12 +118,12 @@ export const sendTask = async function (
outputToken
})
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
transactionData.push({
status: TransactionStatus.PENDING,
createdAt: new Date(),
})
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
transaction.update({
status: TransactionStatus.PENDING,
@@ -143,13 +143,14 @@ export const sendTask = async function (
const wsWallet = new CashuWallet(wsMint)
try {
log.trace('[send] Subscribing to onProofStateUpdates for proof', {secret: proofsToSend[0]})
const unsub = await wsWallet.onProofStateUpdates(
[proofsToSend[0]],
async (proofState: ProofState) => {
log.trace(`Websocket: proof state updated: ${proofState.state}`)
log.trace(`Websocket: proof state updated: ${proofState.state} with secret: ${proofsToSend[0].secret}`)
if (proofState.state == CheckStateEnum.SPENT) {
await WalletTask.syncStateWithMintQueueAwaitable({proofsToSync, mintUrl, isPending: true})
WalletTask.syncStateWithMintQueueAwaitable({proofsToSync, mintUrl, isPending: true})
unsub()
}
},
@@ -171,7 +172,7 @@ export const sendTask = async function (
maxPolls: 3,
maxErrors: 1
},
{proofsToSync, mintUrl, isPending: true}
{proofsToSend, mintUrl, isPending: true}
)
.then(() => log.trace('[syncStateWithMintPoller]', 'polling completed', {mintUrl}))
}
@@ -222,7 +223,8 @@ export const sendFromMintSync = async function (
) {
const mintUrl = mintBalance.mintUrl
const mintInstance = mintsStore.findByUrl(mintUrl)
let proofsToSendFrom: Proof[] = []
let proofsToSendFrom: Proof[] = []
let proofsToSend: CashuProof[] | Proof[] = []
try {
if (!mintInstance) {
@@ -322,7 +324,6 @@ export const sendFromMintSync = async function (
let swapFeeReserve: number = 0
let returnedAmount = 0
let swapFeePaid: number = 0
let proofsToSend: CashuProof[] | Proof[] = []
let returnedProofs: CashuProof[] = []
let isSwapNeeded: boolean = false
@@ -463,7 +464,7 @@ export const sendFromMintSync = async function (
log.error('[sendFromMintSync] Going to clean spent proofs from proofsToSendFrom and from pending', {transactionId})
await WalletTask.syncStateWithMintTask({
proofsToSync: proofsToSendFrom,
proofsToSync: proofsToSend as Proof[],
mintUrl,
isPending: true
})
+4 -4
View File
@@ -1109,7 +1109,7 @@ const syncStateWithMintQueueAwaitable = function (
})
const now = Date.now()
const taskId = `syncStateWithMintTask-${now}`
const taskId = `${SYNC_STATE_WITH_MINT_TASK}-${now}`
return new Promise<SyncStateTaskResult>((resolve, reject) => {
let resolved = false
@@ -1126,7 +1126,7 @@ const syncStateWithMintQueueAwaitable = function (
reject(error)
}
const eventName = `ev_syncStateWithMintTask_result`
const eventName = `ev_${SYNC_STATE_WITH_MINT_TASK}_result`
const handler = (result: SyncStateTaskResult) => {
EventEmitter.off(eventName, handler)
resolveOnce(result)
@@ -1159,7 +1159,7 @@ const syncStateWithMintQueueAwaitable = function (
EventEmitter.off(eventName, handler)
rejectOnce(new AppError(Err.TIMEOUT_ERROR, 'Sync mint state timed out'))
}
}, 20000)
}, TASK_QUEUE_TIMEOUT)
})
}
@@ -1429,7 +1429,7 @@ const syncStateWithMintTask = async function (
revertedTransactionIds: revertedTxIds,
}
} catch (e: any) {
log.error('[syncStateWithMintTask] failed', { mintUrl, error: e.message })
log.error('[syncStateWithMintTask] failed', { mintUrl, error: e.message, stack: e.stack })
if (mint && e.name === Err.MINT_ERROR && e.message.includes('network')) {
mint.setStatus(MintStatus.OFFLINE)
+33 -19
View File
@@ -1,23 +1,37 @@
// utils/useIsInternetReachable.ts
import { useEffect, useState } from 'react'
import NetInfo, { NetInfoState } from '@react-native-community/netinfo'
import {log} from '../services/logService'
import NetInfo from '@react-native-community/netinfo'
import { log } from '../services/logService'
export default function useIsInternetReachable(): boolean {
// Start as false (conservative/safe default — assume offline until proven otherwise)
const [isInternetReachable, setIsInternetReachable] = useState<boolean>(false)
export default function useIsInternetReachable() {
// useState hook for setting netInfo
// const [netInfo, setNetInfo] = useState({} as NetInfoState)
const [isInternetReachable, setIsInternetReachable] = useState<boolean>(true)
useEffect(() => {
// Whenever connection status changes below event fires
const unsubscribe = NetInfo.addEventListener((state: NetInfoState) => {
setIsInternetReachable(state.isInternetReachable as boolean)
useEffect(() => {
// Force an immediate check on mount — this reduces delay significantly
NetInfo.refresh().then((state) => {
log.trace('useIsInternetReachable', 'Initial refresh', {
isConnected: state.isConnected,
isInternetReachable: state.isInternetReachable,
})
if (state.isInternetReachable !== null) {
setIsInternetReachable(state.isInternetReachable)
}
})
// Subscribe to future changes
const unsubscribe = NetInfo.addEventListener((state) => {
// Only update when we have a definitive boolean (ignore null)
if (state.isInternetReachable !== null) {
log.trace('useIsInternetReachable', 'Connection change', {
isInternetReachable: state.isInternetReachable,
})
// Event cleanup function
return () => {
unsubscribe()
}
}, [])
return isInternetReachable
}
setIsInternetReachable(state.isInternetReachable)
}
})
return () => unsubscribe()
}, [])
return isInternetReachable
}
+14 -1
View File
@@ -13604,6 +13604,7 @@ __metadata:
react-native-fast-image: ^8.6.3
react-native-flash-message: ^0.4.2
react-native-gesture-handler: ^2.28.0
react-native-indicators: ^0.17.0
react-native-json-tree: ^1.5.0
react-native-keychain: ^10.0.0
react-native-localize: ^3.5.2
@@ -15041,7 +15042,7 @@ __metadata:
languageName: node
linkType: hard
"prop-types@npm:^15.7.2, prop-types@npm:^15.8.0, prop-types@npm:^15.8.1":
"prop-types@npm:^15.5.10, prop-types@npm:^15.7.2, prop-types@npm:^15.8.0, prop-types@npm:^15.8.1":
version: 15.8.1
resolution: "prop-types@npm:15.8.1"
dependencies:
@@ -15485,6 +15486,18 @@ __metadata:
languageName: node
linkType: hard
"react-native-indicators@npm:^0.17.0":
version: 0.17.0
resolution: "react-native-indicators@npm:0.17.0"
dependencies:
prop-types: ^15.5.10
peerDependencies:
react: "*"
react-native: "*"
checksum: 69d0d485b6a4d81f2daaa98ca9029d0ed760470a5ae9c5cce2b81132224b5116ea9bd6829c6bc0d7b0203db4c43e876020005122c306f1a5734c4629c4bb7933
languageName: node
linkType: hard
"react-native-iphone-screen-helper@npm:^2.0.2":
version: 2.1.2
resolution: "react-native-iphone-screen-helper@npm:2.1.2"