diff --git a/package.json b/package.json index b40e64f8..4e1c298d 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "machine-translate": "npx @inlang/cli machine translate --project minibits.inlang" }, "dependencies": { - "@cashu/cashu-ts": "minibits-cash/cashu-ts#c85d4638c007ac8126bc83a7287a6d848fcc8153", + "@cashu/cashu-ts": "minibits-cash/cashu-ts#e97b1a4ee9a7ddbb1468302c6996a9d6d3efcb6f", "@fortawesome/fontawesome-svg-core": "^7.1.0", "@fortawesome/free-brands-svg-icons": "^7.1.0", "@fortawesome/free-regular-svg-icons": "^7.1.0", diff --git a/src/screens/NfcPayScreen.tsx b/src/screens/NfcPayScreen.tsx index 49babd07..e592334c 100644 --- a/src/screens/NfcPayScreen.tsx +++ b/src/screens/NfcPayScreen.tsx @@ -47,6 +47,7 @@ import Animated, { useAnimatedStyle, Easing, } from 'react-native-reanimated'; +import { NfcService } from '../services/nfcService' //import Animated from 'react-native-reanimated' const ContactlessIcon = (color: ColorValue | string) => ` @@ -113,6 +114,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { const navigation = useNavigation() const { mintsStore, walletStore, proofsStore } = useStores() const unitRef = useRef('sat') + const isInternetReachable = useIsInternetReachable() const [encodedTokenToSend, setEncodedTokenToSend] = useState() @@ -132,8 +134,6 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { 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) @@ -150,17 +150,15 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { useEffect(() => { const initNfc = async () => { try { - const supported = await NfcManager.isSupported() + const supported = await NfcService.init() // runs NfcManager.start() setIsNfcSupported(supported) if (!supported) { setNfcInfo('NFC is not supported on this device') return } - - await NfcManager.start() - - const enabled = await NfcManager.isEnabled() + + const enabled = await NfcService.isEnabled() if (!enabled) { setNfcInfo('Please enable NFC in your device settings') return @@ -198,123 +196,10 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { }, [navigation, isNfcEnabled, isProcessing]) - const sendTaskListenerRef = useRef<((result: TransactionTaskResult) => void) | null>(null); - - const handleSendTaskResult = useCallback( - async (result: TransactionTaskResult) => { - log.debug('[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 }); + const handleSyncStateResult = useCallback(async (result: SyncStateTaskResult) => { + log.trace('[NfcScreen.handleSyncStateResult] handleSyncStateResult triggered', { result, transactionId }); if (!transactionId) return; @@ -322,7 +207,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { // ——— SUCCESS: Receiver claimed the ecash ——— if (completedTransactionIds?.includes(transactionId)) { - log.debug('[NfcScreen] Ecash claimed successfully by the payee.', { transactionId }); + log.debug('[NfcScreen.handleSyncStateResult] Ecash claimed successfully by the payee.', { transactionId }); const amountSentInt = Math.round( (toNumber(amountToPay || '0') * getCurrency(unitRef.current).precision) @@ -337,6 +222,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { }); setTransactionStatus(TransactionStatus.COMPLETED); setIsResultModalVisible(true); + setNfcInfo('Payment completed successfully!'); return; } @@ -345,7 +231,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { 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.' + const message = update?.message || 'Transaction failed to complete on the mint side.' setResultModalInfo({ status: TransactionStatus.ERROR, @@ -422,262 +308,72 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { // ====================== Lightning Payment (Transfer Task) Result Handler ====================== - const handleTransferTaskResult = useCallback( - async (result: TransactionTaskResult) => { - log.debug('[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 readNdefOnce = function () { - const cleanUp = () => { - NfcManager.setEventListener(NfcEvents.DiscoverTag, null); - NfcManager.setEventListener(NfcEvents.SessionClosed, null); - }; - - return new Promise((resolve) => { - let tagFound: TagEvent | null = null; - - NfcManager.setEventListener(NfcEvents.DiscoverTag, (tag: TagEvent) => { - tagFound = tag; - resolve(tagFound); - - NfcManager.unregisterTagEvent().catch(() => 0); - }); - - NfcManager.setEventListener(NfcEvents.SessionClosed, (e: any) => { - if (e) { - log.error(Err.NFC_ERROR, 'NFC Session closed with error', e.message); - } - - cleanUp() - - if (!tagFound) { - resolve(null) - } - }); - - NfcManager.registerTagEvent(); - }); - } - - - const readTag = async function() { - try { - await NfcManager.requestTechnology([NfcTech.Ndef]); - - const tag = await NfcManager.getTag(); - return tag - - } catch (e: any) { - // for tag reading, we don't actually need to show any error - log.error(Err.NFC_ERROR, 'Failed to read NFC tag', e.message); - } finally { - NfcManager.cancelTechnologyRequest(); - } - } - - const startNfcSession = async () => { + let decoded: string | undefined = undefined; try { - log.debug('Starting NFC session for payment request reading...', {caller: 'startNfcSession'}) + log.debug('[startNfcSession] Starting NFC session for payment request reading...') - const tag = await readNdefOnce() - // const tag = await readTag() + const tag = await NfcService.readNdefTag() if (!tag) { return } - log.warn('Tag found', {tag}); + //log.info('NFC Tag found', {tag}); + const bytes = new Uint8Array(tag.ndefMessage[0]?.payload ?? []) + decoded = Ndef.text.decodePayload(bytes) - /*await NfcManager.requestTechnology(NfcTech.Ndef, { - alertMessage: 'Hold phone near the device...', - invalidateAfterFirstRead: false, - }) + if(!decoded || decoded.length < 140) { + const msg = 'This NFC tag can not be processed, missing or truncated NDEF message.' + + setResultModalInfo({ + status: TransactionStatus.ERROR, + message: msg, + }) - const tag = await NfcManager.getTag(); - log.warn('Tag found', {tag}); + toggleResultModal() - if(!tag || !tag.ndefMessage || tag.ndefMessage.length === 0) { - setInfo('No NDEF message found on the NFC tag') + log.error(Err.NFC_ERROR, msg, { tag }) return } - const bytes = new Uint8Array( - tag.ndefMessage[0].payload - ); + setNfcInfo('Processing payment request...') - let payload: string + log.info('NFC tag decoded', { decoded }) - const decoded = Ndef.text.decodePayload(bytes); + setReadNfcData(decoded) + await handlePaymentRequest(decoded) - log.warn('NFC tag decoded', { decoded })*/ - - - // setNfcInfo('Reading payment request...') - - // Listen for tag discovery - /*NfcManager.setEventListener(NfcEvents.DiscoverTag, async (tag: any) => { - if (isProcessing) return - setIsProcessing(true) - setNfcInfo('Reading payment request...') - log.info('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 (e: any) { - log.error(Err.NFC_ERROR, e.message) + log.error('[NfcPayScreen] handlePaymentRequest failed', { + error: e?.message ?? String(e), + stack: e?.stack, + decoded: decoded?.slice?.(0,200) + }) handleError(e) } finally { - NfcManager.cancelTechnologyRequest().catch(() => {}) + } } // handlePaymentRequest.ts (main entry) const handlePaymentRequest = async (data: string) => { - try { - Alert.alert(data) - log.info('[handlePaymentRequest] received data via NFC', { data }) + + // Alert.alert(data) + log.info('[handlePaymentRequest] received data via NFC', { data }) - const result = IncomingParser.findAndExtract(data) - if (!result) { - throw new AppError(Err.VALIDATION_ERROR, 'Unsupported or invalid payment request') - } + const result = IncomingParser.findAndExtract(data) - 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) + log.info('[handlePaymentRequest] parsed result', { result }) + if (!result || !result.encoded) { + 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) } } @@ -718,9 +414,8 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { setMintBalanceToSendFrom(selectedBalance) setAmountToPay(formatDisplayAmount(requiredAmount, unit)) - setIsSendTaskSentToQueue(true); - - WalletTask.sendQueue( + + const result = await WalletTask.sendQueueAwaitable( selectedBalance, requiredAmount, unit, @@ -729,27 +424,105 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { undefined, // p2pk not supported in current Cashu PR spec undefined ) + + await handleSendTaskResult(result) + } + const handleSendTaskResult = async function (result: TransactionTaskResult) { + log.debug('[NfcScreen] handleSendTaskResult start', {transactionStatus: result.transaction?.status}) + + 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() + setNfcInfo('Payment failed') + return + } + + // ———————— Missing Token ———————— + if (!encodedTokenToSend) { + setResultModalInfo({ + status: TransactionStatus.ERROR, + title: 'Internal error', + message: 'Failed to generate ecash token', + }); + toggleResultModal() + setNfcInfo('Payment failed') + return + } + + // ———————— Success Path ———————— + setEncodedTokenToSend(encodedTokenToSend) + // Send over NFC to payee's POS or wallet + + setNfcInfo('Prepared ecash token to be sent via NFC.') + + log.trace('[NfcScreen] handleSendTaskResult: Starting NFC write of an ecash token.') + await NfcService.writeNdefMessage(encodedTokenToSend) + + log.trace('[NfcScreen] handleSendTaskResult: Write completed.') + setNfcInfo('Ecash token sent successfully.') + + } catch (err: any) { + log.error('Error in handleSendTaskResult', {error: err, caller: 'NfcScreen.handleSendTaskResult'}) + + setResultModalInfo({ + status: TransactionStatus.ERROR, + title: 'Unexpected error', + message: err.message || 'Something went wrong', + }) + + toggleResultModal() + } + } + + + const handleLightningInvoice = async (encodedInvoice: string) => { if (!isInternetReachable) { - setInfo(translate('commonOfflinePretty')); + 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 totalRequired = invoiceAmount + feeReserve const balances = proofsStore.getMintBalancesWithEnoughBalance(totalRequired, unitRef.current) - + if (balances.length === 0) { setInfo( translate('transferScreen_insufficientFunds', { @@ -759,16 +532,17 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { ) return } - + const selectedBalance = balances[0] setMintBalanceToSendFrom(selectedBalance) - + + setInfo('Creating Lightning payment quote...') const quote = await walletStore.createLightningMeltQuote( selectedBalance.mintUrl, unitRef.current, encodedInvoice ) - + // Update UI setEncodedInvoice(encodedInvoice) setInvoice(decoded) @@ -776,19 +550,78 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { setMeltQuote(quote) setAmountToPay(formatDisplayAmount(quote.amount, unitRef.current)) if (description) setMemo(description) - setIsTransferTaskSentToQueue(true) + + try { + setInfo('Paying Lightning invoice...') + setIsLoading(true) + + const result = await WalletTask.transferQueueAwaitable( + selectedBalance, + quote.amount, + unitRef.current, + quote, + description ?? '', + expiresAt, + encodedInvoice + ) - WalletTask.transferQueue( - selectedBalance, - quote.amount, - unitRef.current, - quote, - description ?? '', - expiresAt, - encodedInvoice, - undefined, - undefined - ) + const { transaction, message, finalFee } = result + + 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() + } finally { + setIsLoading(false) + setInfo('') + } } @@ -859,21 +692,6 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { }) } - 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) @@ -902,7 +720,6 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { setInfo('') setError(undefined) setIsLoading(false) - setIsTransferTaskSentToQueue(false) setIsResultModalVisible(false) setResultModalInfo(undefined) } @@ -969,7 +786,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { - {info && !isProcessing && } + {info && } {error && } (0) + const lastBackgroundTimestampRef = useRef(0) const isInternetReachable = useIsInternetReachable() const groupedMints: MintsByUnit[] = mintsStore.groupedByUnit @@ -312,22 +313,46 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) { }, [isInternetReachable, userSettingsStore.exchangeCurrency]) - useFocusEffect(performChecks) + useFocusEffect(() => { + log.trace('[useFocusEffect] WalletScreen') + performChecks() + }) + + useEffect(() => { - const handleAppStateChange = (nextAppState: AppStateStatus) => { + /* const handleAppStateChange = (nextAppState: AppStateStatus) => { + if (appState.current.match(/inactive|background/) && nextAppState === 'active') { + log.trace('[handleAppStateChange] WalletScreen active again') performChecks() NostrClient.reconnectToRelays().catch(e => false) } appState.current = nextAppState - }; + }*/ - const subscription = AppState.addEventListener('change', handleAppStateChange) + const subscription = AppState.addEventListener('change', (nextState) => { + if (nextState === 'background') { + lastBackgroundTimestampRef.current = Date.now() + return + } + + if (nextState === 'active') { + const timeInBackground = Date.now() - lastBackgroundTimestampRef.current + if (timeInBackground < 1000) { // (usually <1s for NFC) + log.trace('[handleAppStateChange] Ignored too short background event (could be NFC tap)', { timeInBackground }) + return // Ignore – this was an NFC flicker + } + + // Real foreground – run your normal logic (e.g., unlock check, etc.) + log.trace('[handleAppStateChange] WalletScreen active again') + performChecks() + NostrClient.reconnectToRelays().catch(e => false) + } + }) + + return () => subscription.remove() - return () => { - subscription.remove() // Ensure cleanup to avoid multiple listeners - } }, [performChecks]) diff --git a/src/services/cashu/cashuUtils.ts b/src/services/cashu/cashuUtils.ts index 3ab3323f..3dbd6919 100644 --- a/src/services/cashu/cashuUtils.ts +++ b/src/services/cashu/cashuUtils.ts @@ -100,7 +100,6 @@ const extractEncodedCashuToken = function (maybeToken: string): string { const extractEncodedCashuPaymentRequest = function (maybeRequest: string): string { - log.trace('[extractEncodedCashuPaymentRequest] Extract payment request from', {maybeRequest}) let encodedRequest: string | undefined = undefined diff --git a/src/services/incomingParser.ts b/src/services/incomingParser.ts index cefc99fd..a641d558 100644 --- a/src/services/incomingParser.ts +++ b/src/services/incomingParser.ts @@ -110,7 +110,7 @@ const findAndExtract = function ( const maybeCashuPaymentRequest = CashuUtils.findEncodedCashuPaymentRequest(incomingData) if(maybeCashuPaymentRequest) { - log.trace('Got maybeCashuPaymentRequest', maybeCashuPaymentRequest, 'findAndExtract') + log.trace('Got maybeCashuPaymentRequest', {maybeCashuPaymentRequest, caller: 'findAndExtract'}) const encoded = CashuUtils.extractEncodedCashuPaymentRequest(maybeCashuPaymentRequest) // throws @@ -123,7 +123,7 @@ const findAndExtract = function ( const maybeCashuPaymentRequestPayload = CashuUtils.findEncodedCashuPaymentRequestPayload(incomingData) if(maybeCashuPaymentRequestPayload) { - log.trace('Got maybeCashuPaymentRequestPayload', maybeCashuPaymentRequestPayload, 'findAndExtract') + log.trace('Got maybeCashuPaymentRequestPayload', {maybeCashuPaymentRequestPayload, caller: 'findAndExtract'}) const encoded = incomingData // to be aligned with other cases we pass encoded (stringified) JSON payload diff --git a/src/services/nfcService.ts b/src/services/nfcService.ts new file mode 100644 index 00000000..611c7426 --- /dev/null +++ b/src/services/nfcService.ts @@ -0,0 +1,76 @@ +import { Platform } from 'react-native' +import NfcManager, { NfcTech, Ndef, NfcEvents, TagEvent } from 'react-native-nfc-manager' +import { log } from './logService' +import AppError, { Err } from '../utils/AppError' + +const NFC_STALE_DELAY_MS = 120 + +const init = async function () { + const supported = await NfcManager.isSupported() + if (supported) { + await NfcManager.start() + + } + return supported + } + +const isEnabled = function () { + return NfcManager.isEnabled() +} + +const goToNfcSetting = function () { + return NfcManager.goToNfcSetting() +} + +/** + * Ensures a fresh NFC connection by cancelling any previous session + * and adding a tiny delay on Android + */ +const withFreshNfcConnection = async (action: () => Promise): Promise => { + // Cancel any previous session + await NfcManager.cancelTechnologyRequest().catch(() => {}) + log.trace('[withFreshNfcConnection] Pre-cancel completed') + + if (Platform.OS === 'android') { + await new Promise(r => setTimeout(r, NFC_STALE_DELAY_MS)) + } + + try { + return await action() // Only requestTechnology + getTag / writeNdef + } finally { + // This now runs IMMEDIATELY after read/write + await NfcManager.cancelTechnologyRequest().catch(() => {}) + log.trace('[withFreshNfcConnection] Post-cancel completed') + } +} + + +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') + const bytes = Ndef.encodeMessage([Ndef.textRecord('en', text)]) + await NfcManager.ndefHandler.writeNdefMessage(bytes) + }) +} + + + +export const NfcService = { + init, + isEnabled, + goToNfcSetting, + readNdefTag, + writeNdefMessage, +} \ No newline at end of file diff --git a/src/services/syncQueueService.ts b/src/services/syncQueueService.ts index ddaba43c..e2569e17 100644 --- a/src/services/syncQueueService.ts +++ b/src/services/syncQueueService.ts @@ -22,31 +22,47 @@ const getSyncQueue = function () { return _queue as TaskQueue } -const addTask = function (taskId: TaskId, task: Promise | any) { +const addTask = function ( + taskId: TaskId, + task: Promise | (() => Promise) | any +): Promise { const queue = getSyncQueue() log.info(`Adding new task ${taskId} to the queue`) - queue - .addTask( + const promise = queue.addTask( task, - taskId, _handleTaskStatusChange) - .then((result: WalletTaskResult | TransactionTaskResult) => _handleTaskResult(taskId, result)) - + taskId, + _handleTaskStatusChange + ) as Promise + + // Side effect: handle result when it resolves (fire-and-forget) + promise.then((result: any) => { + _handleTaskResult(taskId, result as WalletTaskResult | TransactionTaskResult) + }).catch(() => {}) // Optional: ignore errors here if handled elsewhere + + return promise } - -const addPrioritizedTask = function (taskId: TaskId, task: Promise | any) { +const addPrioritizedTask = function ( + taskId: TaskId, + task: Promise | (() => Promise) | any +): Promise { const queue = getSyncQueue() log.info(`Adding new high priority task ${taskId} to the queue`) - queue - .addPrioritizedTask( + const promise = queue.addPrioritizedTask( task, - taskId, _handleTaskStatusChange) - .then((result: WalletTaskResult | TransactionTaskResult) => _handleTaskResult(taskId, result)) - + taskId, + _handleTaskStatusChange + ) as Promise + + promise.then((result: any) => { + _handleTaskResult(taskId, result as WalletTaskResult | TransactionTaskResult) + }).catch(() => {}) + + return promise } // retrieve result of wallet transaction by listening to ev_taskFuncion event diff --git a/src/services/walletService.ts b/src/services/walletService.ts index 59b104c3..22a78b29 100644 --- a/src/services/walletService.ts +++ b/src/services/walletService.ts @@ -3,6 +3,7 @@ import {getSnapshot} from 'mobx-state-tree' import { GiftWrap, EncryptedDirectMessage } from 'nostr-tools/kinds' import {log} from './logService' import {Proof} from '../models/Proof' +import EventEmitter from '../utils/eventEmitter' import { Transaction, TransactionData, @@ -67,6 +68,11 @@ type WalletTaskService = { isPending: boolean } ) => Promise + syncStateWithAllMintsQueueAwaitable: ( + options: { + isPending: boolean + } + ) => Promise syncStateWithMintQueue: ( options: { proofsToSync: Proof[], @@ -74,6 +80,13 @@ type WalletTaskService = { isPending: boolean } ) => Promise + syncStateWithMintQueueAwaitable: ( + options: { + proofsToSync: Proof[], + mintUrl: string, + isPending: boolean + } + ) => Promise syncStateWithMintTask: ( options: { proofsToSync: Proof[], @@ -99,21 +112,47 @@ type WalletTaskService = { nwcEvent?: NostrEvent, draftTransactionId?: number ) => Promise + transferQueueAwaitable: ( + mintBalanceToTransferFrom: MintBalance, + amountToTransfer: number, + unit: MintUnit, + meltQuote: MeltQuoteResponse, + memo: string, + invoiceExpiry: Date, + encodedInvoice: string, + nwcEvent?: NostrEvent, + draftTransactionId?: number + ) => Promise receiveQueue: ( token: Token, amountToReceive: number, memo: string, encodedToken: string, ) => Promise + receiveQueueAwaitable: ( + token: Token, + amountToReceive: number, + memo: string, + encodedToken: string, + ) => Promise receiveOfflinePrepareQueue: ( token: Token, amountToReceive: number, memo: string, encodedToken: string, ) => Promise + receiveOfflinePrepareQueueAwaitable: ( + token: Token, + amountToReceive: number, + memo: string, + encodedToken: string, + ) => Promise receiveOfflineCompleteQueue: ( transactionId: number ) => Promise + receiveOfflineCompleteQueueAwaitable: ( + transactionId: number + ) => Promise sendQueue: ( mintBalanceToSendFrom: MintBalance, amountToSend: number, @@ -123,6 +162,15 @@ type WalletTaskService = { p2pk?: { pubkey: string; locktime?: number; refundKeys?: Array }, draftTransactionId?: number ) => Promise + sendQueueAwaitable: ( + mintBalanceToSendFrom: MintBalance, + amountToSend: number, + unit: MintUnit, + memo: string, + selectedProofs: Proof[], + p2pk?: { pubkey: string; locktime?: number; refundKeys?: Array }, + draftTransactionId?: number + ) => Promise swapAllQueue: () => Promise topupQueue: ( mintBalanceToTopup: MintBalance, @@ -132,15 +180,32 @@ type WalletTaskService = { contactToSendTo?: Contact, nwcEvent?: NostrEvent ) => Promise + topupQueueAwaitable: ( + mintBalanceToTopup: MintBalance, + amountToTopup: number, + unit: MintUnit, + memo: string, + contactToSendTo?: Contact, + nwcEvent?: NostrEvent + ) => Promise cashuPaymentRequestQueue: ( mintBalanceToReceiveTo: MintBalance, amountToRequest: number, unit: MintUnit, memo: string, ) => Promise + cashuPaymentRequestQueueAwaitable: ( + mintBalanceToReceiveTo: MintBalance, + amountToRequest: number, + unit: MintUnit, + memo: string, + ) => Promise revertQueue: ( transaction: Transaction ) => Promise + revertQueueAwaitable: ( + transaction: Transaction + ) => Promise testQueue: () => Promise recoverMintQuote: (params: { mintUrl: string, @@ -234,6 +299,84 @@ const transferQueue = async function ( } +const transferQueueAwaitable = function ( + mintBalanceToTransferFrom: MintBalance, + amountToTransfer: number, + unit: MintUnit, + meltQuote: MeltQuoteResponse, + memo: string, + invoiceExpiry: Date, + encodedInvoice: string, + nwcEvent?: NostrEvent, + draftTransactionId?: number +): Promise { + const now = Date.now() + const taskId = `transferTask-${now}` + + return new Promise((resolve, reject) => { + let resolved = false + + const resolveOnce = (result: TransactionTaskResult) => { + if (resolved) return + resolved = true + resolve(result) + } + + const rejectOnce = (error: any) => { + if (resolved) return + resolved = true + reject(error) + } + + const eventName = `ev_transferTask_result` + const handler = (result: TransactionTaskResult) => { + EventEmitter.off(eventName, handler) + resolveOnce(result) + } + + EventEmitter.on(eventName, handler) + + SyncQueue.addPrioritizedTask( + taskId, + async () => { + try { + return await transferTask( + mintBalanceToTransferFrom, + amountToTransfer, + unit, + meltQuote, + memo, + invoiceExpiry, + encodedInvoice, + nwcEvent, + draftTransactionId + ) + } catch (error) { + rejectOnce(error) + throw error + } + } + ) + .then((result) => { + if (!resolved) { + resolveOnce(result) + } + }) + .catch((error) => { + rejectOnce(error) + }) + + // Safety timeout + setTimeout(() => { + if (!resolved) { + EventEmitter.off(eventName, handler) + rejectOnce(new AppError(Err.TIMEOUT_ERROR, 'transferQueue timed out')) + } + }, 15000) + }) +} + + const receiveQueue = async function ( token: Token, amountToReceive: number, @@ -271,6 +414,79 @@ const receiveQueue = async function ( return } + +const receiveQueueAwaitable = function ( + token: Token, + amountToReceive: number, + memo: string, + encodedToken: string, +): Promise { + const now = Date.now() + const proofsCount = token.proofs.length + + const taskId = proofsCount > MAX_SWAP_INPUT_SIZE + ? `receiveBatchTask-${now}` + : `receiveTask-${now}` + + const eventName = proofsCount > MAX_SWAP_INPUT_SIZE + ? `ev_receiveBatchTask_result` + : `ev_receiveTask_result` + + return new Promise((resolve, reject) => { + let resolved = false + + const resolveOnce = (result: TransactionTaskResult) => { + if (resolved) return + resolved = true + resolve(result) + } + + const rejectOnce = (error: any) => { + if (resolved) return + resolved = true + reject(error) + } + + const handler = (result: TransactionTaskResult) => { + EventEmitter.off(eventName, handler) + resolveOnce(result) + } + + EventEmitter.on(eventName, handler) + + const taskPromise = proofsCount > MAX_SWAP_INPUT_SIZE + ? receiveBatchTask(token, amountToReceive, memo, encodedToken) + : receiveTask(token, amountToReceive, memo, encodedToken) + + SyncQueue.addPrioritizedTask( + taskId, + async () => { + try { + return await taskPromise + } catch (error) { + rejectOnce(error) + throw error + } + } + ) + .then((result) => { + if (!resolved) { + resolveOnce(result) + } + }) + .catch((error) => { + rejectOnce(error) + }) + + setTimeout(() => { + if (!resolved) { + EventEmitter.off(eventName, handler) + rejectOnce(new AppError(Err.TIMEOUT_ERROR, 'receiveQueue timed out')) + } + }, 15000) + }) +} + /* * Receive big tokens in batches to keep mint load reasonable. * @@ -364,6 +580,74 @@ const receiveOfflinePrepareQueue = async function ( } +const receiveOfflinePrepareQueueAwaitable = function ( + token: Token, + amountToReceive: number, + memo: string, + encodedToken: string, +): Promise { + const now = Date.now() + const taskId = `receiveOfflinePrepareTask-${now}` + + return new Promise((resolve, reject) => { + let resolved = false + + const resolveOnce = (result: TransactionTaskResult) => { + if (resolved) return + resolved = true + resolve(result) + } + + const rejectOnce = (error: any) => { + if (resolved) return + resolved = true + reject(error) + } + + const eventName = `ev_receiveOfflinePrepareTask_result` + const handler = (result: TransactionTaskResult) => { + EventEmitter.off(eventName, handler) + resolveOnce(result) + } + + EventEmitter.on(eventName, handler) + + SyncQueue.addTask( + taskId, + async () => { + try { + return await receiveOfflinePrepareTask( + token, + amountToReceive, + memo, + encodedToken + ) + } catch (error) { + rejectOnce(error) + throw error + } + } + ) + .then((result) => { + if (!resolved) { + resolveOnce(result) + } + }) + .catch((error) => { + rejectOnce(error) + }) + + // Timeout after 20s (offline tasks can be slower) + setTimeout(() => { + if (!resolved) { + EventEmitter.off(eventName, handler) + rejectOnce(new AppError(Err.TIMEOUT_ERROR, 'Offline receive prepare timed out')) + } + }, 20000) + }) +} + + const receiveOfflineCompleteQueue = async function ( transactionId: number ): Promise { @@ -378,6 +662,65 @@ const receiveOfflineCompleteQueue = async function ( } +const receiveOfflineCompleteQueueAwaitable = function ( + transactionId: number +): Promise { + const now = Date.now() + const taskId = `receiveOfflineCompleteTask-${now}` + + return new Promise((resolve, reject) => { + let resolved = false + + const resolveOnce = (result: TransactionTaskResult) => { + if (resolved) return + resolved = true + resolve(result) + } + + const rejectOnce = (error: any) => { + if (resolved) return + resolved = true + reject(error) + } + + const eventName = `ev_receiveOfflineCompleteTask_result` + const handler = (result: TransactionTaskResult) => { + EventEmitter.off(eventName, handler) + resolveOnce(result) + } + + EventEmitter.on(eventName, handler) + + SyncQueue.addTask( + taskId, + async () => { + try { + return await receiveOfflineCompleteTask(transactionId) + } catch (error) { + rejectOnce(error) + throw error + } + } + ) + .then((result) => { + if (!resolved) { + resolveOnce(result) + } + }) + .catch((error) => { + rejectOnce(error) + }) + + setTimeout(() => { + if (!resolved) { + EventEmitter.off(eventName, handler) + rejectOnce(new AppError(Err.TIMEOUT_ERROR, 'Offline receive complete timed out')) + } + }, 20000) + }) +} + + const sendQueue = async function ( mintBalanceToSendFrom: MintBalance, amountToSend: number, @@ -404,6 +747,81 @@ const sendQueue = async function ( } +const sendQueueAwaitable = function ( + mintBalanceToSendFrom: MintBalance, + amountToSend: number, + unit: MintUnit, + memo: string, + selectedProofs: Proof[], + p2pk?: { pubkey: string; locktime?: number; refundKeys?: Array }, + draftTransactionId?: number +): Promise { + const now = Date.now() + const taskId = `sendTask-${now}` + + return new Promise((resolve, reject) => { + let resolved = false + + const resolveOnce = (result: TransactionTaskResult) => { + if (resolved) return + resolved = true + resolve(result) + } + + const rejectOnce = (error: any) => { + if (resolved) return + resolved = true + reject(error) + } + + const eventName = `ev_sendTask_result` + const handler = (result: TransactionTaskResult) => { + EventEmitter.off(eventName, handler) + resolveOnce(result) + } + + EventEmitter.on(eventName, handler) + + // Explicitly type the generic when calling + SyncQueue.addPrioritizedTask( + taskId, + async () => { + try { + return await sendTask( + mintBalanceToSendFrom, + amountToSend, + unit, + memo, + selectedProofs, + p2pk, + draftTransactionId + ) + } catch (error) { + rejectOnce(error) + throw error + } + } + ) + .then((result) => { // Now result is correctly typed as TransactionTaskResult + if (!resolved) { + resolveOnce(result) + } + }) + .catch((error) => { + rejectOnce(error) + }) + + // Timeout safety + setTimeout(() => { + if (!resolved) { + EventEmitter.off(eventName, handler) + rejectOnce(new AppError(Err.TIMEOUT_ERROR, 'sendQueue timed out')) + } + }, 10000) + }) +} + + /* * swapAllTask sends all proofs to pending and swaps them with the mint for standard amount preference * This decreases the total number of proofs held by the wallet. Used to optimize exported backup size. @@ -556,6 +974,73 @@ const topupQueue = async function ( } +const topupQueueAwaitable = function ( + mintBalanceToTopup: MintBalance, + amountToTopup: number, + unit: MintUnit, + memo: string, + contactToSendTo?: Contact, + nwcEvent?: NostrEvent +): Promise { + const now = Date.now() + const taskId = `topupTask-${now}` + + return new Promise((resolve, reject) => { + let resolved = false + + const resolveOnce = (result: TransactionTaskResult) => { + if (resolved) return + resolved = true + resolve(result) + } + + const rejectOnce = (error: any) => { + if (resolved) return + resolved = true + reject(error) + } + + const eventName = `ev_topupTask_result` + const handler = (result: TransactionTaskResult) => { + EventEmitter.off(eventName, handler) + resolveOnce(result) + } + + EventEmitter.on(eventName, handler) + + SyncQueue.addPrioritizedTask( + taskId, + async () => { + try { + return await topupTask( + mintBalanceToTopup, + amountToTopup, + unit, + memo, + contactToSendTo, + nwcEvent + ) + } catch (error) { + rejectOnce(error) + throw error + } + } + ) + .then((result) => { + if (!resolved) resolveOnce(result) + }) + .catch(rejectOnce) + + setTimeout(() => { + if (!resolved) { + EventEmitter.off(eventName, handler) + rejectOnce(new AppError(Err.TIMEOUT_ERROR, 'Topup task timed out')) + } + }, 15000) + }) +} + + const cashuPaymentRequestQueue = async function ( mintBalanceToReceiveTo: MintBalance, amountToRequest: number, @@ -576,6 +1061,69 @@ const cashuPaymentRequestQueue = async function ( } +const cashuPaymentRequestQueueAwaitable = function ( + mintBalanceToReceiveTo: MintBalance, + amountToRequest: number, + unit: MintUnit, + memo: string, +): Promise { + const now = Date.now() + const taskId = `cashuPaymentRequestTask-${now}` + + return new Promise((resolve, reject) => { + let resolved = false + + const resolveOnce = (result: TransactionTaskResult) => { + if (resolved) return + resolved = true + resolve(result) + } + + const rejectOnce = (error: any) => { + if (resolved) return + resolved = true + reject(error) + } + + const eventName = `ev_cashuPaymentRequestTask_result` + const handler = (result: TransactionTaskResult) => { + EventEmitter.off(eventName, handler) + resolveOnce(result) + } + + EventEmitter.on(eventName, handler) + + SyncQueue.addPrioritizedTask( + taskId, + async () => { + try { + return await cashuPaymentRequestTask( + mintBalanceToReceiveTo, + amountToRequest, + unit, + memo + ) + } catch (error) { + rejectOnce(error) + throw error + } + } + ) + .then((result) => { + if (!resolved) resolveOnce(result) + }) + .catch(rejectOnce) + + setTimeout(() => { + if (!resolved) { + EventEmitter.off(eventName, handler) + rejectOnce(new AppError(Err.TIMEOUT_ERROR, 'Cashu payment request timed out')) + } + }, 10000) + }) +} + + const revertQueue = async function ( transaction: Transaction @@ -589,6 +1137,61 @@ const revertQueue = async function ( } +const revertQueueAwaitable = function ( + transaction: Transaction +): Promise { + const now = Date.now() + const taskId = `revertTask-${now}` + + return new Promise((resolve, reject) => { + let resolved = false + + const resolveOnce = (result: TransactionTaskResult) => { + if (resolved) return + resolved = true + resolve(result) + } + + const rejectOnce = (error: any) => { + if (resolved) return + resolved = true + reject(error) + } + + const eventName = `ev_revertTask_result` + const handler = (result: TransactionTaskResult) => { + EventEmitter.off(eventName, handler) + resolveOnce(result) + } + + EventEmitter.on(eventName, handler) + + SyncQueue.addPrioritizedTask( + taskId, + async () => { + try { + return await revertTask(transaction) + } catch (error) { + rejectOnce(error) + throw error + } + } + ) + .then((result) => { + if (!resolved) resolveOnce(result) + }) + .catch(rejectOnce) + + setTimeout(() => { + if (!resolved) { + EventEmitter.off(eventName, handler) + rejectOnce(new AppError(Err.TIMEOUT_ERROR, 'Revert task timed out')) + } + }, 15000) + }) +} + + const syncStateWithAllMintsQueue = async function (options: { isPending: boolean @@ -603,6 +1206,63 @@ const syncStateWithAllMintsQueue = async function (options: { +const syncStateWithAllMintsQueueAwaitable = function ( + options: { isPending: boolean } +): Promise { + const { isPending } = options + const now = Date.now() + const taskId = `syncSpendableStateTask-${now}` + + return new Promise((resolve, reject) => { + let resolved = false + + const resolveOnce = (result: SyncStateTaskResult) => { + if (resolved) return + resolved = true + resolve(result) + } + + const rejectOnce = (error: any) => { + if (resolved) return + resolved = true + reject(error) + } + + const eventName = `ev_syncSpendableStateTask_result` + const handler = (result: SyncStateTaskResult) => { + EventEmitter.off(eventName, handler) + resolveOnce(result) + } + + EventEmitter.on(eventName, handler) + + SyncQueue.addPrioritizedTask( + taskId, + async () => { + try { + return await syncStateWithAllMintsTask({ isPending }) + } catch (error) { + rejectOnce(error) + throw error + } + } + ) + .then((result) => { + if (!resolved) resolveOnce(result) + }) + .catch(rejectOnce) + + setTimeout(() => { + if (!resolved) { + EventEmitter.off(eventName, handler) + rejectOnce(new AppError(Err.TIMEOUT_ERROR, 'Sync all mints state timed out')) + } + }, 30000) // Longer timeout — syncing all mints can take time + }) +} + + + const syncStateWithAllMintsTask = async function (options: { isPending: boolean }): Promise { @@ -714,7 +1374,76 @@ const syncStateWithMintQueue = async function ( ) } - + +const syncStateWithMintQueueAwaitable = function ( + options: { + proofsToSync: Proof[] + mintUrl: string + isPending: boolean + } +): Promise { + const { mintUrl, isPending, proofsToSync } = options + log.trace('[syncStateWithMintQueueAwaitable] start', { + mintUrl, + isPending, + proofsToSyncCount: proofsToSync.length + }) + + const now = Date.now() + const taskId = `syncStateWithMintTask-${now}` + + return new Promise((resolve, reject) => { + let resolved = false + + const resolveOnce = (result: SyncStateTaskResult) => { + if (resolved) return + resolved = true + resolve(result) + } + + const rejectOnce = (error: any) => { + if (resolved) return + resolved = true + reject(error) + } + + const eventName = `ev_syncStateWithMintTask_result` + const handler = (result: SyncStateTaskResult) => { + EventEmitter.off(eventName, handler) + resolveOnce(result) + } + + EventEmitter.on(eventName, handler) + + SyncQueue.addTask( + taskId, + async () => { + try { + return await syncStateWithMintTask({ + proofsToSync, + mintUrl, + isPending + }) + } catch (error) { + rejectOnce(error) + throw error + } + } + ) + .then((result) => { + if (!resolved) resolveOnce(result) + }) + .catch(rejectOnce) + + setTimeout(() => { + if (!resolved) { + EventEmitter.off(eventName, handler) + rejectOnce(new AppError(Err.TIMEOUT_ERROR, 'Sync mint state timed out')) + } + }, 20000) + }) +} + /** * Sync wallet proof state with mint reality (SPENT / PENDING / UNSPENT) @@ -2499,22 +3228,32 @@ const testQueue = async () => { export const WalletTask: WalletTaskService = { syncStateWithAllMintsQueue, + syncStateWithAllMintsQueueAwaitable, syncStateWithMintQueue, + syncStateWithMintQueueAwaitable, syncStateWithMintTask, handleInFlightQueue, handlePendingQueue, handleClaimQueue, handleNwcRequestQueue, receiveEventsFromRelaysQueue, - receiveQueue, + receiveQueue, + receiveQueueAwaitable, receiveOfflinePrepareQueue, - receiveOfflineCompleteQueue, + receiveOfflinePrepareQueueAwaitable, + receiveOfflineCompleteQueue, + receiveOfflineCompleteQueueAwaitable, sendQueue, + sendQueueAwaitable, swapAllQueue, - transferQueue, + transferQueue, + transferQueueAwaitable, topupQueue, + topupQueueAwaitable, cashuPaymentRequestQueue, + cashuPaymentRequestQueueAwaitable, revertQueue, + revertQueueAwaitable, testQueue, recoverMintQuote, recoverMeltQuoteChange, diff --git a/yarn.lock b/yarn.lock index 9ef792dd..b17eb0e1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1894,15 +1894,15 @@ __metadata: languageName: node linkType: hard -"@cashu/cashu-ts@minibits-cash/cashu-ts#c85d4638c007ac8126bc83a7287a6d848fcc8153": +"@cashu/cashu-ts@minibits-cash/cashu-ts#e97b1a4ee9a7ddbb1468302c6996a9d6d3efcb6f": version: 2.4.0 - resolution: "@cashu/cashu-ts@https://github.com/minibits-cash/cashu-ts.git#commit=c85d4638c007ac8126bc83a7287a6d848fcc8153" + resolution: "@cashu/cashu-ts@https://github.com/minibits-cash/cashu-ts.git#commit=e97b1a4ee9a7ddbb1468302c6996a9d6d3efcb6f" dependencies: "@cashu/crypto": ^0.3.4 "@noble/curves": ^1.3.0 "@noble/hashes": ^1.3.3 buffer: ^6.0.3 - checksum: 9d60865e47509428ee23ed5fec87282ff1fdad1748529010461e9c239d04616b8dde1ed6afbe51bf1c693aeadeaa4e02ede792d3ee2962e99913c1253c061568 + checksum: 68d46ceb9c60e9cef305aad86534c08b23de434de1efcd217d00c73bdf84704801c3cd700901903256de5a408caf029cea7886e9a303dd423027f08184bc1cb5 languageName: node linkType: hard @@ -13535,7 +13535,7 @@ __metadata: "@babel/plugin-proposal-export-namespace-from": ^7.18.9 "@babel/preset-env": ^7.25.3 "@babel/runtime": ^7.25.0 - "@cashu/cashu-ts": "minibits-cash/cashu-ts#c85d4638c007ac8126bc83a7287a6d848fcc8153" + "@cashu/cashu-ts": "minibits-cash/cashu-ts#e97b1a4ee9a7ddbb1468302c6996a9d6d3efcb6f" "@fortawesome/fontawesome-svg-core": ^7.1.0 "@fortawesome/free-brands-svg-icons": ^7.1.0 "@fortawesome/free-regular-svg-icons": ^7.1.0