mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-08-12 09:23:20 +00:00
2732 lines
102 KiB
TypeScript
2732 lines
102 KiB
TypeScript
import {addSeconds, isBefore} from 'date-fns'
|
|
import {getSnapshot} from 'mobx-state-tree'
|
|
import notifee, { AndroidImportance } from '@notifee/react-native'
|
|
import { GiftWrap, EncryptedDirectMessage } from 'nostr-tools/kinds'
|
|
import {log} from './logService'
|
|
import {Proof} from '../models/Proof'
|
|
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, MINIBITS_SERVER_API_HOST, 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, 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'
|
|
import { Database } from './sqlite'
|
|
|
|
/**
|
|
* 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>
|
|
syncStateWithMintQueue: (
|
|
options: {
|
|
proofsToSync: Proof[],
|
|
mintUrl: string,
|
|
isPending: boolean
|
|
}
|
|
) => Promise<void>
|
|
syncStateWithMintTask: (
|
|
options: {
|
|
proofsToSync: Proof[],
|
|
mintUrl: string,
|
|
isPending: boolean
|
|
}
|
|
) => Promise<SyncStateTaskResult | void>
|
|
handleInFlightQueue: () => Promise<void>
|
|
handlePendingQueue: () => Promise<void>
|
|
handlePendingTopupQueue: (params: {
|
|
transaction: Transaction
|
|
}) => 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>
|
|
receiveQueue: (
|
|
token: Token,
|
|
amountToReceive: number,
|
|
memo: string,
|
|
encodedToken: string,
|
|
) => Promise<void>
|
|
receiveOfflinePrepareQueue: (
|
|
token: Token,
|
|
amountToReceive: number,
|
|
memo: string,
|
|
encodedToken: string,
|
|
) => Promise<void>
|
|
receiveOfflineCompleteQueue: (
|
|
transactionId: number
|
|
) => Promise<void>
|
|
sendQueue: (
|
|
mintBalanceToSendFrom: MintBalance,
|
|
amountToSend: number,
|
|
unit: MintUnit,
|
|
memo: string,
|
|
selectedProofs: Proof[],
|
|
p2pk?: { pubkey: string; locktime?: number; refundKeys?: Array<string> },
|
|
draftTransactionId?: number
|
|
) => Promise<void>
|
|
swapAllQueue: () => Promise<void>
|
|
topupQueue: (
|
|
mintBalanceToTopup: MintBalance,
|
|
amountToTopup: number,
|
|
unit: MintUnit,
|
|
memo: string,
|
|
contactToSendTo?: Contact,
|
|
nwcEvent?: NostrEvent
|
|
) => Promise<void>
|
|
cashuPaymentRequestQueue: (
|
|
mintBalanceToReceiveTo: MintBalance,
|
|
amountToRequest: number,
|
|
unit: MintUnit,
|
|
memo: string,
|
|
) => Promise<void>
|
|
revertQueue: (
|
|
transaction: Transaction
|
|
) => Promise<void>
|
|
testQueue: () => Promise<void>
|
|
recoverMintQuote: (params: {
|
|
mintUrl: string,
|
|
mintQuote: string
|
|
}) => Promise<{recoveredAmount: number}>
|
|
recoverMeltQuoteChange: (params: {
|
|
mintUrl: string,
|
|
meltQuote: string
|
|
}) => 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 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
|
|
}
|
|
|
|
/*
|
|
* 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 receiveOfflineCompleteQueue = async function (
|
|
transactionId: number
|
|
): Promise<void> {
|
|
const now = new Date().getTime()
|
|
SyncQueue.addTask(
|
|
`receiveOfflineCompleteTask-${now}`,
|
|
async () => await receiveOfflineCompleteTask(
|
|
transactionId
|
|
)
|
|
)
|
|
return
|
|
}
|
|
|
|
|
|
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
|
|
}
|
|
|
|
|
|
/*
|
|
* 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 = MAX_SWAP_INPUT_SIZE
|
|
|
|
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
|
|
)
|
|
|
|
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 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 revertQueue = async function (
|
|
transaction: Transaction
|
|
): Promise<void> {
|
|
const now = new Date().getTime()
|
|
SyncQueue.addPrioritizedTask(
|
|
`revertTask-${now}`,
|
|
async () => await revertTask(transaction)
|
|
)
|
|
return
|
|
}
|
|
|
|
|
|
|
|
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 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})
|
|
)
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
* Checks with the mint whether proofs have been spent / pending by mint.
|
|
* Under normal wallet operations, it is used to check pending proofs that were sent to the payee.
|
|
*
|
|
* However it is used as well as a recovery process to remove spent proofs from the spendable balance itself.
|
|
* This situation occurs as a result of broken wallet state and causes failure of
|
|
* subsequent transactions because mint returns "Tokens already spent" if any spent proof is used as an input.
|
|
*
|
|
* It is a task function that should always be added to SyncQueue and not called directly
|
|
*
|
|
* @mintUrl URL of the mint to check for spent and pending proofs
|
|
* @isPending whether the proofs come from spendable or pending state by the wallet
|
|
* @proofsToSync proofs to check the spent / pending status
|
|
* @returns WalletTaskResult
|
|
*/
|
|
|
|
const syncStateWithMintTask = async function (
|
|
options: {
|
|
proofsToSync: Proof[],
|
|
mintUrl: string,
|
|
isPending: boolean
|
|
}
|
|
): Promise<SyncStateTaskResult> {
|
|
|
|
log.trace('[syncStateWithMintTask] start', {mintUrl: options.mintUrl})
|
|
|
|
const transactionStateUpdates: TransactionStateUpdate[] = []
|
|
const completedTransactionIds: number[] = []
|
|
const errorTransactionIds: number[] = []
|
|
const pendingTransactionIds: number[] = []
|
|
const revertedTransactionIds: number[] = []
|
|
|
|
const {proofsToSync, mintUrl, isPending} = options
|
|
const mint = mintsStore.findByUrl(mintUrl as string)
|
|
|
|
try {
|
|
|
|
if (proofsToSync.length === 0) {
|
|
const message = `No ${isPending ? 'pending' : ''} proofs found for mint, skipping mint call...`
|
|
log.trace('[syncStateWithMintTask]', message, mintUrl)
|
|
|
|
return {
|
|
taskFunction: SYNC_STATE_WITH_MINT_TASK,
|
|
mintUrl,
|
|
message,
|
|
transactionStateUpdates: [],
|
|
completedTransactionIds: [],
|
|
errorTransactionIds: [],
|
|
revertedTransactionIds: []
|
|
} as SyncStateTaskResult
|
|
}
|
|
|
|
const proofsByState = await walletStore.getProofsStatesFromMint(
|
|
mintUrl,
|
|
mint && mint.units ? mint.units[0] : 'sat', // likely not to be unit-dependent
|
|
proofsToSync
|
|
)
|
|
|
|
if(mint) {
|
|
mint.setStatus(MintStatus.ONLINE)
|
|
}
|
|
|
|
const spentByMintAmount = CashuUtils.getProofsAmount(proofsByState.SPENT)
|
|
const pendingByMintAmount = CashuUtils.getProofsAmount(proofsByState.PENDING)
|
|
const unspentByMintAmount = CashuUtils.getProofsAmount(proofsByState.UNSPENT)
|
|
|
|
log.debug('[syncStateWithMintTask]', `${isPending ? 'Pending' : ''} spent and pending by mint amounts`, {
|
|
spentByMintAmount,
|
|
pendingByMintAmount,
|
|
unspentByMintAmount,
|
|
isPending
|
|
})
|
|
|
|
// 1. Complete transactions with their proofs becoming spent by mint
|
|
if (proofsByState.SPENT.length > 0) {
|
|
|
|
// Remove spent proofs model instances
|
|
proofsStore.removeProofs(proofsByState.SPENT as Proof[], isPending)
|
|
|
|
// Clean pendingByMint secrets from state if proofs came back as spent by mint
|
|
if(proofsStore.pendingByMintSecrets.length > 0) {
|
|
const spentByMintSecrets = new Set(proofsByState.SPENT.map(proof => proof.secret))
|
|
const tobeRemoved = proofsStore.pendingByMintSecrets.filter(secret => spentByMintSecrets.has(secret))
|
|
|
|
if(tobeRemoved.length > 0) {
|
|
proofsStore.removeManyFromPendingByMint(tobeRemoved)
|
|
}
|
|
}
|
|
|
|
// Map to track spent amounts by transaction ID
|
|
const transactionStateMap: { [key: number]: number } = {}
|
|
|
|
for (const spent of proofsByState.SPENT) {
|
|
// Find the matching proof
|
|
const spentProof = proofsToSync.find(proof => spent.secret === proof.secret)
|
|
|
|
if (spentProof) {
|
|
// Get the transaction ID (tId) from the matching proof
|
|
const tId = spentProof.tId
|
|
|
|
// Accumulate the spent amount for this transaction ID
|
|
if (!transactionStateMap[tId]) {
|
|
transactionStateMap[tId] = 0
|
|
}
|
|
transactionStateMap[tId] += spentProof.amount
|
|
}
|
|
}
|
|
|
|
// Convert the transactionStateMap to an array of transactionStateUpdate objects
|
|
const spentStateUpdates = Object.entries(transactionStateMap).map(([tId, spentByMintTxAmount]) => {
|
|
const tx = transactionsStore.findById(Number(tId))
|
|
|
|
if (tx) {
|
|
// spent amount does not cover matched tx amount
|
|
// means that some spent proofs were used as inputs into the swap / melt
|
|
if(spentByMintTxAmount < tx.amount) {
|
|
|
|
errorTransactionIds.push(Number(tId))
|
|
|
|
// return unspent proofs from pending back to spendable
|
|
if(isPending) {
|
|
const unspentProofs = proofsToSync.filter(proof =>
|
|
proofsByState.SPENT.find(spent => spent.secret !== proof.secret)
|
|
)
|
|
|
|
if(unspentProofs.length > 0) {
|
|
log.trace('[syncStateWithMintTask]', `Moving ${unspentProofs.length} unspent proofs from pending back to spendable.`)
|
|
// remove it from pending proofs in the wallet
|
|
proofsStore.removeProofs(unspentProofs, true, true)
|
|
// add proofs back to the spendable wallet
|
|
proofsStore.addProofs(unspentProofs)
|
|
}
|
|
}
|
|
|
|
return {
|
|
tId: Number(tId),
|
|
amount: tx.amount,
|
|
spentByMintAmount: spentByMintTxAmount as number,
|
|
message: 'Some spent ecash has been used as an input for this transaction.',
|
|
updatedStatus: TransactionStatus.ERROR
|
|
} as TransactionStateUpdate
|
|
|
|
} else {
|
|
|
|
completedTransactionIds.push(Number(tId))
|
|
|
|
return {
|
|
tId: Number(tId),
|
|
amount: tx.amount,
|
|
spentByMintAmount: spentByMintTxAmount as number,
|
|
meltQuoteToRecover: tx.type === TransactionType.TRANSFER && tx.quote.length > 0 ? tx.quote : null,
|
|
updatedStatus: TransactionStatus.COMPLETED
|
|
} as TransactionStateUpdate
|
|
}
|
|
}
|
|
|
|
return {
|
|
tId: Number(tId),
|
|
updatedStatus: TransactionStatus.ERROR,
|
|
message: 'Could not find transaction in the database.'
|
|
} as TransactionStateUpdate
|
|
})
|
|
|
|
log.trace('[syncStateWithMintTask]', {spentStateUpdates})
|
|
|
|
transactionStateUpdates.push(...spentStateUpdates)
|
|
|
|
// Recover melt quote change for long pending, now completed transactions
|
|
for (const update of spentStateUpdates) {
|
|
if(update.meltQuoteToRecover) {
|
|
|
|
const {recoveredAmount} = await recoverMeltQuoteChange({
|
|
mintUrl,
|
|
meltQuote: update.meltQuoteToRecover
|
|
})
|
|
update.recoveredChangeAmount = recoveredAmount
|
|
}
|
|
}
|
|
|
|
// Update related transactions statuses
|
|
log.debug('[syncStateWithMintTask]', 'Transaction id(s) to complete', completedTransactionIds.toString())
|
|
|
|
// Complete related transactions
|
|
if (completedTransactionIds.length > 0) {
|
|
const transactionDataUpdate = {
|
|
status: TransactionStatus.COMPLETED,
|
|
spentStateUpdates,
|
|
createdAt: new Date(),
|
|
}
|
|
|
|
await transactionsStore.updateStatuses(
|
|
completedTransactionIds,
|
|
TransactionStatus.COMPLETED,
|
|
JSON.stringify(transactionDataUpdate),
|
|
)
|
|
|
|
stopPolling(`syncStateWithMintPoller-${mintUrl}`)
|
|
}
|
|
|
|
if (errorTransactionIds.length > 0) {
|
|
const transactionDataUpdate = {
|
|
status: TransactionStatus.ERROR,
|
|
spentStateUpdates,
|
|
createdAt: new Date(),
|
|
}
|
|
|
|
await transactionsStore.updateStatuses(
|
|
errorTransactionIds,
|
|
TransactionStatus.ERROR,
|
|
JSON.stringify(transactionDataUpdate),
|
|
)
|
|
|
|
log.error('[syncStateWithMintTask]', `Transaction status update error`, {spentStateUpdates})
|
|
stopPolling(`syncStateWithMintPoller-${mintUrl}`)
|
|
}
|
|
}
|
|
|
|
// 2. Make sure that transactions with proofs pending by mint are pending
|
|
// and that we keep their secrets in pendingByMint state
|
|
if (proofsByState.PENDING.length > 0) {
|
|
|
|
// To prevent multiple pending status updates we select only those
|
|
// pending by mint proofs that the wallet does not track yet
|
|
const newPendingByMintProofs = proofsByState.PENDING.filter(
|
|
proof => !proofsStore.pendingByMintSecrets.includes(proof.secret)
|
|
)
|
|
|
|
if(newPendingByMintProofs.length > 0) {
|
|
// Now we add them to the state
|
|
for (const proof of newPendingByMintProofs) {
|
|
proofsStore.addToPendingByMint(proof as Proof)
|
|
}
|
|
|
|
// Map to track pending amounts by transaction ID
|
|
const transactionStateMap: { [key: number]: number } = {}
|
|
|
|
for (const pending of newPendingByMintProofs) {
|
|
// Find the matching pending proof
|
|
const pendingProof = proofsToSync.find(proof => pending.secret === proof.secret)
|
|
|
|
if (pendingProof) {
|
|
// Get the transaction ID (tId) from the matching proof
|
|
const tId = pendingProof.tId
|
|
|
|
// Add the tId to the list of transactions to complete
|
|
if(!pendingTransactionIds.includes(tId)) {
|
|
pendingTransactionIds.push(tId)
|
|
}
|
|
|
|
// Accumulate the pending amount for this transaction ID
|
|
if (!transactionStateMap[tId]) {
|
|
transactionStateMap[tId] = 0
|
|
}
|
|
transactionStateMap[tId] += pendingProof.amount
|
|
}
|
|
}
|
|
|
|
// Convert the transactionStateMap to an array of transactionStateUpdate objects
|
|
const pendingStateUpdates = Object.entries(transactionStateMap).map(([tId, pendingByMintAmount]) => ({
|
|
tId: Number(tId),
|
|
pendingByMintAmount: pendingByMintAmount as number,
|
|
updatedStatus: TransactionStatus.PENDING
|
|
} as TransactionStateUpdate))
|
|
|
|
log.trace('[syncStateWithMintTask]', {pendingStateUpdates})
|
|
|
|
transactionStateUpdates.push(...pendingStateUpdates)
|
|
|
|
// If we somehow found new pending by mint proofs inside spendable balance during cleanup from spent, move them to pending
|
|
if(!isPending) {
|
|
// remove it from spendable proofs in the wallet
|
|
proofsStore.removeProofs(newPendingByMintProofs as Proof[], false) // we clean spendable balance
|
|
// add proofs to the pending wallet
|
|
proofsStore.addProofs(newPendingByMintProofs as Proof[], true)
|
|
}
|
|
|
|
// Update related transactions statuses
|
|
log.debug('[syncStateWithMintTask]', 'Transaction id(s) to be pending', pendingTransactionIds.toString())
|
|
|
|
// Keep or make related transactions pending (mostly timed-out transfers from PREPARED status)
|
|
if (pendingTransactionIds.length > 0) {
|
|
const transactionDataUpdate = {
|
|
status: TransactionStatus.PENDING,
|
|
pendingStateUpdates,
|
|
message: 'Ecash has been moved to pending while the mint waits for related lightning payment to settle.',
|
|
createdAt: new Date(),
|
|
}
|
|
|
|
await transactionsStore.updateStatuses(
|
|
pendingTransactionIds,
|
|
TransactionStatus.PENDING,
|
|
JSON.stringify(transactionDataUpdate),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Revert transactions that were pending by mint back to spendable if they
|
|
// are not pending anymore nor were spent - thus lightning payment failed
|
|
|
|
const remainingSecrets: string[] = getSnapshot(proofsStore.pendingByMintSecrets)
|
|
log.trace('[syncStateWithMintTask]', 'Remaining pendingByMintSecrets', remainingSecrets)
|
|
|
|
if(remainingSecrets.length > 0) {
|
|
let secretsTobeMovedToSpendable: string[] = []
|
|
|
|
if(proofsByState.PENDING.length > 0) {
|
|
// Filter remainingSecrets to get those that do not exist in pendingByMintProofs
|
|
const pendingByMintSecrets = new Set(proofsByState.PENDING.map(proof => proof.secret))
|
|
secretsTobeMovedToSpendable = remainingSecrets.filter(secret => !pendingByMintSecrets.has(secret))
|
|
} else {
|
|
secretsTobeMovedToSpendable = remainingSecrets
|
|
}
|
|
|
|
if(secretsTobeMovedToSpendable.length > 0) {
|
|
log.debug('[syncStateWithMintTask]', 'Moving proofs from pendingByMint to spendable', {secretsTobeMovedToSpendable})
|
|
|
|
// Map to track reverted amounts by transaction ID
|
|
const transactionStateMap: { [key: number]: number } = {}
|
|
const proofsToBeMovedToSpendable: Proof[] = []
|
|
|
|
for (const secret of secretsTobeMovedToSpendable) {
|
|
// Find the matching proof in the MST state by the secret
|
|
const revertedProof = proofsStore.getBySecret(secret, true) // only pending
|
|
|
|
if (revertedProof) {
|
|
// Get the transaction ID (tId) from the matching proof
|
|
const tId = revertedProof.tId
|
|
|
|
// Add the tId to the list of transactions to complete
|
|
if(!revertedTransactionIds.includes(tId)) {
|
|
revertedTransactionIds.push(tId)
|
|
}
|
|
|
|
// Accumulate the spent amount for this transaction ID
|
|
if (!transactionStateMap[tId]) {
|
|
transactionStateMap[tId] = 0
|
|
}
|
|
transactionStateMap[tId] += revertedProof.amount
|
|
proofsToBeMovedToSpendable.push(revertedProof)
|
|
}
|
|
}
|
|
|
|
// Convert the transactionStateMap to an array of transactionStateUpdate objects
|
|
const revertedStateUpdates = Object.entries(transactionStateMap).map(([tId, revertedByMintAmount]) => ({
|
|
tId: Number(tId),
|
|
revertedByMintAmount: revertedByMintAmount as number,
|
|
updatedStatus: TransactionStatus.REVERTED
|
|
} as TransactionStateUpdate))
|
|
|
|
log.trace('[syncStateWithMintTask]', {revertedStateUpdates})
|
|
|
|
transactionStateUpdates.push(...revertedStateUpdates)
|
|
|
|
if(proofsToBeMovedToSpendable.length > 0) {
|
|
// remove it from pending proofs in the wallet
|
|
proofsStore.removeProofs(proofsToBeMovedToSpendable, true, true)
|
|
// add proofs back to the spendable wallet
|
|
proofsStore.addProofs(proofsToBeMovedToSpendable)
|
|
}
|
|
|
|
if(revertedTransactionIds.length > 0) {
|
|
const transactionDataUpdate = {
|
|
status: TransactionStatus.REVERTED,
|
|
revertedStateUpdates,
|
|
message: 'Ecash has been returned to spendable balance.',
|
|
createdAt: new Date(),
|
|
}
|
|
|
|
await transactionsStore.updateStatuses(
|
|
revertedTransactionIds,
|
|
TransactionStatus.REVERTED,
|
|
JSON.stringify(transactionDataUpdate),
|
|
)
|
|
}
|
|
|
|
// remove handled secrets from pendingByMint state
|
|
proofsStore.removeManyFromPendingByMint(secretsTobeMovedToSpendable)
|
|
}
|
|
}
|
|
|
|
return {
|
|
taskFunction: SYNC_STATE_WITH_MINT_TASK,
|
|
mintUrl,
|
|
message: `Completed sync for ${isPending ? 'pending' : ''} proofs with the mint`,
|
|
transactionStateUpdates,
|
|
completedTransactionIds,
|
|
errorTransactionIds,
|
|
pendingTransactionIds,
|
|
revertedTransactionIds
|
|
} as SyncStateTaskResult
|
|
} catch(e: any) {
|
|
// silent
|
|
log.error('[syncStateWithMintTask]', e.name, {message: e.message, mintUrl})
|
|
|
|
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 for ${isPending ? 'pending' : ''} proofs with the mint ended with error: ${e.message}`,
|
|
error: e,
|
|
transactionStateUpdates,
|
|
completedTransactionIds,
|
|
errorTransactionIds,
|
|
pendingTransactionIds,
|
|
revertedTransactionIds
|
|
} as SyncStateTaskResult
|
|
}
|
|
}
|
|
|
|
|
|
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 that were issued by mint, but wallet failed to receive them if swap did not complete.
|
|
*/
|
|
const handleInFlightByMintTask = async function (mint: Mint): Promise<WalletTaskResult> {
|
|
|
|
const mintUrl = mint.mintUrl
|
|
const inFlightCounters = mint.proofsCountersWithInFlightRequests
|
|
|
|
log.trace('[handleInFlightByMintTask]', {mintUrl: mint.mintUrl, inFlightCounters})
|
|
|
|
const allInFlightRequestLength = mint.allInFLightRequests?.length
|
|
const errors: string[] = []
|
|
|
|
if(inFlightCounters && inFlightCounters.length > 0) {
|
|
|
|
for(const counter of inFlightCounters) {
|
|
|
|
for(const inFlight of counter.inFlightRequests) {
|
|
|
|
const transaction = transactionsStore.findById(inFlight.transactionId)
|
|
|
|
if(!transaction) {
|
|
counter.removeInFlightRequest(inFlight.transactionId)
|
|
continue
|
|
}
|
|
|
|
let transactionData = []
|
|
|
|
try {
|
|
transactionData = JSON.parse(transaction.data)
|
|
} catch (e) {}
|
|
|
|
const {mint, unit} = transaction
|
|
|
|
switch(transaction.type) {
|
|
case TransactionType.RECEIVE:
|
|
try {
|
|
|
|
const {proofs, swapFeePaid} = await walletStore.receive(
|
|
mint,
|
|
unit,
|
|
inFlight.request.token,
|
|
transaction.id,
|
|
{
|
|
inFlightRequest: inFlight
|
|
}
|
|
)
|
|
|
|
const { addedAmount: receivedAmount } = WalletUtils.addCashuProofs(
|
|
mint,
|
|
proofs,
|
|
{
|
|
unit,
|
|
transactionId: transaction.id,
|
|
isPending: false
|
|
}
|
|
)
|
|
|
|
if(proofs.length > 0 && receivedAmount === 0) {
|
|
throw new AppError(Err.WALLET_ERROR,
|
|
'Received proofs could not be stored into the wallet, most likely are already there.',
|
|
{
|
|
caller: HANDLE_INFLIGHT_BY_MINT_TASK,
|
|
numberOfProofs: proofs.length
|
|
}
|
|
)
|
|
}
|
|
|
|
const outputToken = getEncodedToken({
|
|
mint,
|
|
proofs,
|
|
unit
|
|
})
|
|
|
|
// Update tx amount if full amount was not received
|
|
if (receivedAmount !== transaction.amount) {
|
|
transaction.update({amount: receivedAmount})
|
|
}
|
|
|
|
transactionData.push({
|
|
status: TransactionStatus.COMPLETED,
|
|
receivedAmount,
|
|
swapFeePaid,
|
|
unit,
|
|
createdAt: new Date(),
|
|
})
|
|
|
|
transaction.update({
|
|
status: TransactionStatus.COMPLETED,
|
|
data: JSON.stringify(transactionData),
|
|
})
|
|
|
|
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance!
|
|
transaction.update({balanceAfter, outputToken})
|
|
|
|
if(swapFeePaid > 0) {
|
|
transaction.update({fee: swapFeePaid})
|
|
}
|
|
break
|
|
|
|
} catch (e: any) {
|
|
log.error('[handleInFlightByMintTask] Receive', e.name, e.message)
|
|
errors.push(e.message)
|
|
break
|
|
}
|
|
|
|
|
|
case TransactionType.SEND:
|
|
try {
|
|
const {
|
|
returnedProofs,
|
|
proofsToSend,
|
|
swapFeePaid
|
|
} = await walletStore.send(
|
|
mintUrl,
|
|
inFlight.request.amount,
|
|
unit,
|
|
inFlight.request.proofs,
|
|
transaction.id,
|
|
{
|
|
inFlightRequest: inFlight,
|
|
p2pk: undefined
|
|
}
|
|
)
|
|
|
|
WalletUtils.addCashuProofs(
|
|
mintUrl,
|
|
returnedProofs,
|
|
{
|
|
unit,
|
|
transactionId: transaction.id,
|
|
isPending: false
|
|
}
|
|
)
|
|
|
|
// remove used proofs and move sent proofs to pending
|
|
proofsStore.removeProofs(inFlight.request.proofs)
|
|
|
|
const { addedAmount: sentAmount } = WalletUtils.addCashuProofs(
|
|
mintUrl,
|
|
proofsToSend,
|
|
{
|
|
unit,
|
|
transactionId: transaction.id,
|
|
isPending: true
|
|
}
|
|
)
|
|
|
|
if(proofsToSend.length > 0 && sentAmount === 0) {
|
|
throw new AppError(Err.WALLET_ERROR,
|
|
'Sent proofs could not be moved to pending, most likely are already there.',
|
|
{
|
|
caller: HANDLE_INFLIGHT_BY_MINT_TASK,
|
|
numberOfProofs: proofsToSend.length
|
|
}
|
|
)
|
|
}
|
|
|
|
const outputToken = getEncodedToken({
|
|
mint: mintUrl,
|
|
proofs: proofsToSend,
|
|
unit,
|
|
})
|
|
|
|
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance!
|
|
|
|
transactionData.push({
|
|
status: TransactionStatus.PENDING,
|
|
createdAt: new Date(),
|
|
})
|
|
|
|
transaction.update({
|
|
status: TransactionStatus.PENDING,
|
|
data: JSON.stringify(transactionData),
|
|
outputToken,
|
|
balanceAfter
|
|
})
|
|
|
|
if(swapFeePaid > 0) {
|
|
transaction.update({fee: swapFeePaid})
|
|
}
|
|
break
|
|
|
|
} catch (e: any) {
|
|
log.error('[handleInFlightByMintTask] Send', e.name, e.message)
|
|
errors.push(e.message)
|
|
break
|
|
}
|
|
|
|
case TransactionType.TOPUP:
|
|
try {
|
|
const proofs = await walletStore.mintProofs(
|
|
mintUrl,
|
|
inFlight.request.amount,
|
|
unit,
|
|
inFlight.request.quote,
|
|
transaction.id,
|
|
{
|
|
inFlightRequest: inFlight
|
|
}
|
|
)
|
|
|
|
const {addedAmount: mintedAmount} = WalletUtils.addCashuProofs(
|
|
mintUrl as string,
|
|
proofs,
|
|
{
|
|
unit,
|
|
transactionId: transaction.id,
|
|
isPending: false
|
|
}
|
|
)
|
|
|
|
if(proofs.length > 0 && mintedAmount === 0) {
|
|
throw new AppError(Err.WALLET_ERROR,
|
|
'Minted proofs could not be stored into the wallet, most likely are already there.',
|
|
{
|
|
caller: HANDLE_INFLIGHT_BY_MINT_TASK,
|
|
numberOfProofs: proofs.length
|
|
}
|
|
)
|
|
}
|
|
|
|
stopPolling(`handlePendingTopupPoller-${transaction.paymentId}`)
|
|
|
|
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance!
|
|
|
|
transactionData.push({
|
|
status: TransactionStatus.COMPLETED,
|
|
createdAt: new Date(),
|
|
})
|
|
|
|
transaction.update({
|
|
status: TransactionStatus.COMPLETED,
|
|
data: JSON.stringify(transactionData),
|
|
balanceAfter
|
|
})
|
|
|
|
break
|
|
|
|
} catch(e: any) {
|
|
log.error('[handleInFlightByMintTask] Topup', e.name, e.message)
|
|
errors.push(e.message)
|
|
break
|
|
}
|
|
|
|
|
|
case TransactionType.TRANSFER:
|
|
try {
|
|
|
|
const {quote, change} = await walletStore.payLightningMelt(
|
|
mintUrl,
|
|
unit,
|
|
inFlight.request.meltQuote,
|
|
inFlight.request.proofsToSend,
|
|
transaction.id,
|
|
{
|
|
inFlightRequest: inFlight
|
|
}
|
|
)
|
|
|
|
// Spend pending proofs that were used to settle the lightning invoice
|
|
proofsStore.removeProofs(inFlight.request.proofsToSend as Proof[], true, false)
|
|
|
|
// Save preimage asap
|
|
if(quote.payment_preimage) {
|
|
transaction.update({proof: quote.payment_preimage})
|
|
}
|
|
|
|
const proofsToMeltFromAmount = CashuUtils.getProofsAmount(inFlight.request.proofsToSend)
|
|
|
|
let totalFeePaid = proofsToMeltFromAmount - transaction.amount
|
|
let returnedAmount = CashuUtils.getProofsAmount(change)
|
|
|
|
if(change.length > 0) {
|
|
const {addedAmount: changeAmount} = WalletUtils.addCashuProofs(
|
|
mintUrl,
|
|
change,
|
|
{
|
|
unit,
|
|
transactionId: transaction.id,
|
|
isPending: false
|
|
}
|
|
)
|
|
|
|
if(changeAmount === 0) {
|
|
throw new AppError(Err.WALLET_ERROR,
|
|
'Proofs returned as a change could not be stored into the wallet, most likely are already there.',
|
|
{
|
|
caller: 'handleInFlightByMintTask',
|
|
numberOfProofs: change.length
|
|
}
|
|
)
|
|
}
|
|
|
|
const outputToken = getEncodedToken({
|
|
mint: mintUrl,
|
|
proofs: change,
|
|
unit,
|
|
})
|
|
|
|
transaction.update({outputToken})
|
|
|
|
totalFeePaid = totalFeePaid - returnedAmount
|
|
}
|
|
|
|
// Save final fee in db
|
|
if(totalFeePaid !== transaction.fee) {
|
|
transaction.update({fee: totalFeePaid})
|
|
}
|
|
|
|
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance!
|
|
|
|
transactionData.push({
|
|
status: TransactionStatus.COMPLETED,
|
|
returnedAmount,
|
|
preimage: quote.payment_preimage,
|
|
createdAt: new Date(),
|
|
})
|
|
|
|
transaction.update({
|
|
status: TransactionStatus.COMPLETED,
|
|
data: JSON.stringify(transactionData),
|
|
balanceAfter
|
|
})
|
|
|
|
break
|
|
|
|
} catch(e: any) {
|
|
log.error('[handleInFlightByMintTask] Topup', e.name, e.message)
|
|
errors.push(e.message)
|
|
break
|
|
}
|
|
default:
|
|
log.error('[handleInFlightByMintTask] Unknown transaction type', {type: transaction.type})
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
taskFunction: HANDLE_INFLIGHT_BY_MINT_TASK,
|
|
mintUrl,
|
|
errors,
|
|
message: `${allInFlightRequestLength} inFlight requests were resent and processed with ${errors.length} errors.`,
|
|
}
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
taskFunction: HANDLE_INFLIGHT_BY_MINT_TASK,
|
|
mintUrl,
|
|
message: 'No proofCounters with inFlight requests, skipping...',
|
|
} as WalletTaskResult
|
|
}
|
|
|
|
|
|
|
|
const handlePendingQueue = async function (): Promise<void> {
|
|
const pendingTopups: Transaction[] = transactionsStore.getPendingTopups()
|
|
const pendingTransfers: Transaction[] = transactionsStore.getPendingTransfers()
|
|
|
|
log.trace('[handlePendingQueue] start', {pendingTopups, pendingTransfers})
|
|
|
|
// we just expire expired transfers, no ecash ops so no need of queue
|
|
if(pendingTransfers.length > 0) {
|
|
for (const transfer of pendingTransfers) {
|
|
if (isBefore(transfer.expiresAt as Date, new Date())) {
|
|
log.debug('[handlePendingQueue]', `Transfer invoice expired: ${transfer.paymentId} ${transfer.quote}`)
|
|
|
|
const transactionData = JSON.parse(transfer.data)
|
|
transactionData.push({
|
|
status: TransactionStatus.EXPIRED,
|
|
message: 'Invoice expired',
|
|
createdAt: new Date(),
|
|
})
|
|
|
|
transfer.update({
|
|
status: TransactionStatus.EXPIRED,
|
|
data: JSON.stringify(transactionData)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
if (pendingTopups.length === 0) {
|
|
log.trace('[handlePendingQueue]', 'No pending topups - skipping task send to the queue...')
|
|
return
|
|
}
|
|
|
|
for (const topup of pendingTopups) {
|
|
// skip pr if active poller exists
|
|
if(pollerExists(`handlePendingTopupPoller-${topup.paymentId}`)) {
|
|
log.trace('[handlePendingQueue] Skipping check of pendingTopup, poller exists', {paymentHash: topup.paymentId})
|
|
continue
|
|
}
|
|
|
|
const now = new Date().getTime()
|
|
|
|
SyncQueue.addTask(
|
|
`handlePendingTopupTask-${now}`,
|
|
async () => await handlePendingTopupTask({transaction: topup})
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const handlePendingTopupQueue = async function (params: {transaction: Transaction}): Promise<void> {
|
|
const {transaction} = params
|
|
log.trace('[handlePendingTopup] start', {paymentHash: transaction.paymentId, mintQuote: transaction.quote})
|
|
|
|
const now = new Date().getTime()
|
|
|
|
SyncQueue.addTask(
|
|
`_handlePendingTopupTask-${now}`,
|
|
async () => await handlePendingTopupTask({transaction})
|
|
)
|
|
}
|
|
|
|
|
|
|
|
const handlePendingTopupTask = async function (params: {transaction: Transaction}): Promise<WalletTaskResult> {
|
|
const {transaction} = params
|
|
const {
|
|
id: transactionId,
|
|
mint: mintUrl,
|
|
unit,
|
|
amount,
|
|
paymentId: paymentHash,
|
|
quote: mintQuote,
|
|
expiresAt
|
|
} = transaction
|
|
|
|
log.trace('[handlePendingTopupTask] start', transaction)
|
|
|
|
let transactionData = []
|
|
|
|
try {
|
|
transactionData = JSON.parse(transaction.data)
|
|
} catch (e) {}
|
|
|
|
const mintInstance = mintsStore.findByUrl(mintUrl as string)
|
|
|
|
try {
|
|
if(!mintInstance || !mintQuote || !unit || !amount) {
|
|
// Database.deleteTransactionById(transactionId)
|
|
throw new AppError(Err.VALIDATION_ERROR, 'Missing mint, mint quote, unit, amount', {transactionId, caller: 'handlePendingTopupTask'})
|
|
}
|
|
|
|
// check is quote has been paid
|
|
const { state, mintQuote: quote } = await walletStore.checkLightningMintQuote(mintUrl, mintQuote)
|
|
|
|
if (quote !== mintQuote) {
|
|
throw new AppError(Err.VALIDATION_ERROR, 'Returned quote is different then the one requested', {mintUrl, quote, mintQuote})
|
|
}
|
|
|
|
if (isBefore(expiresAt as Date, new Date())) {
|
|
log.debug('[handlePendingTopupTask]', `Invoice expired, removing: ${paymentHash} ${mintQuote}`)
|
|
|
|
transactionData.push({
|
|
status: TransactionStatus.EXPIRED,
|
|
message: 'Invoice expired',
|
|
createdAt: new Date(),
|
|
})
|
|
|
|
transaction.update({
|
|
status: TransactionStatus.EXPIRED,
|
|
data: JSON.stringify(transactionData)
|
|
})
|
|
|
|
|
|
stopPolling(`handlePendingTopupPoller-${paymentHash}`)
|
|
}
|
|
|
|
switch (state) {
|
|
case MintQuoteState.UNPAID:
|
|
log.trace('[handlePendingTopupTask] Quote not paid', {mintUrl, mintQuote})
|
|
|
|
return {
|
|
taskFunction: HANDLE_PENDING_TOPUP_TASK,
|
|
transaction,
|
|
mintUrl,
|
|
unit,
|
|
amount,
|
|
paymentHash,
|
|
message: `Quote ${mintQuote} has not yet been paid.`,
|
|
} as WalletTaskResult
|
|
|
|
/*
|
|
* PAID
|
|
*/
|
|
case MintQuoteState.PAID:
|
|
|
|
let proofs: CashuProof[] = []
|
|
|
|
try {
|
|
proofs = (await walletStore.mintProofs(
|
|
mintUrl,
|
|
amount,
|
|
unit,
|
|
mintQuote,
|
|
transactionId
|
|
))
|
|
} catch (e: any) {
|
|
if(e.message.includes('outputs have already been signed before')) {
|
|
|
|
log.error('[handlePendingTopupTask] Increasing proofsCounter outdated values and repeating mintProofs.')
|
|
|
|
proofs = (await walletStore.mintProofs(
|
|
mintUrl,
|
|
amount,
|
|
unit,
|
|
mintQuote,
|
|
transactionId,
|
|
{increaseCounterBy: 10}
|
|
))
|
|
} else {
|
|
throw e
|
|
}
|
|
}
|
|
|
|
if (!proofs || proofs.length === 0) {
|
|
throw new AppError(Err.VALIDATION_ERROR, 'Mint did not return any proofs.')
|
|
}
|
|
|
|
// we got proofs, accept to the wallet asap
|
|
WalletUtils.addCashuProofs(
|
|
mintUrl,
|
|
proofs,
|
|
{
|
|
unit,
|
|
transactionId,
|
|
isPending: false
|
|
}
|
|
)
|
|
|
|
stopPolling(`handlePendingTopupPoller-${paymentHash}`)
|
|
|
|
const currencyCode = getCurrency(unit).code
|
|
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance!
|
|
|
|
// update related tx
|
|
transactionData.push({
|
|
status: TransactionStatus.COMPLETED,
|
|
createdAt: new Date(),
|
|
})
|
|
|
|
transaction.update({
|
|
status: TransactionStatus.COMPLETED,
|
|
balanceAfter,
|
|
data: JSON.stringify(transactionData)
|
|
})
|
|
|
|
_sendTopupNotification(amount, unit)
|
|
|
|
return {
|
|
taskFunction: HANDLE_PENDING_TOPUP_TASK,
|
|
mintUrl,
|
|
unit,
|
|
amount,
|
|
paymentHash,
|
|
transaction,
|
|
message: `Your invoice has been paid and your wallet balance credited with ${formatCurrency(amount, currencyCode)} ${currencyCode}.`,
|
|
} as TransactionTaskResult
|
|
/*
|
|
* ISSUED
|
|
*/
|
|
case MintQuoteState.ISSUED:
|
|
log.trace('[handlePendingTopupTask] Quote already issued', {mintUrl, mintQuote})
|
|
|
|
return {
|
|
taskFunction: HANDLE_PENDING_TOPUP_TASK,
|
|
transaction,
|
|
mintUrl,
|
|
unit,
|
|
amount,
|
|
paymentHash,
|
|
message: `Ecash for quote ${mintQuote} has already been issued.`,
|
|
} as WalletTaskResult
|
|
/*
|
|
* UNKNOWN
|
|
*/
|
|
default:
|
|
log.error(`[handlePendingTopupTask] Unknown MintQuoteState`, {state})
|
|
return {
|
|
taskFunction: HANDLE_PENDING_TOPUP_TASK,
|
|
transaction,
|
|
mintUrl,
|
|
unit,
|
|
amount,
|
|
paymentHash,
|
|
message: `Unknown MintQuoteState ${state}`,
|
|
} as WalletTaskResult
|
|
}
|
|
|
|
} catch (e: any) {
|
|
return {
|
|
taskFunction: HANDLE_PENDING_TOPUP_TASK,
|
|
mintUrl,
|
|
unit,
|
|
amount,
|
|
paymentHash,
|
|
transaction,
|
|
error: {name: e.name, message: e.message, params: e.params || undefined},
|
|
message: `handlePendingTopupTask ended with error: ${e.message}`,
|
|
} as TransactionTaskResult
|
|
}
|
|
|
|
}
|
|
|
|
|
|
const recoverMintQuote = async function (params: {mintUrl: string, mintQuote: string}): Promise<{recoveredAmount: number}> {
|
|
const {mintUrl, mintQuote} = params
|
|
const mintInstance = mintsStore.findByUrl(mintUrl as string)
|
|
const unit = 'sat'
|
|
|
|
if(!mintInstance || !mintQuote) {
|
|
throw new AppError(Err.VALIDATION_ERROR, 'Missing mint or mint quote', {mintUrl})
|
|
}
|
|
|
|
// check is quote has been paid
|
|
const { state, mintQuote: quote, encodedInvoice } = await walletStore.checkLightningMintQuote(mintUrl, mintQuote)
|
|
|
|
if (quote !== mintQuote) {
|
|
throw new AppError(Err.VALIDATION_ERROR, 'Returned quote is different then the one requested', {mintUrl, quote, mintQuote})
|
|
}
|
|
|
|
switch (state) {
|
|
case MintQuoteState.UNPAID:
|
|
log.trace('[recoverMintQuote] Quote not paid', {mintUrl, mintQuote})
|
|
throw new AppError(Err.VALIDATION_ERROR, `Quote ${mintQuote} is not paid`)
|
|
case MintQuoteState.PAID:
|
|
const invoice = LightningUtils.decodeInvoice(encodedInvoice)
|
|
const {amount, description} = LightningUtils.getInvoiceData(invoice)
|
|
|
|
const transactionData: TransactionData[] = [
|
|
{
|
|
status: TransactionStatus.DRAFT,
|
|
amount,
|
|
unit,
|
|
createdAt: new Date(),
|
|
}
|
|
]
|
|
|
|
const newTransaction = {
|
|
type: TransactionType.TOPUP,
|
|
amount,
|
|
fee: 0,
|
|
unit,
|
|
data: JSON.stringify(transactionData),
|
|
memo: description,
|
|
mint: mintUrl,
|
|
status: TransactionStatus.DRAFT,
|
|
}
|
|
// store tx in db and in the model
|
|
const transaction = await transactionsStore.addTransaction(newTransaction)
|
|
const transactionId = transaction.id
|
|
|
|
let proofs: CashuProof[] = []
|
|
|
|
try {
|
|
proofs = (await walletStore.mintProofs(
|
|
mintUrl as string,
|
|
amount,
|
|
'sat',
|
|
mintQuote,
|
|
transactionId
|
|
))
|
|
} catch (e: any) {
|
|
if(e.message.includes('outputs have already been signed before')) {
|
|
|
|
log.error('[recoverMintQuote] Increasing proofsCounter outdated values and repeating mintProofs.')
|
|
|
|
proofs = (await walletStore.mintProofs(
|
|
mintUrl as string,
|
|
amount,
|
|
unit,
|
|
mintQuote,
|
|
transactionId,
|
|
{increaseCounterBy: 10}
|
|
))
|
|
} else {
|
|
throw e
|
|
}
|
|
}
|
|
|
|
if (!proofs || proofs.length === 0) {
|
|
throw new AppError(Err.VALIDATION_ERROR, 'Mint did not return any proofs.')
|
|
}
|
|
|
|
// we got proofs, accept to the wallet asap
|
|
const {addedAmount: recoveredAmount} = WalletUtils.addCashuProofs(
|
|
mintUrl as string,
|
|
proofs,
|
|
{
|
|
unit,
|
|
transactionId,
|
|
isPending: false
|
|
}
|
|
)
|
|
|
|
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance!
|
|
|
|
transactionData.push({
|
|
status: TransactionStatus.RECOVERED,
|
|
createdAt: new Date(),
|
|
})
|
|
|
|
transaction.update({
|
|
status: TransactionStatus.RECOVERED,
|
|
balanceAfter,
|
|
data: JSON.stringify(transactionData)
|
|
})
|
|
|
|
return { recoveredAmount }
|
|
/*
|
|
* ISSUED
|
|
*/
|
|
case MintQuoteState.ISSUED:
|
|
log.trace('[recoverMintQuote] Quote already issued', {mintUrl, mintQuote})
|
|
|
|
throw new AppError(Err.VALIDATION_ERROR, `Quote ${mintQuote} already issued.`)
|
|
/*
|
|
* UNKNOWN
|
|
*/
|
|
default:
|
|
log.error(`[recoverMintQuote] Unknown MintQuoteState`, {state})
|
|
throw new AppError(Err.VALIDATION_ERROR, `Quote ${mintQuote} has unknown state ${state}`)
|
|
}
|
|
|
|
}
|
|
|
|
|
|
const recoverMeltQuoteChange = async function (params: {mintUrl: string, meltQuote: string}): Promise<{recoveredAmount: number}> {
|
|
const {mintUrl, meltQuote} = params
|
|
const mintInstance = mintsStore.findByUrl(mintUrl as string)
|
|
const unit = 'sat'
|
|
|
|
if(!mintInstance || !meltQuote) {
|
|
throw new AppError(Err.VALIDATION_ERROR, 'Missing mint or melt quote', {mintUrl})
|
|
}
|
|
|
|
// check is quote has been paid
|
|
const meltQuoteResponse: MeltQuoteResponse = await walletStore.checkLightningMeltQuote(mintUrl, meltQuote)
|
|
const {quote, state} = meltQuoteResponse
|
|
const amountToRecover = sumBlindSignatures(meltQuoteResponse.change)
|
|
|
|
if (quote !== meltQuote) {
|
|
throw new AppError(Err.VALIDATION_ERROR, 'Returned quote is different then the one requested', {mintUrl, meltQuoteResponse, meltQuote})
|
|
}
|
|
|
|
switch (state) {
|
|
/*
|
|
* UNPAID
|
|
*/
|
|
case MeltQuoteState.UNPAID:
|
|
log.trace('[recoverMeltQuoteChange] Quote not paid', {mintUrl, meltQuote})
|
|
throw new AppError(Err.VALIDATION_ERROR, `Quote ${meltQuote} is not paid`)
|
|
/*
|
|
* PENDING
|
|
*/
|
|
case MeltQuoteState.PENDING:
|
|
log.trace('[recoverMeltQuoteChange] Quote is pending', {mintUrl, meltQuote})
|
|
|
|
throw new AppError(Err.VALIDATION_ERROR, `Quote ${meltQuote} is still pending.`)
|
|
/*
|
|
* PAID
|
|
*/
|
|
case MeltQuoteState.PAID:
|
|
|
|
if(!meltQuoteResponse.change || meltQuoteResponse.change.length === 0) {
|
|
throw new AppError(Err.VALIDATION_ERROR, `Quote ${meltQuote} has not any change to recover.`)
|
|
}
|
|
|
|
let transaction: Transaction | undefined = undefined
|
|
let transactionId: number | undefined = undefined
|
|
|
|
transaction = transactionsStore.findByQuote(meltQuote)
|
|
transactionId = transaction?.id
|
|
const transactionData: TransactionData[] = transaction ? JSON.parse(transaction.data) : []
|
|
|
|
// Older transactions might not have quote set
|
|
if(!transaction) {
|
|
transactionData.push({
|
|
status: TransactionStatus.DRAFT,
|
|
amountToRecover,
|
|
unit,
|
|
meltQuoteToRecover: quote,
|
|
createdAt: new Date(),
|
|
})
|
|
|
|
const newTransaction = {
|
|
type: TransactionType.RECEIVE,
|
|
amount: amountToRecover,
|
|
fee: 0,
|
|
unit,
|
|
data: JSON.stringify(transactionData),
|
|
memo: 'Melt quote change recovery',
|
|
mint: mintUrl,
|
|
status: TransactionStatus.DRAFT,
|
|
}
|
|
// store tx in db and in the model
|
|
const transaction = await transactionsStore.addTransaction(newTransaction)
|
|
transaction.update({quote: meltQuote})
|
|
transactionId = transaction.id
|
|
}
|
|
|
|
try {
|
|
|
|
const change = await walletStore.recoverMeltQuoteChange(
|
|
mintUrl as string,
|
|
meltQuoteResponse
|
|
)
|
|
|
|
// Force swap with the mint to make sure that change proofs are valid
|
|
const {proofsToSend, returnedProofs} = await walletStore.send(
|
|
mintUrl as string,
|
|
0,
|
|
unit,
|
|
change as Proof[],
|
|
transactionId,
|
|
{
|
|
increaseCounterBy: change.length, //if we missed to receive the change before, counter might be outdated
|
|
p2pk: undefined,
|
|
}
|
|
)
|
|
|
|
log.debug('[recoverMeltQuoteChange] Swapped proofs', {proofsToSend, returnedProofs})
|
|
|
|
|
|
if (!returnedProofs || returnedProofs.length === 0) {
|
|
throw new AppError(Err.VALIDATION_ERROR, 'Mint did not return any proofs.')
|
|
}
|
|
|
|
const {addedAmount: recoveredAmount} = WalletUtils.addCashuProofs(
|
|
mintUrl as string,
|
|
returnedProofs,
|
|
{
|
|
unit,
|
|
transactionId,
|
|
isPending: false
|
|
}
|
|
)
|
|
|
|
if(amountToRecover !== recoveredAmount) {
|
|
transaction.update({amount: recoveredAmount})
|
|
}
|
|
|
|
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance!
|
|
const outputToken = getEncodedToken({
|
|
mint: mintUrl,
|
|
proofs: returnedProofs,
|
|
unit,
|
|
})
|
|
|
|
transactionData.push({
|
|
status: TransactionStatus.RECOVERED,
|
|
recoveredAmount,
|
|
createdAt: new Date(),
|
|
})
|
|
|
|
transaction.update({
|
|
status: TransactionStatus.RECOVERED,
|
|
balanceAfter,
|
|
outputToken,
|
|
data: JSON.stringify(transactionData)
|
|
})
|
|
|
|
return { recoveredAmount }
|
|
|
|
} catch (e: any) {
|
|
const transactionData: TransactionData[] = JSON.parse(transaction?.data || '[]')
|
|
|
|
transactionData.push({
|
|
status: TransactionStatus.ERROR,
|
|
error: WalletUtils.formatError(e),
|
|
createdAt: new Date()
|
|
})
|
|
|
|
transaction.update({
|
|
status: TransactionStatus.ERROR,
|
|
data: JSON.stringify(transactionData)
|
|
})
|
|
}
|
|
/*
|
|
* UNKNOWN
|
|
*/
|
|
default:
|
|
log.error(`[recoverMeltQuoteChange] Unknown MeltQuoteState`, {state: meltQuoteResponse.state})
|
|
throw new AppError(Err.VALIDATION_ERROR, `Quote ${meltQuote} has unknown state ${meltQuoteResponse.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,
|
|
keys.NOSTR.publicKey,
|
|
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')
|
|
|
|
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})
|
|
|
|
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(contactsStore.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)
|
|
contactsStore.addReceivedEventId(event.id)
|
|
|
|
// move window to receive events to the last event created_at to avoid recive it again
|
|
// contactsStore.setLastPendingReceivedCheck(event.created_at)
|
|
|
|
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
|
|
contactsStore.setLastPendingReceivedCheck(directMessageEvent.created_at)
|
|
|
|
log.trace('[handleReceivedEventTask]', 'Received event', {directMessageEvent})
|
|
|
|
// get sender profile and save it as a contact
|
|
// this is not valid for events sent from LNURL bridge, that are sent and signed by a minibits server key
|
|
// and *** do not contain sentFrom *** // LEGACY, replaced by claim api
|
|
let sentFromPubkey = directMessageEvent.pubkey
|
|
let sentFrom = NostrClient.getFirstTagValue(directMessageEvent.tags, 'from') as string
|
|
let sentFromNpub = NostrClient.getNpubkey(sentFromPubkey)
|
|
let contactFrom: Contact | undefined = undefined
|
|
let zapSenderProfile: NostrProfile | undefined = undefined
|
|
let sentFromPicture: string | undefined = undefined
|
|
|
|
// add ecash or pr sender to the contacts
|
|
if(sentFrom) {
|
|
const sentFromName = NostrClient.getNameFromNip05(sentFrom as string)
|
|
|
|
if(sentFrom.includes(MINIBITS_NIP05_DOMAIN)) {
|
|
sentFromPicture = MINIBITS_SERVER_API_HOST + '/profile/avatar/' + sentFromPubkey
|
|
}
|
|
|
|
// we skip retrieval of external nostr profiles to minimize failures
|
|
// external contacts will thus miss image and lud16 address...
|
|
|
|
contactFrom = {
|
|
pubkey: sentFromPubkey,
|
|
npub: sentFromNpub,
|
|
nip05: sentFrom,
|
|
lud16: sentFrom.includes(MINIBITS_NIP05_DOMAIN) ? sentFrom : undefined,
|
|
name: sentFromName || undefined,
|
|
picture: sentFromPicture || undefined,
|
|
isExternalDomain: sentFrom.includes(MINIBITS_NIP05_DOMAIN) ? false : true
|
|
} as Contact
|
|
|
|
contactsStore.addContact(contactFrom)
|
|
} else {
|
|
// Event was sent from Minibits server
|
|
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'
|
|
|
|
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= proofsStore.getMintBalanceWithMaxBalance('sat')
|
|
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',
|
|
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
|
|
})
|
|
|
|
_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)
|
|
availableBalances.push(mintBalance)
|
|
}
|
|
}
|
|
|
|
} else {
|
|
availableBalances.push(proofsStore.getMintBalanceWithMaxBalance(unit as MintUnit))
|
|
}
|
|
|
|
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,
|
|
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
|
|
})
|
|
|
|
_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 _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,
|
|
syncStateWithMintQueue,
|
|
syncStateWithMintTask,
|
|
handleInFlightQueue,
|
|
handlePendingQueue,
|
|
handlePendingTopupQueue,
|
|
handleClaimQueue,
|
|
handleNwcRequestQueue,
|
|
receiveEventsFromRelaysQueue,
|
|
receiveQueue,
|
|
receiveOfflinePrepareQueue,
|
|
receiveOfflineCompleteQueue,
|
|
sendQueue,
|
|
swapAllQueue,
|
|
transferQueue,
|
|
topupQueue,
|
|
cashuPaymentRequestQueue,
|
|
revertQueue,
|
|
testQueue,
|
|
recoverMintQuote,
|
|
recoverMeltQuoteChange
|
|
} |