Files
minibits_wallet/src/services/walletService.ts
T

3260 lines
107 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {addSeconds, isBefore} from 'date-fns'
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,
TransactionStatus,
TransactionType
} from '../models/Transaction'
import {rootStoreInstance} from '../models'
import {CashuProof, CashuUtils} from './cashu/cashuUtils'
import {LightningUtils} from './lightning/lightningUtils'
import AppError, {Err} from '../utils/AppError'
import {MintBalance, MintStatus} from '../models/Mint'
import {MeltQuoteResponse, MeltQuoteState, MintQuoteState, PaymentRequestPayload, Token, getDecodedToken, getEncodedToken} from '@cashu/cashu-ts'
import {Mint} from '../models/Mint'
import {pollerExists, stopPolling} from '../utils/poller'
import { NostrClient, NostrEvent, NostrProfile } from './nostrService'
import { MINIBITS_NIP05_DOMAIN, MINIBIT_SERVER_NOSTR_PUBKEY } from '@env'
import { IncomingDataType, IncomingParser } from './incomingParser'
import { Contact } from '../models/Contact'
import { SyncQueue } from './syncQueueService'
import {
receiveTask,
receiveOfflinePrepareTask,
receiveOfflineCompleteTask,
receiveByCashuPaymentRequestTask
} from './wallet/receiveTask'
import { sendTask } from './wallet/sendTask'
import { topupTask } from './wallet/topupTask'
import { transferTask } from './wallet/transferTask'
import { revertTask } from './wallet/revertTask'
import { WalletUtils } from './wallet/utils'
import { NotificationService } from './notificationService'
import { CurrencyCode, MintUnit, MintUnitCurrencyPairs, MintUnits, formatCurrency, getCurrency } from './wallet/currency'
import { MinibitsClient } from './minibitsService'
import { UnsignedEvent } from 'nostr-tools'
import { Platform } from 'react-native'
import { cashuPaymentRequestTask } from './wallet/cashuPaymentRequestTask'
import { decodePaymentRequest, sumBlindSignatures } from '@cashu/cashu-ts/src/utils'
/**
* The default number of proofs per denomination to keep in a wallet.
*/
export const DEFAULT_DENOMINATION_TARGET = 2
export const MAX_SWAP_INPUT_SIZE = 100
export const MAX_SYNC_INPUT_SIZE = 200 // 1000 hard mint limit
export const TEST_TASK = 'testTask'
export const SWAP_ALL_TASK = 'swapAllTask'
export const SYNC_STATE_WITH_ALL_MINTS_TASK = 'syncStateWithAllMintsTask'
export const SYNC_STATE_WITH_MINT_TASK = 'syncStateWithMintTask'
export const HANDLE_NWC_REQUEST_TASK = 'handleNwcRequestTask'
export const HANDLE_CLAIM_TASK = 'handleClaimTask'
export const HANDLE_PENDING_TOPUP_TASK = 'handlePendingTopupTask'
export const HANDLE_INFLIGHT_BY_MINT_TASK = 'handleInFlightByMintTask'
export const HANDLE_RECEIVED_EVENT_TASK = 'handleReceivedEventTask'
type WalletTaskService = {
syncStateWithAllMintsQueue: (
options: {
isPending: boolean
}
) => Promise<void>
syncStateWithAllMintsQueueAwaitable: (
options: {
isPending: boolean
}
) => Promise<SyncStateTaskResult>
syncStateWithMintQueue: (
options: {
proofsToSync: Proof[],
mintUrl: string,
isPending: boolean
}
) => Promise<void>
syncStateWithMintQueueAwaitable: (
options: {
proofsToSync: Proof[],
mintUrl: string,
isPending: boolean
}
) => Promise<SyncStateTaskResult>
syncStateWithMintTask: (
options: {
proofsToSync: Proof[],
mintUrl: string,
isPending: boolean
}
) => Promise<SyncStateTaskResult | void>
handleInFlightQueue: () => Promise<void>
handlePendingQueue: () => Promise<void>
handleClaimQueue: () => Promise<void>
handleNwcRequestQueue: (params: {
requestEvent: NostrEvent
}) => Promise<void>
receiveEventsFromRelaysQueue: () => Promise<void>
transferQueue: (
mintBalanceToTransferFrom: MintBalance,
amountToTransfer: number,
unit: MintUnit,
meltQuote: MeltQuoteResponse,
memo: string,
invoiceExpiry: Date,
encodedInvoice: string,
nwcEvent?: NostrEvent,
draftTransactionId?: number
) => Promise<void>
transferQueueAwaitable: (
mintBalanceToTransferFrom: MintBalance,
amountToTransfer: number,
unit: MintUnit,
meltQuote: MeltQuoteResponse,
memo: string,
invoiceExpiry: Date,
encodedInvoice: string,
nwcEvent?: NostrEvent,
draftTransactionId?: number
) => Promise<TransactionTaskResult>
receiveQueue: (
token: Token,
amountToReceive: number,
memo: string,
encodedToken: string,
) => Promise<void>
receiveQueueAwaitable: (
token: Token,
amountToReceive: number,
memo: string,
encodedToken: string,
) => Promise<TransactionTaskResult>
receiveOfflinePrepareQueue: (
token: Token,
amountToReceive: number,
memo: string,
encodedToken: string,
) => Promise<void>
receiveOfflinePrepareQueueAwaitable: (
token: Token,
amountToReceive: number,
memo: string,
encodedToken: string,
) => Promise<TransactionTaskResult>
receiveOfflineCompleteQueue: (
transactionId: number
) => Promise<void>
receiveOfflineCompleteQueueAwaitable: (
transactionId: number
) => Promise<TransactionTaskResult>
sendQueue: (
mintBalanceToSendFrom: MintBalance,
amountToSend: number,
unit: MintUnit,
memo: string,
selectedProofs: Proof[],
p2pk?: { pubkey: string; locktime?: number; refundKeys?: Array<string> },
draftTransactionId?: number
) => Promise<void>
sendQueueAwaitable: (
mintBalanceToSendFrom: MintBalance,
amountToSend: number,
unit: MintUnit,
memo: string,
selectedProofs: Proof[],
p2pk?: { pubkey: string; locktime?: number; refundKeys?: Array<string> },
draftTransactionId?: number
) => Promise<TransactionTaskResult>
swapAllQueue: () => Promise<void>
topupQueue: (
mintBalanceToTopup: MintBalance,
amountToTopup: number,
unit: MintUnit,
memo: string,
contactToSendTo?: Contact,
nwcEvent?: NostrEvent
) => Promise<void>
topupQueueAwaitable: (
mintBalanceToTopup: MintBalance,
amountToTopup: number,
unit: MintUnit,
memo: string,
contactToSendTo?: Contact,
nwcEvent?: NostrEvent
) => Promise<TransactionTaskResult>
cashuPaymentRequestQueue: (
mintBalanceToReceiveTo: MintBalance,
amountToRequest: number,
unit: MintUnit,
memo: string,
) => Promise<void>
cashuPaymentRequestQueueAwaitable: (
mintBalanceToReceiveTo: MintBalance,
amountToRequest: number,
unit: MintUnit,
memo: string,
) => Promise<TransactionTaskResult>
revertQueue: (
transaction: Transaction
) => Promise<void>
revertQueueAwaitable: (
transaction: Transaction
) => Promise<TransactionTaskResult>
testQueue: () => Promise<void>
recoverMintQuote: (params: {
mintUrl: string,
mintQuote: string
}) => Promise<{recoveredAmount: number}>
recoverMeltQuoteChange: (params: {
mintUrl: string,
meltQuote: string | MeltQuoteResponse
}) => Promise<{recoveredAmount: number}>
}
export interface WalletTaskResult {
taskFunction: string
message: string
mintUrl?: string
error?: AppError
[key: string]: any
}
export interface TransactionTaskResult extends WalletTaskResult {
mintUrl: string,
transaction?: Transaction
swapFeePaid?: number
lightningFeePaid?: number
meltFeePaid?: number
meltQuote?: MeltQuoteResponse
nwcEvent?: NostrEvent
}
export interface TransactionStateUpdate {
tId: number
amount?: number
spentByMintAmount?: number
pendingByMintAmount?: number
movedToSpendableAmount?: number,
meltQuoteToRecover?: string,
recoveredChangeAmount?: number,
message?: string
updatedStatus: TransactionStatus,
}
export interface SyncStateTaskResult extends WalletTaskResult {
transactionStateUpdates: TransactionStateUpdate[],
completedTransactionIds: number[],
errorTransactionIds: number[],
revertedTransactionIds: number[]
}
const {
userSettingsStore,
walletProfileStore,
mintsStore,
proofsStore,
transactionsStore,
contactsStore,
relaysStore,
walletStore,
nwcStore,
} = rootStoreInstance
const transferQueue = async function (
mintBalanceToTransferFrom: MintBalance,
amountToTransfer: number,
unit: MintUnit,
meltQuote: MeltQuoteResponse,
memo: string,
invoiceExpiry: Date,
encodedInvoice: string,
nwcEvent?: NostrEvent,
draftTransactionId?: number
): Promise<void> {
const now = new Date().getTime()
SyncQueue.addPrioritizedTask(
`transferTask-${now}`,
async () => await transferTask(
mintBalanceToTransferFrom,
amountToTransfer,
unit,
meltQuote,
memo,
invoiceExpiry,
encodedInvoice,
nwcEvent,
draftTransactionId
)
)
return
}
const transferQueueAwaitable = function (
mintBalanceToTransferFrom: MintBalance,
amountToTransfer: number,
unit: MintUnit,
meltQuote: MeltQuoteResponse,
memo: string,
invoiceExpiry: Date,
encodedInvoice: string,
nwcEvent?: NostrEvent,
draftTransactionId?: number
): Promise<TransactionTaskResult> {
const now = Date.now()
const taskId = `transferTask-${now}`
return new Promise<TransactionTaskResult>((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<TransactionTaskResult>(
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,
memo: string,
encodedToken: string,
): Promise<void> {
const now = new Date().getTime()
const proofsCount = token.proofs.length
if(proofsCount > MAX_SWAP_INPUT_SIZE) {
SyncQueue.addPrioritizedTask(
`receiveBatchTask-${now}`,
async () => await receiveBatchTask(
token,
amountToReceive,
memo,
encodedToken,
)
)
} else {
SyncQueue.addPrioritizedTask(
`receiveTask-${now}`,
async () => await receiveTask(
token,
amountToReceive,
memo,
encodedToken,
)
)
}
return
}
const receiveQueueAwaitable = function (
token: Token,
amountToReceive: number,
memo: string,
encodedToken: string,
): Promise<TransactionTaskResult> {
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<TransactionTaskResult>((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<TransactionTaskResult>(
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.
*
*/
const receiveBatchTask = async function (
token: Token,
amountToReceive: number,
memo: string,
encodedToken: string,
) : Promise<WalletTaskResult> {
const maxBatchSize = MAX_SWAP_INPUT_SIZE
const mintUrl = token.mint
const proofsToReceive = token.proofs
const unit = token.unit
let receivedProofsCount = 0
if (proofsToReceive.length > maxBatchSize) {
let index =0
for (let i = 0; i < proofsToReceive.length; i += maxBatchSize) {
index++
const batch = proofsToReceive.slice(i, i + maxBatchSize)
const batchAmount = CashuUtils.getProofsAmount(batch)
const batchToken: Token = {
mint: mintUrl,
proofs: batch,
memo: `${memo} #${index}`,
unit
}
const batchEncodedToken = getEncodedToken(batchToken)
const result = await receiveTask(
batchToken,
batchAmount,
`${memo} #${index}`,
batchEncodedToken,
)
if(result.receivedProofsCount) {
receivedProofsCount += result.receivedProofsCount
}
}
} else {
// If the length is less than or equal to 100, do normal receive
const result = await receiveTask(
token,
amountToReceive,
memo,
encodedToken,
)
if(result.receivedProofsCount) {
receivedProofsCount += result.receivedProofsCount
}
}
return {
taskFunction: 'receiveBatchTask',
mintUrl,
message: 'receiveBatchTask completed. ',
receivedProofsCount
}
}
const receiveOfflinePrepareQueue = async function (
token: Token,
amountToReceive: number,
memo: string,
encodedToken: string,
): Promise<void> {
const now = new Date().getTime()
SyncQueue.addTask(
`receiveOfflinePrepareTask-${now}`,
async () => await receiveOfflinePrepareTask(
token,
amountToReceive,
memo,
encodedToken,
)
)
return
}
const receiveOfflinePrepareQueueAwaitable = function (
token: Token,
amountToReceive: number,
memo: string,
encodedToken: string,
): Promise<TransactionTaskResult> {
const now = Date.now()
const taskId = `receiveOfflinePrepareTask-${now}`
return new Promise<TransactionTaskResult>((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<TransactionTaskResult>(
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<void> {
const now = new Date().getTime()
SyncQueue.addTask(
`receiveOfflineCompleteTask-${now}`,
async () => await receiveOfflineCompleteTask(
transactionId
)
)
return
}
const receiveOfflineCompleteQueueAwaitable = function (
transactionId: number
): Promise<TransactionTaskResult> {
const now = Date.now()
const taskId = `receiveOfflineCompleteTask-${now}`
return new Promise<TransactionTaskResult>((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<TransactionTaskResult>(
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,
unit: MintUnit,
memo: string,
selectedProofs: Proof[],
p2pk?: { pubkey: string; locktime?: number; refundKeys?: Array<string> },
draftTransactionId?: number
): Promise<void> {
const now = new Date().getTime()
SyncQueue.addPrioritizedTask(
`sendTask-${now}`,
async () => await sendTask(
mintBalanceToSendFrom,
amountToSend,
unit,
memo,
selectedProofs,
p2pk,
draftTransactionId
)
)
return
}
const sendQueueAwaitable = function (
mintBalanceToSendFrom: MintBalance,
amountToSend: number,
unit: MintUnit,
memo: string,
selectedProofs: Proof[],
p2pk?: { pubkey: string; locktime?: number; refundKeys?: Array<string> },
draftTransactionId?: number
): Promise<TransactionTaskResult> {
const now = Date.now()
const taskId = `sendTask-${now}`
return new Promise<TransactionTaskResult>((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<TransactionTaskResult>(
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.
* Heavy, needs to run using the foreground service. May freeze the wallet.
*/
const swapAllQueue = async function (): Promise<void> {
const now = new Date().getTime()
return SyncQueue.addPrioritizedTask(
`swapAllTask-${now}`,
async () => await swapAllTask()
)
}
const swapAllTask = async function (): Promise<WalletTaskResult> {
log.trace('[swapAllTask] start')
if (mintsStore.mintCount === 0) {
return {
taskFunction: SWAP_ALL_TASK,
message: 'No mints to swap with.'
}
}
let initialProofsCount = 0
let finalProofsCount = 0
const errors: string[] = []
// Do not create a pending transaction above mint's spent sync (check) limit as it becomes stuck pending
// As well keep tokens reasonably sized so that a device can keep related transaction in the state / load it from DB
const maxBatchSize = 2
for (const mint of mintsStore.allMints) {
for (const unit of mint.units) {
const proofsToOptimize = proofsStore.getByMint(mint.mintUrl, { isPending: false, unit, ascending: true })
if(proofsToOptimize.length === 0) {
continue
}
initialProofsCount += proofsToOptimize.length
const mintBalance = mint.balances
if (proofsToOptimize.length > maxBatchSize) {
let index = 0
for (let i = 0; i < proofsToOptimize.length; i += maxBatchSize) {
index++
const batch = proofsToOptimize.slice(i, i + maxBatchSize)
const batchAmount = CashuUtils.getProofsAmount(batch)
const sendResult = await sendTask(
mintBalance!,
batchAmount,
unit,
`Optimize ecash #${index}`,
batch // forces offline mode
)
const encodedTokenToReceive: string = sendResult.encodedTokenToSend
const tokenToReceive = getDecodedToken(encodedTokenToReceive)
const tokenAmount = CashuUtils.getProofsAmount(tokenToReceive.proofs)
const receiveResult = await receiveBatchTask(
tokenToReceive,
tokenAmount,
tokenToReceive.memo as string,
encodedTokenToReceive
)
// sync pending proofs state (from pending to spent) from current round send so that there is not big pile of pending at the end
// that makes wallet freeze on next startup
await syncStateWithMintTask({proofsToSync: batch, mintUrl: mint.mintUrl, isPending: true})
if(receiveResult.receivedProofsCount && receiveResult.receivedProofsCount > 0) {
finalProofsCount += receiveResult.receivedProofsCount
}
if(receiveResult.error) {
errors.push(receiveResult.error.message)
}
}
} else {
// If the length is less than or equal to limit, run with all proofs.
const proofsAmount = CashuUtils.getProofsAmount(proofsToOptimize)
const sendResult = await sendTask(
mintBalance!,
proofsAmount,
unit,
`Optimize ecash`,
proofsToOptimize // forces offline mode
)
const encodedTokenToReceive: string = sendResult.encodedTokenToSend
const tokenToReceive = getDecodedToken(encodedTokenToReceive)
const tokenAmount = CashuUtils.getProofsAmount(tokenToReceive.proofs)
const receiveResult = await receiveBatchTask(
tokenToReceive,
tokenAmount,
tokenToReceive.memo as string,
encodedTokenToReceive
)
if(receiveResult.receivedProofsCount && receiveResult.receivedProofsCount > 0) {
finalProofsCount += receiveResult.receivedProofsCount
}
if(receiveResult.error) {
errors.push(receiveResult.error.message)
}
}
}
}
return {
taskFunction: SWAP_ALL_TASK,
message: `Proofs optimization completed with ${errors.length} errors. Proofs number went from ${initialProofsCount} to ${finalProofsCount}`,
initialProofsCount,
finalProofsCount,
errors,
}
}
const topupQueue = async function (
mintBalanceToTopup: MintBalance,
amountToTopup: number,
unit: MintUnit,
memo: string,
contactToSendTo?: Contact,
nwcEvent?: NostrEvent
): Promise<void> {
const now = new Date().getTime()
SyncQueue.addPrioritizedTask(
`topupTask-${now}`,
async () => await topupTask(
mintBalanceToTopup,
amountToTopup,
unit,
memo,
contactToSendTo,
nwcEvent
)
)
return
}
const topupQueueAwaitable = function (
mintBalanceToTopup: MintBalance,
amountToTopup: number,
unit: MintUnit,
memo: string,
contactToSendTo?: Contact,
nwcEvent?: NostrEvent
): Promise<TransactionTaskResult> {
const now = Date.now()
const taskId = `topupTask-${now}`
return new Promise<TransactionTaskResult>((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<TransactionTaskResult>(
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,
unit: MintUnit,
memo: string,
): Promise<void> {
const now = new Date().getTime()
SyncQueue.addPrioritizedTask(
`cashuPaymentRequestTask-${now}`,
async () => await cashuPaymentRequestTask(
mintBalanceToReceiveTo,
amountToRequest,
unit,
memo,
)
)
return
}
const cashuPaymentRequestQueueAwaitable = function (
mintBalanceToReceiveTo: MintBalance,
amountToRequest: number,
unit: MintUnit,
memo: string,
): Promise<TransactionTaskResult> {
const now = Date.now()
const taskId = `cashuPaymentRequestTask-${now}`
return new Promise<TransactionTaskResult>((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<TransactionTaskResult>(
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
): Promise<void> {
const now = new Date().getTime()
SyncQueue.addPrioritizedTask(
`revertTask-${now}`,
async () => await revertTask(transaction)
)
return
}
const revertQueueAwaitable = function (
transaction: Transaction
): Promise<TransactionTaskResult> {
const now = Date.now()
const taskId = `revertTask-${now}`
return new Promise<TransactionTaskResult>((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<TransactionTaskResult>(
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
}): Promise<void> {
const { isPending } = options
const now = new Date().getTime()
return SyncQueue.addPrioritizedTask(
`syncSpendableStateTask-${now}`,
async () => await syncStateWithAllMintsTask({ isPending })
)
}
const syncStateWithAllMintsQueueAwaitable = function (
options: { isPending: boolean }
): Promise<SyncStateTaskResult> {
const { isPending } = options
const now = Date.now()
const taskId = `syncSpendableStateTask-${now}`
return new Promise<SyncStateTaskResult>((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<SyncStateTaskResult>(
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<SyncStateTaskResult> {
log.trace('[syncStateWithAllMintsTask] start')
if (mintsStore.mintCount === 0) {
return {
taskFunction: SYNC_STATE_WITH_ALL_MINTS_TASK,
transactionStateUpdates: [],
completedTransactionIds: [],
errorTransactionIds: [],
revertedTransactionIds: [],
message: 'No mints'
}
}
const { isPending } = options
const maxBatchSize = MAX_SYNC_INPUT_SIZE
const transactionStateUpdates: TransactionStateUpdate[] = []
const completedTransactionIds: number[] = []
const errorTransactionIds: number[] = []
const revertedTransactionIds: number[] = []
const errors: string[] = []
// group proofs by mint so that we do max one call per mint
// does not depend on unit, process in batches by 100
for (const mint of mintsStore.allMints) {
const proofsToSync = proofsStore.getByMint(mint.mintUrl, { isPending })
const totalProofsCount = proofsToSync.length
if(totalProofsCount === 0) {
log.trace('[syncStateWithAllMintsTask] No proofs to sync, skipping...', { mint: mint.mintUrl })
continue
}
if (totalProofsCount > maxBatchSize) {
for (let i = 0; i < totalProofsCount; i += maxBatchSize) {
const batch = proofsToSync.slice(i, i + maxBatchSize)
const result = await syncStateWithMintTask({ proofsToSync: batch, mintUrl: mint.mintUrl, isPending })
transactionStateUpdates.push(...result.transactionStateUpdates)
completedTransactionIds.push(...result.completedTransactionIds)
errorTransactionIds.push(...result.errorTransactionIds)
revertedTransactionIds.push(...result.revertedTransactionIds)
if(result.error) {
errors.push(result.error.message)
}
}
} else {
// If the length is less than or equal to 100, run syncStateWithMint with all proofs.
const result = await syncStateWithMintTask({ proofsToSync, mintUrl: mint.mintUrl, isPending })
transactionStateUpdates.push(...result.transactionStateUpdates)
completedTransactionIds.push(...result.completedTransactionIds)
errorTransactionIds.push(...result.errorTransactionIds)
revertedTransactionIds.push(...result.revertedTransactionIds)
if(result.error) {
errors.push(result.error.message)
}
}
}
let totalSpent = 0
let message = ''
for (const update of transactionStateUpdates) {
if(update.spentByMintAmount) {
totalSpent += update.spentByMintAmount
}
}
if(isPending) {
message = 'Pending proofs were synced with the mints.'
} else {
message = `Sync completed with ${errors.length} errors. Spent ecash with ${totalSpent} amount was cleaned.`
}
return {
taskFunction: SYNC_STATE_WITH_ALL_MINTS_TASK,
transactionStateUpdates,
completedTransactionIds,
errorTransactionIds,
revertedTransactionIds,
errors,
message
}
}
/*
* Pass syncStateWithMintTask function into synchronous queue for safe processing without race conditions on proof counters. *
*/
const syncStateWithMintQueue = async function (
options: {
proofsToSync: Proof[],
mintUrl: string,
isPending: boolean
}
): Promise<void> {
const {mintUrl, isPending, proofsToSync} = options
log.trace('[syncStateWithMintQueue] start', {mintUrl, isPending, proofsToSyncCount: proofsToSync.length})
const now = new Date().getTime()
return SyncQueue.addTask(
`syncStateWithMintTask-${now}`,
async () => await syncStateWithMintTask({proofsToSync, mintUrl, isPending})
)
}
const syncStateWithMintQueueAwaitable = function (
options: {
proofsToSync: Proof[]
mintUrl: string
isPending: boolean
}
): Promise<SyncStateTaskResult> {
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<SyncStateTaskResult>((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<SyncStateTaskResult>(
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)
* Used both for pending proof resolution and recovery of broken wallet state.
*/
const syncStateWithMintTask = async function (
options: {
proofsToSync: Proof[]
mintUrl: string
isPending: boolean
}
): Promise<SyncStateTaskResult> {
const { proofsToSync, mintUrl, isPending } = options
const mint = mintsStore.findByUrl(mintUrl)
log.trace('[syncStateWithMintTask] start', { mintUrl, proofCount: proofsToSync.length, isPending })
// Result accumulators
const transactionStateUpdates: TransactionStateUpdate[] = []
const completedTxIds: number[] = []
const errorTxIds: number[] = []
const pendingTxIds: number[] = []
const revertedTxIds: number[] = []
try {
if (proofsToSync.length === 0) {
const message = `No ${isPending ? 'pending ' : ''}proofs to sync with mint`
log.trace('[syncStateWithMintTask]', message)
return {
taskFunction: SYNC_STATE_WITH_MINT_TASK,
mintUrl,
message,
transactionStateUpdates,
completedTransactionIds: [],
errorTransactionIds: [],
revertedTransactionIds: []
}
}
// 1. Ask mint what it thinks about these proofs
const statesFromMint = await walletStore.getProofsStatesFromMint(
mintUrl,
mint?.units?.[0] ?? 'sat',
proofsToSync
)
if (mint) mint.setStatus(MintStatus.ONLINE)
const byMintState = {
SPENT: statesFromMint.SPENT.map(p => p.secret),
PENDING: statesFromMint.PENDING.map(p => p.secret),
UNSPENT: statesFromMint.UNSPENT.map(p => p.secret),
}
const secrets = {
spent: new Set(byMintState.SPENT),
pending: new Set(byMintState.PENDING),
unspent: new Set(byMintState.UNSPENT),
}
log.debug('[syncStateWithMintTask] Mint state', {
spent: secrets.spent.size,
pending: secrets.pending.size,
unspent: secrets.unspent.size,
isPending,
})
// Helper: group proofs by tId and compute total amount
const groupByTId = (proofs: Proof[]) => {
const map = new Map<number, { proofs: Proof[]; amount: number }>()
for (const p of proofs) {
if (!p.tId) continue
const entry = map.get(p.tId) ?? { proofs: [], amount: 0 }
entry.proofs.push(p)
entry.amount += p.amount
map.set(p.tId, entry)
}
return map
}
// ─────────────────────────────────────────────────────────────
// 1. Proofs now SPENT at mint → transaction succeeded
// ─────────────────────────────────────────────────────────────
if (secrets.spent.size > 0) {
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)
if (!tx) {
errorTxIds.push(tId)
transactionStateUpdates.push({
tId,
updatedStatus: TransactionStatus.ERROR,
message: 'Transaction not found in DB',
})
continue
}
if (spentAmount < tx.amount) {
// Partial spend → some proofs reused in another pending op → error
errorTxIds.push(tId)
transactionStateUpdates.push({
tId,
amount: tx.amount,
spentByMintAmount: spentAmount,
updatedStatus: TransactionStatus.ERROR,
message: 'Partial spend detected – proofs reused',
})
// If we were checking pending proofs, move unspent ones back
if (isPending) {
const stillUnspent = proofsToSync.filter(p => secrets.unspent.has(p.secret))
proofsStore.revertToSpendable(stillUnspent)
}
} else if (tx.status !== TransactionStatus.REVERTED) {
// Full success
completedTxIds.push(tId)
const update: TransactionStateUpdate = {
tId,
amount: tx.amount,
spentByMintAmount: spentAmount,
updatedStatus: TransactionStatus.COMPLETED,
}
if (tx.type === TransactionType.TRANSFER && tx.quote) {
update.meltQuoteToRecover = tx.quote
}
transactionStateUpdates.push(update)
}
}
// Recover change from completed melts
for (const update of transactionStateUpdates) {
if (update.meltQuoteToRecover) {
const { recoveredAmount } = await recoverMeltQuoteChange({
mintUrl,
meltQuote: update.meltQuoteToRecover,
})
update.recoveredChangeAmount = recoveredAmount
}
}
// Persist transaction status changes
if (completedTxIds.length > 0) {
await transactionsStore.updateStatuses(
completedTxIds,
TransactionStatus.COMPLETED,
JSON.stringify({
status: TransactionStatus.COMPLETED,
spentStateUpdates: transactionStateUpdates.filter(u => completedTxIds.includes(u.tId)), createdAt: new Date()
})
)
stopPolling(`syncStateWithMintPoller-${mintUrl}`)
}
if (errorTxIds.length > 0) {
await transactionsStore.updateStatuses(
errorTxIds,
TransactionStatus.ERROR,
JSON.stringify({
status: TransactionStatus.ERROR,
spentStateUpdates: transactionStateUpdates.filter(u => errorTxIds.includes(u.tId)), createdAt: new Date()
})
)
stopPolling(`syncStateWithMintPoller-${mintUrl}`)
}
}
// ─────────────────────────────────────────────────────────────
// 2. Proofs still PENDING at mint → keep pending in wallet
// ─────────────────────────────────────────────────────────────
if (secrets.pending.size > 0) {
const newPendingProofs = proofsToSync.filter(p => secrets.pending.has(p.secret) && !proofsStore.pendingByMintSecrets.includes(p.secret))
if (newPendingProofs.length > 0) {
proofsStore.registerAsPendingAtMint(newPendingProofs)
const pendingByTx = groupByTId(newPendingProofs)
for (const [tId, { amount: pendingAmount }] of pendingByTx) {
if (!pendingTxIds.includes(tId)) pendingTxIds.push(tId)
transactionStateUpdates.push({
tId,
pendingByMintAmount: pendingAmount,
updatedStatus: TransactionStatus.PENDING,
})
}
// If we discovered pending proofs in spendable balance (recovery mode), move them
if (!isPending) {
proofsStore.moveToPending(newPendingProofs)
}
if (pendingTxIds.length > 0) {
await transactionsStore.updateStatuses(
pendingTxIds,
TransactionStatus.PENDING,
JSON.stringify({
message: 'Waiting for a payment to settle',
pendingStateUpdates: transactionStateUpdates.filter(u => u.updatedStatus === TransactionStatus.PENDING),
createdAt: new Date(),
})
)
}
}
}
// ─────────────────────────────────────────────────────────────
// 3. Proofs no longer pending at mint → lightning failed → revert
// ─────────────────────────────────────────────────────────────
const noLongerPendingSecrets = proofsStore.pendingByMintSecrets.filter(
s => !secrets.pending.has(s)
)
if (noLongerPendingSecrets.length > 0) {
const revertedProofs: Proof[] = []
const revertedByTx = new Map<number, number>()
for (const secret of noLongerPendingSecrets) {
const proof = proofsStore.getBySecret(secret)
if (!proof || !proof.tId) continue
revertedProofs.push(proof)
revertedByTx.set(proof.tId, (revertedByTx.get(proof.tId) ?? 0) + proof.amount)
if (!revertedTxIds.includes(proof.tId)) revertedTxIds.push(proof.tId)
}
if (revertedProofs.length > 0) {
proofsStore.revertToSpendable(revertedProofs)
proofsStore.unregisterFromPendingAtMint(new Set(noLongerPendingSecrets))
for (const [tId, amount] of revertedByTx) {
transactionStateUpdates.push({
tId,
movedToSpendableAmount: amount,
updatedStatus: TransactionStatus.REVERTED,
})
}
if (revertedTxIds.length > 0) {
await transactionsStore.updateStatuses(
revertedTxIds,
TransactionStatus.REVERTED,
JSON.stringify({
message: 'Lightning payment failed – ecash returned to spendable balance',
revertedStateUpdates: transactionStateUpdates.filter(u => u.updatedStatus === TransactionStatus.REVERTED),
createdAt: new Date(),
})
)
}
}
}
// ─────────────────────────────────────────────────────────────
// Final success result
// ─────────────────────────────────────────────────────────────
return {
taskFunction: SYNC_STATE_WITH_MINT_TASK,
mintUrl,
message: `Sync completed for ${proofsToSync.length} ${isPending ? 'pending ' : ''}proofs`,
transactionStateUpdates,
completedTransactionIds: completedTxIds,
errorTransactionIds: errorTxIds,
pendingTransactionIds: pendingTxIds,
revertedTransactionIds: revertedTxIds,
}
} catch (e: any) {
log.error('[syncStateWithMintTask] failed', { mintUrl, error: e.message })
if (mint && e.name === Err.MINT_ERROR && e.message.includes('network')) {
mint.setStatus(MintStatus.OFFLINE)
}
return {
taskFunction: SYNC_STATE_WITH_MINT_TASK,
mintUrl,
message: `Sync failed: ${e.message}`,
error: e,
transactionStateUpdates,
completedTransactionIds: completedTxIds,
errorTransactionIds: errorTxIds,
pendingTransactionIds: pendingTxIds,
revertedTransactionIds: revertedTxIds,
}
}
}
const handleInFlightQueue = async function (): Promise<void> {
log.trace('[handleInFlight] start')
if (mintsStore.mintCount === 0) {
return
}
for (const mint of mintsStore.allMints) {
if(mint.proofsCountersWithInFlightRequests.length === 0) {
log.trace('No proofCounters with inFlight requests, skipping...')
continue
}
const now = new Date().getTime()
SyncQueue.addTask(
`${HANDLE_INFLIGHT_BY_MINT_TASK}-${now}`,
async () => await handleInFlightByMintTask(mint)
)
}
return
}
/**
* Recover proofs from in-flight mint/swap requests that failed due to network issues.
* Uses mint's idempotent endpoints to safely retry and complete pending operations.
*/
const handleInFlightByMintTask = async (mint: Mint): Promise<WalletTaskResult> => {
const mintUrl = mint.mintUrl
const countersWithInFlight = mint.proofsCountersWithInFlightRequests || []
log.trace('[handleInFlightByMintTask] start', {
mintUrl,
counters: countersWithInFlight?.length,
totalRequests: mint.allInFlightRequests?.length ?? 0,
})
if (countersWithInFlight.length === 0) {
return {
taskFunction: HANDLE_INFLIGHT_BY_MINT_TASK,
mintUrl,
message: 'No in-flight requests found',
}
}
const errors: string[] = []
for (const counter of countersWithInFlight) {
for (const inFlight of counter.allInFlightRequests) { // clone to allow safe removal
const tx = transactionsStore.findById(inFlight.transactionId)
if (!tx) {
counter.removeInFlightRequest(inFlight.transactionId)
continue
}
let txData: TransactionData[] = []
try {
txData = tx.data ? JSON.parse(tx.data) : []
} catch (e) {
log.warn('Failed to parse transaction.data', { tId: tx.id })
}
const { unit } = tx
try {
switch (tx.type) {
// ─── RECEIVE (token receive retry) ─────────────────────
case TransactionType.RECEIVE: {
const { proofs, swapFeePaid } = await walletStore.receive(
mintUrl,
unit,
inFlight.request.token,
tx.id,
{ inFlightRequest: inFlight }
)
const { updatedAmount: receivedAmount } = proofsStore.addOrUpdate(proofs, {
mintUrl,
tId: tx.id,
unit,
isPending: false,
isSpent: false,
})
const outputToken = getEncodedToken({ mint: mintUrl, proofs, unit })
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
txData.push({ status: TransactionStatus.COMPLETED, receivedAmount, swapFeePaid, createdAt: new Date() })
tx.update({
amount: receivedAmount,
status: TransactionStatus.COMPLETED,
data: JSON.stringify(txData),
outputToken,
balanceAfter,
fee: swapFeePaid > 0 ? swapFeePaid : tx.fee,
})
break
}
// ─── SEND (ecash send retry) ───────────────────────────
case TransactionType.SEND: {
const { returnedProofs, proofsToSend, swapFeePaid } = await walletStore.send(
mintUrl,
inFlight.request.amount,
unit,
inFlight.request.proofs,
tx.id,
{ inFlightRequest: inFlight }
)
// Mark inputs as spent
proofsStore.addOrUpdate(inFlight.request.proofs, {
mintUrl,
tId: tx.id,
unit,
isPending: false,
isSpent: true,
})
// Add change + outgoing proofs
proofsStore.addOrUpdate(returnedProofs, { mintUrl, tId: tx.id, unit, isPending: false, isSpent: false })
proofsStore.addOrUpdate(proofsToSend, { mintUrl, tId: tx.id, unit, isPending: true, isSpent: false })
const outputToken = getEncodedToken({ mint: mintUrl, proofs: proofsToSend, unit })
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
txData.push({ status: TransactionStatus.PENDING, createdAt: new Date() })
tx.update({
status: TransactionStatus.PENDING,
data: JSON.stringify(txData),
outputToken,
balanceAfter,
fee: swapFeePaid > 0 ? swapFeePaid : tx.fee,
})
break
}
// ─── TOPUP (minting retry) ─────────────────────────────
case TransactionType.TOPUP: {
const proofs = await walletStore.mintProofs(
mintUrl,
inFlight.request.amount,
unit,
inFlight.request.quote,
tx.id,
{ inFlightRequest: inFlight }
)
proofsStore.addOrUpdate(proofs, {
mintUrl,
tId: tx.id,
unit,
isPending: false,
isSpent: false,
})
stopPolling(`handlePendingTopupPoller-${tx.paymentId}`)
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
txData.push({ status: TransactionStatus.COMPLETED, createdAt: new Date() })
tx.update({
status: TransactionStatus.COMPLETED,
data: JSON.stringify(txData),
balanceAfter,
})
break
}
// ─── TRANSFER (melt / lightning out retry) ─────────────
case TransactionType.TRANSFER: {
const { quote, change } = await walletStore.payLightningMelt(
mintUrl,
unit,
inFlight.request.meltQuote,
inFlight.request.proofsToSend,
tx.id,
{ inFlightRequest: inFlight }
)
// Mark spent inputs
proofsStore.addOrUpdate(inFlight.request.proofsToSend, {
mintUrl,
tId: tx.id,
unit,
isPending: false,
isSpent: true,
})
if (change.length > 0) {
proofsStore.addOrUpdate(change, {
mintUrl,
tId: tx.id,
unit,
isPending: false,
isSpent: false,
})
const outputToken = getEncodedToken({ mint: mintUrl, proofs: change, unit })
tx.update({ outputToken })
}
if (quote.payment_preimage) {
tx.update({ proof: quote.payment_preimage })
}
const inputAmount = CashuUtils.getProofsAmount(inFlight.request.proofsToSend)
const changeAmount = CashuUtils.getProofsAmount(change)
const totalFee = inputAmount - tx.amount - changeAmount
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
txData.push({
status: TransactionStatus.COMPLETED,
changeAmount,
preimage: quote.payment_preimage,
createdAt: new Date(),
})
tx.update({
status: TransactionStatus.COMPLETED,
data: JSON.stringify(txData),
fee: totalFee,
balanceAfter,
})
break
}
default:
log.error('[handleInFlightByMintTask] Unknown tx type', { type: tx.type, tId: tx.id })
}
// Success → remove from in-flight
counter.removeInFlightRequest(inFlight.transactionId)
} catch (e: any) {
log.error(`[handleInFlightByMintTask] ${tx.type} failed`, {
tId: tx.id,
error: e.name,
message: e.message,
})
errors.push(`${tx.type} tId=${tx.id}: ${e.message}`)
// Do NOT remove from inFlight — will retry next time
}
}
}
const totalProcessed = mint.allInFlightRequests?.length ?? 0
return {
taskFunction: HANDLE_INFLIGHT_BY_MINT_TASK,
mintUrl,
errors,
message: `Processed ${totalProcessed} in-flight requests (${errors.length} failed)`,
}
}
/**
* Process all pending topups and expired lightning transfers
*/
const handlePendingQueue = async (): Promise<void> => {
const pendingTopups = transactionsStore.getPendingTopups()
const pendingTransfers = transactionsStore.getPendingTransfers()
log.trace('[handlePendingQueue] start', {
pendingTopups: pendingTopups.length,
pendingTransfers: pendingTransfers.length,
})
// 1. Expire old lightning transfers (no ecash ops needed)
for (const tx of pendingTransfers) {
if (tx.expiresAt && isBefore(tx.expiresAt, new Date())) {
log.debug('[handlePendingQueue] Expiring transfer', { paymentId: tx.paymentId })
const update = {
status: TransactionStatus.EXPIRED,
message: 'Lightning invoice expired',
createdAt: new Date(),
}
try {
const txData = tx.data ? [...JSON.parse(tx.data), update] : [update]
tx.update({
status: TransactionStatus.EXPIRED,
data: JSON.stringify(txData),
})
} catch(e: any) {
tx.update({
status: TransactionStatus.EXPIRED,
data: JSON.stringify(update),
})
}
}
}
// 2. Schedule pending topups (only if not already being polled)
for (const tx of pendingTopups) {
if (pollerExists(`handlePendingTopupPoller-${tx.paymentId}`)) {
log.trace('[handlePendingQueue] Skipping topup – poller active', { paymentId: tx.paymentId })
continue
}
// Unique task ID to prevent duplicates
const taskId = `handlePendingTopupTask-${tx.id}-${Date.now()}`
SyncQueue.addTask(taskId, () => handlePendingTopupTask({ transaction: tx }))
}
if (pendingTopups.length === 0) {
log.trace('[handlePendingQueue] No pending topups')
}
}
/**
* Check a single pending mint quote and mint proofs if paid — even after expiry
*/
const handlePendingTopupTask = async (
params: { transaction: Transaction }
): Promise<WalletTaskResult> => {
const { transaction: tx } = params
const {
id: tId,
mint: mintUrl,
unit,
amount,
paymentId: paymentHash,
quote: mintQuote,
expiresAt,
} = tx
log.warn('[handlePendingTopupTask] start', {tx})
const mint = mintsStore.findByUrl(mintUrl)
if (!mint || !mintQuote || !unit || !amount) {
throw new AppError(Err.VALIDATION_ERROR, 'Invalid pending topup transaction', { tId })
}
let txData: TransactionData = tx.data ? JSON.parse(tx.data) : []
try {
// ─── Ask mint about the quote state FIRST (this is authoritative) ───
const { state, mintQuote: returnedQuote } = await walletStore.checkLightningMintQuote(mintUrl, mintQuote)
// ─── If invoice is expired → mark transaction as expired BUT still try to mint if paid ───
const isExpired = expiresAt && isBefore(expiresAt, new Date())
if (isExpired && state !== MintQuoteState.PAID) {
log.debug('[handlePendingTopupTask] Invoice expired and not paid', { paymentHash })
txData.push({ status: TransactionStatus.EXPIRED, message: 'Invoice expired', createdAt: new Date() })
tx.update({ status: TransactionStatus.EXPIRED, data: JSON.stringify(txData) })
stopPolling(`handlePendingTopupPoller-${paymentHash}`)
return {
taskFunction: HANDLE_PENDING_TOPUP_TASK,
transaction: tx,
mintUrl,
unit,
amount,
paymentHash,
message: 'Topup invoice expired and was not paid',
}
}
// ─── Main state handling (UNPAID / PAID / ISSUED) ───
switch (state) {
case MintQuoteState.UNPAID:
log.trace('[handlePendingTopupTask] Quote still unpaid', { mintQuote })
// If expired → final failure
if (isExpired) {
txData.push({ status: TransactionStatus.EXPIRED, message: 'Invoice expired (unpaid)', createdAt: new Date() })
tx.update({ status: TransactionStatus.EXPIRED, data: JSON.stringify(txData) })
stopPolling(`handlePendingTopupPoller-${paymentHash}`)
}
return {
taskFunction: HANDLE_PENDING_TOPUP_TASK,
transaction: tx,
mintUrl,
unit,
amount,
paymentHash,
message: isExpired ? 'Invoice expired (unpaid)' : 'Quote not paid yet',
}
case MintQuoteState.ISSUED:
log.info('[handlePendingTopupTask] Proofs already issued (likely from another device)', { mintQuote })
// Mark as completed even if expired
txData.push({ status: TransactionStatus.COMPLETED, note: 'Already issued', createdAt: new Date() })
tx.update({ status: TransactionStatus.COMPLETED, data: JSON.stringify(txData) })
stopPolling(`handlePendingTopupPoller-${paymentHash}`)
return {
taskFunction: HANDLE_PENDING_TOPUP_TASK,
transaction: tx,
mintUrl,
unit,
amount,
paymentHash,
message: 'Ecash already issued',
}
case MintQuoteState.PAID: {
log.debug('[handlePendingTopupTask] Quote PAID – minting proofs (even if expired)', { paymentHash, amount, unit, isExpired })
let proofs: CashuProof[] = []
try {
proofs = await walletStore.mintProofs(mintUrl, amount, unit, mintQuote, tId)
} catch (e: any) {
if (WalletUtils.shouldHealOutputsError(e)) {
log.error('[handlePendingTopupTask] Increasing proofsCounter outdated values and repeating mintProofs.')
proofs = await walletStore.mintProofs(mintUrl, amount, unit, mintQuote, tId, { increaseCounterBy: 10 })
} else {
throw e
}
}
if (proofs.length === 0) {
throw new AppError(Err.MINT_ERROR, 'Mint returned no proofs after payment')
}
proofsStore.addOrUpdate(proofs, {
mintUrl,
unit,
tId,
isPending: false,
isSpent: false,
})
const currencyCode = getCurrency(unit).code
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
txData.push({ status: TransactionStatus.COMPLETED, createdAt: new Date() })
tx.update({
status: TransactionStatus.COMPLETED,
data: JSON.stringify(txData),
balanceAfter,
})
stopPolling(`handlePendingTopupPoller-${paymentHash}`)
_sendTopupNotification(amount, unit)
return {
taskFunction: HANDLE_PENDING_TOPUP_TASK,
transaction: tx,
mintUrl,
unit,
amount,
paymentHash,
message: `Topup successful: +${formatCurrency(amount, currencyCode)} ${currencyCode}${isExpired ? ' (paid after expiry)' : ''}`,
}
}
default:
log.error('[handlePendingTopupTask] Unknown quote state', { state })
return {
taskFunction: HANDLE_PENDING_TOPUP_TASK,
transaction: tx,
mintUrl,
unit,
amount,
paymentHash,
message: `Unknown quote state: ${state}`,
}
}
} catch (e: any) {
log.error('[handlePendingTopupTask] failed', {
tId,
paymentHash,
error: e.name,
message: e.message,
})
return {
taskFunction: HANDLE_PENDING_TOPUP_TASK,
transaction: tx,
mintUrl,
unit,
amount,
paymentHash,
error: WalletUtils.formatError(e),
message: `Topup failed: ${e.message}`,
}
}
}
/**
* Manually recover minted ecash from a paid mint quote (e.g. lost topup)
*/
const recoverMintQuote = async (
params: { mintUrl: string; mintQuote: string }
): Promise<{ recoveredAmount: number }> => {
const { mintUrl, mintQuote } = params
const mint = mintsStore.findByUrl(mintUrl)
const unit: MintUnit = 'sat'
if (!mint || !mintQuote) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing mint or mint quote', { mintUrl, mintQuote })
}
log.trace('[recoverMintQuote] start', { mintUrl, mintQuote })
const { state, mintQuote: returnedQuote, encodedInvoice } = await walletStore.checkLightningMintQuote(mintUrl, mintQuote)
if (returnedQuote !== mintQuote) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint returned mismatched quote', { mintQuote, returnedQuote })
}
switch (state) {
case MintQuoteState.UNPAID:
throw new AppError(Err.VALIDATION_ERROR, `Quote ${mintQuote} is not paid`)
case MintQuoteState.ISSUED:
throw new AppError(Err.VALIDATION_ERROR, `Quote ${mintQuote} already issued – nothing to recover`)
case MintQuoteState.PAID: {
const invoice = LightningUtils.decodeInvoice(encodedInvoice)
const { amount, description } = LightningUtils.getInvoiceData(invoice)
// Create recovery transaction
const txData: TransactionData[] = [
{ status: TransactionStatus.DRAFT, amount, unit, createdAt: new Date() },
]
const tx = await transactionsStore.addTransaction({
type: TransactionType.TOPUP,
amount,
fee: 0,
unit,
data: JSON.stringify(txData),
memo: description || 'Recovered topup',
mint: mintUrl,
status: TransactionStatus.DRAFT,
quote: mintQuote,
})
let proofs: CashuProof[] = []
try {
proofs = await walletStore.mintProofs(mintUrl, amount, unit, mintQuote, tx.id)
} catch (e: any) {
if (WalletUtils.shouldHealOutputsError(e)) {
log.error('[recoverMintQuote] Increasing proofsCounter outdated values and repeating mintProofs')
proofs = await walletStore.mintProofs(mintUrl, amount, unit, mintQuote, tx.id, { increaseCounterBy: 10 })
} else {
throw e
}
}
if (proofs.length === 0) {
throw new AppError(Err.MINT_ERROR, 'Mint returned no proofs to recover')
}
const { updatedAmount: recoveredAmount } = proofsStore.addOrUpdate(proofs, {
mintUrl,
unit,
tId: tx.id,
isPending: false,
isSpent: false,
})
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
txData.push({ status: TransactionStatus.RECOVERED, recoveredAmount, createdAt: new Date() })
tx.update({
status: TransactionStatus.RECOVERED,
amount: recoveredAmount,
balanceAfter,
data: JSON.stringify(txData),
})
log.debug('[recoverMintQuote] Success', { mintUrl, mintQuote, recoveredAmount })
return { recoveredAmount }
}
default:
log.error('[recoverMintQuote] Unknown quote state', { state })
throw new AppError(Err.VALIDATION_ERROR, `Unknown quote state: ${state}`)
}
}
/**
* Manually recover change from a paid melt quote (lightning out)
*/
const recoverMeltQuoteChange = async (
params: {
mintUrl: string
meltQuote: string | MeltQuoteResponse
}
): Promise<{ recoveredAmount: number }> => {
const { mintUrl, meltQuote } = params
const mint = mintsStore.findByUrl(mintUrl)
const unit: MintUnit = 'sat'
if (!mint || !meltQuote) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing mint or melt quote', { mintUrl, meltQuote })
}
log.trace('[recoverMeltQuoteChange] start', { mintUrl, meltQuote })
const meltQuoteResponse: MeltQuoteResponse =
typeof meltQuote === 'string'
? await walletStore.checkLightningMeltQuote(mintUrl, meltQuote)
: meltQuote
const { quote, state, change } = meltQuoteResponse
switch (state) {
case MeltQuoteState.UNPAID:
throw new AppError(Err.VALIDATION_ERROR, `Melt quote ${meltQuote} was not paid`)
case MeltQuoteState.PENDING:
throw new AppError(Err.VALIDATION_ERROR, `Melt quote ${meltQuote} is still pending – cannot recover change yet`)
case MeltQuoteState.PAID: {
if (!change || change.length === 0) {
throw new AppError(Err.VALIDATION_ERROR, `No change available for melt quote ${meltQuote}`)
}
let tx = transactionsStore.findLastBy({ quote })
if (!tx) {
throw new AppError(Err.VALIDATION_ERROR, 'Original melt transaction not found', { meltQuote })
}
let txData: TransactionData = tx.data ? JSON.parse(tx.data) : []
try {
// Recover blind signatures requires original counter value to produce valid proofs
const change = await walletStore.recoverMeltQuoteChange(mintUrl, meltQuoteResponse, tx.id)
// Make sure we do not recover already received change
const newChange = change.filter(proof => !proofsStore.alreadyExists(proof))
if (newChange.length === 0) {
throw new AppError(Err.MINT_ERROR, `No new ecash proofs to recover from melt quote, ${change.length} proofs already in wallet.`)
}
// Force zero-value swap to validate and receive change proofs
const { returnedProofs } = await walletStore.send(
mintUrl,
0,
unit,
newChange as Proof[],
tx.id,
{ increaseCounterBy: newChange.length } // ?
)
if (!returnedProofs || returnedProofs.length === 0) {
throw new AppError(Err.MINT_ERROR, 'Mint returned no proofs during change recovery')
}
const { updatedAmount: recoveredAmount } = proofsStore.addOrUpdate(returnedProofs, {
mintUrl,
unit,
tId: tx.id,
isPending: false,
isSpent: false,
})
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
const outputToken = getEncodedToken({ mint: mintUrl, proofs: returnedProofs, unit })
txData.push({
status: TransactionStatus.RECOVERED,
recoveredAmount,
createdAt: new Date(),
})
tx.update({
status: TransactionStatus.RECOVERED,
amount: recoveredAmount,
balanceAfter,
outputToken,
data: JSON.stringify(txData),
})
log.debug('[recoverMeltQuoteChange] Success', { meltQuote, recoveredAmount })
return { recoveredAmount }
} catch (e: any) {
log.error('[recoverMeltQuoteChange] Failed', { meltQuote, error: e.message })
txData.push({
status: TransactionStatus.ERROR,
error: WalletUtils.formatError(e),
createdAt: new Date(),
})
tx.update({
status: TransactionStatus.ERROR,
data: JSON.stringify(txData),
})
return { recoveredAmount: 0 }
}
}
default:
log.error('[recoverMeltQuoteChange] Unknown melt state', { state })
throw new AppError(Err.VALIDATION_ERROR, `Unknown melt quote state: ${state}`)
}
}
const handleClaimQueue = async function (): Promise<void> {
log.info('[handleClaimQueue] start')
const {isOwnProfile} = walletProfileStore
if(isOwnProfile) {
log.info('[handleClaimQueue] Skipping claim queue, wallet uses own Nostr keys...')
return
}
const {isBatchClaimOn} = userSettingsStore
const keys = await walletStore.getCachedWalletKeys() // throws
// Based on user setting, ask for batched token if more then 5 payments are waiting to be claimed
const claimedTokens = await MinibitsClient.createClaim(
keys.walletId,
keys.SEED.seedHash,
isBatchClaimOn ? 5 : undefined
)
if(claimedTokens.length === 0) {
log.debug('[handleClaimQueue] No claimed invoices returned from the server...')
return
}
log.debug(`[handleClaimQueue] Claimed ${claimedTokens.length} tokens from the server...`)
for(const claimedToken of claimedTokens) {
const now = new Date().getTime()
SyncQueue.addTask(
`handleClaimTask-${now}`,
async () => await handleClaimTask({claimedToken})
)
}
return
}
const handleClaimTask = async function (params: {
claimedToken: {
token: string,
zapSenderProfile?: string,
zapRequest?: string,
}}) {
let decoded: Token | undefined = undefined
try {
const {claimedToken} = params
log.debug('[handleClaimTask] claimed token', {claimedToken})
if(!claimedToken.token) {
throw new AppError(Err.VALIDATION_ERROR, '[handleClaimTask] Missing encodedToken to receive.')
}
const encryptedToken = claimedToken.token
const encodedToken = await NostrClient.decryptNip04(MINIBIT_SERVER_NOSTR_PUBKEY, encryptedToken)
log.debug('[handleClaimTask] decrypted token', {encodedToken})
decoded = getDecodedToken(encodedToken)
const amountToReceive = CashuUtils.getProofsAmount(decoded.proofs)
const memo = decoded.memo || 'Received to Lightning address'
const result: TransactionTaskResult = await receiveTask(
decoded,
amountToReceive,
memo,
encodedToken,
)
if(result && result.transaction) {
const transaction = transactionsStore.findById(result.transaction.id!)
const {zapSenderProfile, zapRequest} = claimedToken
let message: string = ''
if(transaction) {
if (zapSenderProfile) {
let sentFrom: string = ''
try {
const profile: NostrProfile = JSON.parse(zapSenderProfile)
sentFrom = profile.nip05 ?? profile.name
} catch(e: any) {}
transaction.update({profile: zapSenderProfile, sentFrom})
}
if (zapRequest) {
transaction.update({zapRequest})
}
}
}
return {
mintUrl: decoded.mint,
taskFunction: HANDLE_CLAIM_TASK,
message: result.error ? result.error.message : 'Ecash sent to your lightning address has been received.',
error: result.error || undefined,
proofsCount: decoded.proofs.length,
proofsAmount: result.transaction?.amount,
} as WalletTaskResult
} catch (e: any) {
log.error(e.name, e.message)
return {
mintUrl: decoded ? decoded.mint : '',
taskFunction: HANDLE_CLAIM_TASK,
message: e.message,
error: WalletUtils.formatError(e),
} as WalletTaskResult
}
}
const handleNwcRequestQueue = async function (params: {requestEvent: NostrEvent}): Promise<void> {
const {requestEvent} = params
log.trace('[handleNwcRequestQueue] start')
// pass command to handler
const now = new Date().getTime()
SyncQueue.addTask(
`handleNwcRequestTask-${now}`,
async () => await nwcStore.handleNwcRequestTask(requestEvent)
)
}
const _sendTopupNotification = async function (amount: number, unit: MintUnit) {
const currencyCode = getCurrency(unit).code
await NotificationService.createLocalNotification(
`⚡ ${formatCurrency(amount, currencyCode)} ${currencyCode} received!`,
`Your invoice has been paid and your wallet balance credited with ${formatCurrency(amount, currencyCode)} ${currencyCode}.`,
)
}
/*
* Checks with NOSTR relays whether there is ecash to be received or an invoice to be paid.
*/
const receiveEventsFromRelaysQueue = async function (): Promise<void> {
log.trace('[receiveEventsFromRelays] starting listening for events')
if(!walletProfileStore.pubkey) {
const message = `No wallet profile created.`
log.trace('[receiveEventsFromRelays]', message)
return
}
try {
const { lastPendingReceivedCheck } = contactsStore
const TWO_DAYS = 2 * 24 * 60 * 60
const filter = {
kinds: [GiftWrap, EncryptedDirectMessage],
"#p": [walletProfileStore.pubkey],
since: lastPendingReceivedCheck ? lastPendingReceivedCheck - TWO_DAYS : 0
} // 2 days variance to the past from real event created_at for dm sent as gift wraps
log.trace('[receiveEventsFromRelays]', {filter})
// do not use event timestamp as it might be in the future, preventing further checks
contactsStore.setLastPendingReceivedCheck()
const pool = NostrClient.getRelayPool()
// make sure we have at least default relays
if(relaysStore.allRelays.length < 3) {
relaysStore.addDefaultRelays()
}
let relaysToConnect = relaysStore.allUrls
let eventsBatch: NostrEvent[] = []
const sub = pool.subscribeMany(relaysToConnect , [filter], {
onevent(event) {
// ignore all kinds of duplicate events
if(eventsBatch.some(ev => ev.id === event.id)) {
log.warn(
Err.ALREADY_EXISTS_ERROR,
'Duplicate event received by this subscription, skipping...',
{id: event.id, created_at: event.created_at}
)
return
}
if(relaysStore.eventAlreadyReceived(event.id)) {
log.warn(
Err.ALREADY_EXISTS_ERROR,
'Event has been processed in the past, skipping...',
{id: event.id, created_at: event.created_at}
)
return
}
eventsBatch.push(event)
relaysStore.addReceivedEventId(event.id)
const now = new Date().getTime()
SyncQueue.addTask(
`handleReceivedEventTask-${now}`,
async () => await handleReceivedEventTask(event)
)
},
oneose() {
log.trace('[receiveEventsFromRelays]', `Eose: Got ${eventsBatch.length} receive events`)
const connections = pool.listConnectionStatus()
for (const conn of Array.from(connections)) {
const relayInstance = relaysStore.findByUrl(conn[0])
if(conn[1] === true) {
log.trace('[receiveEventsFromRelays] Connection is OPEN', {conn: conn[0]})
relayInstance?.setStatus(WebSocket.OPEN)
} else {
log.trace('[receiveEventsFromRelays] Connection is CLOSED', {conn: conn[0]})
relayInstance?.setStatus(WebSocket.CLOSED)
}
}
}
})
} catch (e: any) {
log.error(e.name, e.message)
return
}
}
const handleReceivedEventTask = async function (encryptedEvent: NostrEvent): Promise<WalletTaskResult> {
try {
let directMessageEvent: NostrEvent | UnsignedEvent | undefined = undefined
let decryptedMessage: string | undefined = undefined
if (encryptedEvent.kind === EncryptedDirectMessage) {
directMessageEvent = encryptedEvent
decryptedMessage = await NostrClient.decryptNip04(encryptedEvent.pubkey, encryptedEvent.content)
}
if (encryptedEvent.kind === GiftWrap) {
directMessageEvent = await NostrClient.decryptDirectMessageNip17(encryptedEvent)
decryptedMessage = directMessageEvent.content
}
if(!directMessageEvent || !decryptedMessage) {
throw new AppError(Err.VALIDATION_ERROR, 'Unrecognized direct message kind', {kind: encryptedEvent.kind})
}
// set REAL direct message created_at as the lastPendingReceivedCheck
// but only if it is in the past (so that malicious event won't block future checks)
if(directMessageEvent.created_at < new Date().getTime() / 1000) {
contactsStore.setLastPendingReceivedCheck(directMessageEvent.created_at)
}
log.trace('[handleReceivedEventTask]', 'Received event', {directMessageEvent})
let sentFromPubkey = directMessageEvent.pubkey
let sentFrom = NostrClient.getFirstTagValue(directMessageEvent.tags, 'from') as string | undefined
let sentFromNpub = NostrClient.getNpubkey(sentFromPubkey)
// Drop message if user wants to receive only from contacts and sender is not in
if(userSettingsStore.isReceiveOnlyFromContactsOn
&& sentFromPubkey !== MINIBIT_SERVER_NOSTR_PUBKEY) {
const contactInstance = contactsStore.findByPubkey(sentFromPubkey)
if(!contactInstance) {
// drop silently
let message = 'Message received over Nostr has been blocked, the sender is not in your contacts.'
log.error(message, {sentFromPubkey, sentFrom, decryptedMessage})
return {
taskFunction: HANDLE_RECEIVED_EVENT_TASK,
message,
} as WalletTaskResult
}
}
// get sender profile and save it as a contact
// this is not valid for events sent from Minibits LNURL bridge, that are sent and signed by a minibits server key
// and *** do not contain sentFrom *** // LEGACY, replaced by claim api
let contactFrom: Contact | undefined = undefined
let zapSenderProfile: NostrProfile | undefined = undefined
let sentFromPicture: string | undefined = undefined
// Add ecash or pr sender to the contacts.
// To avoid malicious events (from tag can be faked), add only minibits.cash addresses
// where we check, that sentFromPubkey matches the pubkey of sentFrom name on the Minibits server
if( sentFrom
&& sentFrom.includes(MINIBITS_NIP05_DOMAIN)
&& userSettingsStore.isReceiveOnlyFromContactsOn === false
) {
const serverProfile = await MinibitsClient.getWalletProfileByNip05(sentFrom)
if(serverProfile.pubkey !== sentFromPubkey) {
throw new AppError(Err.UNAUTHORIZED_ERROR, 'Sender pubkey does not match the one on the Minibits server.', {sentFrom, sentFromPubkey, serverProfile})
}
log.info('[handleReceivedEventTask]', 'Event sent from Minibits server user, adding to contacts...', {sentFrom, sentFromPubkey})
contactFrom = {
pubkey: sentFromPubkey,
npub: sentFromNpub,
nip05: serverProfile.nip05,
lud16: serverProfile.lud16,
name: serverProfile.name,
picture: serverProfile.avatar,
isExternalDomain: false
} as Contact
contactsStore.addContact(contactFrom)
}
// Event was sent from Minibits server, try to extract zap sender profile from the message
if(sentFromPubkey === MINIBIT_SERVER_NOSTR_PUBKEY) {
log.info('[handleReceivedEventTask]', 'Event sent from Minibits server, extracting zap sender profile...')
const maybeZapSenderString = _extractZapSenderData(decryptedMessage)
if(maybeZapSenderString) {
try {
zapSenderProfile = JSON.parse(maybeZapSenderString)
if(zapSenderProfile) {
sentFromPubkey = zapSenderProfile.pubkey // zap sender pubkey
sentFrom = zapSenderProfile.nip05 ?? zapSenderProfile.name
sentFromPicture = zapSenderProfile.picture
const sentFromLud16 = zapSenderProfile.lud16
// we do not add zappers to contacts but if we have such contact, set or update its lightning address by the one from profile
const contactInstance = contactsStore.findByPubkey(sentFromPubkey)
if(contactInstance && sentFromLud16) {
contactInstance.setLud16(sentFromLud16)
}
}
} catch (e: any) {
log.warn('[handleReceivedEventTask]', 'Could not get sender from zapRequest', {message: e.message, maybeZapSenderString})
}
}
}
// parse incoming message
const incoming = IncomingParser.findAndExtract(decryptedMessage)
log.trace('[handleReceivedEventTask]', 'Incoming data', {incoming})
//
// Receive token start
//
if(incoming.type === IncomingDataType.CASHU) {
const decoded = getDecodedToken(incoming.encoded)
const amountToReceive = CashuUtils.getProofsAmount(decoded.proofs)
const memo = decoded.memo || 'Received over Nostr'
// do not allow to receive automatically from unknown mints
if(!mintsStore.mintExists(decoded.mint)) {
let message = 'Receiving ecash token over Nostr from unknown mint is not allowed.'
const transactionData: TransactionData[] = []
let transaction: Transaction | undefined = undefined
const {unit, mint} = decoded
transactionData.push({
status: TransactionStatus.ERROR,
amountToReceive,
unit,
createdAt: new Date(),
})
const newTransaction = {
type: TransactionType.RECEIVE,
amount: amountToReceive,
fee: 0,
unit: unit as MintUnit,
data: JSON.stringify(transactionData),
memo,
mint,
status: TransactionStatus.DRAFT,
}
transaction = await transactionsStore.addTransaction(newTransaction)
transaction.update({inputToken: incoming.encoded})
await _sendErrorReceiveNotification(
amountToReceive,
decoded.unit as MintUnit,
decoded.mint
)
throw new AppError(Err.VALIDATION_ERROR, message, {decoded})
}
const {transaction, receivedAmount} = await receiveTask(
decoded,
amountToReceive,
memo,
incoming.encoded as string,
)
// store contact or zapseder in tx details
if(transaction && sentFrom) {
if (contactFrom) {
transaction.update({profile: JSON.stringify(contactFrom), sentFrom})
}
if (zapSenderProfile) {
transaction.update({profile: JSON.stringify(zapSenderProfile), sentFrom})
}
const isZap = zapSenderProfile ? true : false
// We do it defensively only after cash is received
// and asynchronously so we speed up queue
_sendReceiveNotification(
receivedAmount,
transaction.unit,
isZap,
sentFrom,
sentFromPicture
) // TODO move to task result handler
}
return {
mintUrl: decoded.mint,
taskFunction: HANDLE_RECEIVED_EVENT_TASK,
message: 'Incoming ecash token has been received.',
proofsCount: decoded.proofs.length,
proofsAmount: receivedAmount,
transaction
} as WalletTaskResult
}
//
// Receive bolt11 invoice start
//
else if (incoming.type === IncomingDataType.INVOICE) {
// payer is current wallet profile
const {
pubkey,
npub,
name,
picture,
} = walletProfileStore
const contactTo: Contact = {
pubkey,
npub,
name,
picture
}
const decoded = LightningUtils.decodeInvoice(incoming.encoded)
const {
amount,
description,
expiry,
payment_hash: paymentHash,
timestamp
} = LightningUtils.getInvoiceData(decoded)
const maybeMemo = NostrClient.findMemo(decryptedMessage)
// create draft transaction
const defaultMintBalance: MintBalance | undefined = proofsStore.getMintBalanceWithMaxBalance('sat')
if(!defaultMintBalance) {
let message = 'Wallet does not have any mint with SATS unit.'
throw new AppError(Err.VALIDATION_ERROR, message, {decoded})
}
const transactionData: TransactionData[] = [
{
status: TransactionStatus.DRAFT,
mintBalanceToTransferFrom: defaultMintBalance.mintUrl,
amountToTransfer: amount,
unit: 'sat',
isNwc: false,
createdAt: new Date(),
}
]
const newTransaction = {
type: TransactionType.TRANSFER,
amount,
fee: 0,
unit: 'sat' as MintUnit,
data: JSON.stringify(transactionData),
memo: maybeMemo || description,
mint: defaultMintBalance.mintUrl,
status: TransactionStatus.DRAFT,
}
const transaction = await transactionsStore.addTransaction(newTransaction)
transaction.update({
paymentId: paymentHash,
paymentRequest: incoming.encoded,
expiresAt: addSeconds(new Date(timestamp * 1000), expiry),
profile: JSON.stringify(contactFrom),
sentTo: contactFrom?.nip05 ?? contactFrom?.name, // payee
sentFrom: contactTo.nip05 ?? contactTo.name // payer
})
if(contactFrom) _sendIncomingInvoiceNotification(amount, 'sat', contactFrom)
return {
mintUrl: '',
taskFunction: HANDLE_RECEIVED_EVENT_TASK,
message: 'Incoming Lightning payment request been received.',
proofsCount: 0,
proofsAmount: amount
} as WalletTaskResult
}
//
// Receive cashu payment requet to pay
//
else if (incoming.type === IncomingDataType.CASHU_PAYMENT_REQUEST) {
// receiver is current wallet profile
const {
pubkey,
npub,
name,
picture,
} = walletProfileStore
const contactTo: Contact = {
pubkey,
npub,
name,
picture
}
const decoded = decodePaymentRequest(incoming.encoded)
// do not create draft transaction for amount-less pr
if(!decoded.amount || !decoded.unit) {
let message = 'Cashu payment request is missing amount or unit.'
throw new AppError(Err.VALIDATION_ERROR, message, {decoded})
}
// create draft transaction
const {amount, unit, description, id, mints} = decoded
const availableBalances: MintBalance[] = []
if (mints && mints.length > 0) {
for (const mint of mints) {
if (mintsStore.mintExists(mint)) {
const mintBalance = proofsStore.getMintBalance(mint)
if(mintBalance) {
availableBalances.push(mintBalance)
}
}
}
} else {
const mintBalance = proofsStore.getMintBalanceWithMaxBalance(unit as MintUnit)
if(!mintBalance) {
let message = 'Wallet does not have any mint with this unit.'
throw new AppError(Err.VALIDATION_ERROR, message, {decoded})
}
availableBalances.push(mintBalance)
}
if(availableBalances.length === 0) {
let message = 'Wallet does not have any of the mints accepted by Cashu payment request.'
throw new AppError(Err.VALIDATION_ERROR, message, {decoded})
}
const transactionData: TransactionData[] = [{
status: TransactionStatus.DRAFT,
mintBalanceToSendFrom: availableBalances[0],
amountToSend: amount,
unit,
createdAt: new Date(),
}]
const newTransaction = {
type: TransactionType.SEND,
amount,
fee: 0,
unit: unit as MintUnit,
data: JSON.stringify(transactionData),
memo: description,
mint: availableBalances[0].mintUrl,
status: TransactionStatus.DRAFT,
}
const transaction = await transactionsStore.addTransaction(newTransaction)
// TODO make single insert
transaction.update({
paymentId: id,
paymentRequest: incoming.encoded,
profile: JSON.stringify(contactFrom),
sentTo: contactFrom?.nip05 ?? contactFrom?.name, // payee
sentFrom: contactTo.nip05 ?? contactTo.name // payer
})
if(contactFrom) _sendIncomingInvoiceNotification(amount, unit as MintUnit, contactFrom)
return {
mintUrl: '',
taskFunction: HANDLE_RECEIVED_EVENT_TASK,
message: 'Incoming Cashu payment request been received.',
proofsCount: 0,
proofsAmount: amount
} as WalletTaskResult
}
//
// Receive ecash from paid cashu payment requet
//
else if(incoming.type === IncomingDataType.CASHU_PAYMENT_REQUEST_PAYLOAD) {
const decoded: PaymentRequestPayload = JSON.parse(incoming.encoded)
log.trace('[handleReceivedEventTask]', 'Decoded payment request payload', {decoded})
const {transaction, receivedAmount, message} = await receiveByCashuPaymentRequestTask(
decoded
)
// store contact or zapseder in tx details
if(transaction && sentFrom) {
if (contactFrom) {
transaction.update({profile: JSON.stringify(contactFrom), sentFrom})
}
transaction.update({paymentId: decoded.id})
// We do it defensively only after cash is received
// and asynchronously so we speed up queue
_sendReceiveNotification(
receivedAmount,
transaction.unit,
false,
sentFrom,
sentFromPicture
) // TODO move to task result handler
}
return {
mintUrl: decoded.mint,
taskFunction: HANDLE_RECEIVED_EVENT_TASK,
message,
proofsCount: decoded.proofs.length,
proofsAmount: receivedAmount,
transaction,
} as WalletTaskResult
}
else if (incoming.type === IncomingDataType.LNURL) {
throw new AppError(Err.NOTFOUND_ERROR, 'LNURL support is not yet implemented.', {caller: HANDLE_RECEIVED_EVENT_TASK})
} else {
throw new AppError(Err.NOTFOUND_ERROR, 'Received unknown event message', {caller: HANDLE_RECEIVED_EVENT_TASK,incoming})
}
} catch (e: any) {
return {
taskFunction: HANDLE_RECEIVED_EVENT_TASK,
message: e.message,
error: WalletUtils.formatError(e)
} as WalletTaskResult
}
}
const _sendReceiveNotification = async function (
receivedAmount: number,
unit: MintUnit,
isZap: boolean,
sentFrom: string,
sentFromPicture?: string
): Promise<void> {
const getNotificationContent = (
amount: number,
currency: CurrencyCode,
zap: boolean,
sender: string
): { title: string; body: string } => {
const title = Platform.OS === 'android'
? `<b>⚡${formatCurrency(amount, currency)} ${currency}</b> received!`
: `⚡${formatCurrency(amount, currency)} ${currency} received!`
const body = Platform.OS === 'android'
? `${zap ? 'Zap' : 'Ecash'} from <b>${sender || 'unknown payer'}</b> is now in your wallet.`
: `${zap ? 'Zap' : 'Ecash'} from ${sender || 'unknown payer'} is now in your wallet.`
return { title, body };
}
const enabled = await NotificationService.areNotificationsEnabled()
if(!enabled) {
return
}
//
// Send notification event
//
const currencyCode = getCurrency(unit).code
if(receivedAmount && receivedAmount > 0) {
const { title, body } = getNotificationContent(receivedAmount, currencyCode, isZap, sentFrom);
await NotificationService.createLocalNotification(title, body, sentFromPicture);
}
return
}
const _sendErrorReceiveNotification = async function (
amountToReceive: number,
unit: MintUnit,
mint: string
): Promise<void> {
const getNotificationContent = (
amount: number,
currency: CurrencyCode,
): { title: string; body: string } => {
const title = Platform.OS === 'android'
? `<b>Received ${formatCurrency(amount, currency)} ${currency} ecash token from unknonw mint!</b>`
: `Received ${formatCurrency(amount, currency)} ${currency} ecash token from unknonw mint!`
const body = Platform.OS === 'android'
? `Add <b>${mint}</b> to your wallet first to receive ecash over the Nostr network.`
: `Add ${mint} to your wallet first to receive ecash over the Nostr network.`
return { title, body };
}
const enabled = await NotificationService.areNotificationsEnabled()
if(!enabled) {
return
}
//
// Send notification event
//
const currencyCode = getCurrency(unit).code
if(amountToReceive && amountToReceive > 0) {
const { title, body } = getNotificationContent(amountToReceive, currencyCode);
await NotificationService.createLocalNotification(title, body);
}
return
}
const _sendIncomingInvoiceNotification = async function (amount: number, unit: MintUnit, from: Contact) {
await NotificationService.createLocalNotification(
Platform.OS === 'android' ? `⚡ Please pay <b>${formatCurrency(amount, getCurrency(unit).code)} ${getCurrency(unit).code}</b>!` : `⚡ Please pay ${formatCurrency(amount, getCurrency(unit).code)} ${getCurrency(unit).code}!`,
`${from.nip05 || 'Unknown'} has sent you a request to pay.`,
from.picture,
)
}
const _extractZapSenderData = function (str: string) {
const match = str.match(/\{[^}]*\}/);
return match ? match[0] : null;
}
const testQueue = async () => {
const now = new Date().getTime()
SyncQueue.addTask(
`testTask-${now}`,
async () => {
await new Promise((res) => setTimeout(res, 10 * 1000))
return `testTask-${now} result`
}
)
};
export const WalletTask: WalletTaskService = {
syncStateWithAllMintsQueue,
syncStateWithAllMintsQueueAwaitable,
syncStateWithMintQueue,
syncStateWithMintQueueAwaitable,
syncStateWithMintTask,
handleInFlightQueue,
handlePendingQueue,
handleClaimQueue,
handleNwcRequestQueue,
receiveEventsFromRelaysQueue,
receiveQueue,
receiveQueueAwaitable,
receiveOfflinePrepareQueue,
receiveOfflinePrepareQueueAwaitable,
receiveOfflineCompleteQueue,
receiveOfflineCompleteQueueAwaitable,
sendQueue,
sendQueueAwaitable,
swapAllQueue,
transferQueue,
transferQueueAwaitable,
topupQueue,
topupQueueAwaitable,
cashuPaymentRequestQueue,
cashuPaymentRequestQueueAwaitable,
revertQueue,
revertQueueAwaitable,
testQueue,
recoverMintQuote,
recoverMeltQuoteChange,
}