Files
minibits_wallet/src/services/walletService.ts
T

1830 lines
65 KiB
TypeScript

import {isBefore} from 'date-fns'
import {getSnapshot} from 'mobx-state-tree'
import { kinds as NostrKinds } from 'nostr-tools'
import {log} from './logService'
import {Proof} from '../models/Proof'
import {
Transaction,
TransactionStatus
} from '../models/Transaction'
import {rootStoreInstance} from '../models'
import {CashuUtils, ProofV3, TokenV3} from './cashu/cashuUtils'
import {LightningUtils} from './lightning/lightningUtils'
import AppError, {Err} from '../utils/AppError'
import {MintBalance, MintProofsCounter, MintStatus} from '../models/Mint'
import {MeltQuoteResponse, MintQuoteState} 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 { PaymentRequest, PaymentRequestStatus, PaymentRequestType } from '../models/PaymentRequest'
import { IncomingDataType, IncomingParser } from './incomingParser'
import { Contact } from '../models/Contact'
import { getDefaultAmountPreference } from '@cashu/cashu-ts/src/utils'
import { SyncQueue } from './syncQueueService'
import { receiveTask, receiveOfflinePrepareTask, receiveOfflineCompleteTask} 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 { MintUnit, formatCurrency, getCurrency } from './wallet/currency'
import { MinibitsClient } from './minibitsService'
export const MAX_SWAP_INPUT_SIZE = 100
export const MAX_SYNC_INPUT_SIZE = 200 // 1000 hard mint limit
type WalletTaskService = {
syncPendingStateWithMints: () => Promise<void>
syncSpendableStateWithMints: () => Promise<void>
syncStateWithMint: (
options: {
proofsToSync: Proof[],
mintUrl: string,
isPending: boolean
}
) => Promise<void>
syncStateWithMintSync: (
options: {
proofsToSync: Proof[],
mintUrl: string,
isPending: boolean
}
) => Promise<SyncStateTaskResult>
handleInFlight: () => Promise<void>
handleInFlightSync: (mint: Mint) => Promise<WalletTaskResult>
handlePendingTopups: () => Promise<void>
handlePendingTopup: (params: {
paymentRequest: PaymentRequest
}) => Promise<void>
handleClaim: () => Promise<void>
receiveEventsFromRelays: () => Promise<void>
transfer: (
mintBalanceToTransferFrom: MintBalance,
amountToTransfer: number,
unit: MintUnit,
meltQuote: MeltQuoteResponse,
memo: string,
invoiceExpiry: Date,
encodedInvoice: string,
nwcEvent?: NostrEvent
) => Promise<void>
receive: (
token: TokenV3,
amountToReceive: number,
memo: string,
encodedToken: string,
) => Promise<void>
receiveBatch: (
token: TokenV3,
amountToReceive: number,
memo: string,
encodedToken: string,
) => Promise<void>
receiveOfflinePrepare: (
token: TokenV3,
amountToReceive: number,
memo: string,
encodedToken: string,
) => Promise<void>
receiveOfflineComplete: (
transactionId: number
) => Promise<void>
send: (
mintBalanceToSendFrom: MintBalance,
amountToSend: number,
unit: MintUnit,
memo: string,
selectedProofs: Proof[]
) => Promise<void>
sendAll: () => Promise<void>
topup: (
mintBalanceToTopup: MintBalance,
amountToTopup: number,
unit: MintUnit,
memo: string,
contactToSendTo?: Contact,
nwcEvent?: NostrEvent
) => Promise<void>
revert: (
transaction: Transaction
) => Promise<void>
}
export interface WalletTaskResult {
taskFunction: string
mintUrl: string
message: string
error?: AppError
[key: string]: any
}
export interface TransactionTaskResult extends WalletTaskResult {
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,
message?: string
updatedStatus: TransactionStatus,
}
export interface SyncStateTaskResult extends WalletTaskResult {
transactionStateUpdates: TransactionStateUpdate[],
completedTransactionIds: number[],
errorTransactionIds: number[],
revertedTransactionIds: number[]
}
export type ReceivedEventResult = {
status: TransactionStatus | PaymentRequestStatus,
title: string,
message: string,
memo?: string,
picture?: string
paymentRequest?: PaymentRequest
token?: TokenV3
}
const {
userSettingsStore,
walletProfileStore,
mintsStore,
proofsStore,
transactionsStore,
paymentRequestsStore,
contactsStore,
relaysStore,
walletStore,
} = rootStoreInstance
const transfer = async function (
mintBalanceToTransferFrom: MintBalance,
amountToTransfer: number,
unit: MintUnit,
meltQuote: MeltQuoteResponse,
memo: string,
invoiceExpiry: Date,
encodedInvoice: string,
nwcEvent?: NostrEvent
): Promise<void> {
const now = new Date().getTime()
SyncQueue.addPrioritizedTask(
`transferTask-${now}`,
async () => await transferTask(
mintBalanceToTransferFrom,
amountToTransfer,
unit,
meltQuote,
memo,
invoiceExpiry,
encodedInvoice,
nwcEvent
)
)
return
}
const receive = async function (
token: TokenV3,
amountToReceive: number,
memo: string,
encodedToken: string,
): Promise<void> {
const now = new Date().getTime()
SyncQueue.addPrioritizedTask(
`receiveTask-${now}`,
async () => await receiveTask(
token,
amountToReceive,
memo,
encodedToken,
)
)
return
}
/*
* Receive big tokens in batches to keep mint load reasonable.
* Used when optimizing wallet proof amounts but might become the default.
*/
const receiveBatch = async function (
token: TokenV3,
amountToReceive: number,
memo: string,
encodedToken: string,
) {
const maxBatchSize = MAX_SWAP_INPUT_SIZE
const mintUrl = token.token[0].mint
const proofsToReceive = token.token.flatMap(entry => entry.proofs)
const unit = token.unit
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: TokenV3 = {
token: [
{
mint: mintUrl,
proofs: batch
}
],
memo: `${memo} #${index}`,
unit
}
const batchEncodedToken = CashuUtils.encodeToken(batchToken)
// Queued WalletTask
receive(
batchToken,
batchAmount,
`${memo} #${index}`,
batchEncodedToken,
)
}
} else {
// If the length is less than or equal to 100, do normal receive
receive(
token,
amountToReceive,
memo,
encodedToken,
)
}
}
const receiveOfflinePrepare = async function (
token: TokenV3,
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 receiveOfflineComplete = async function (
transactionId: number
): Promise<void> {
const now = new Date().getTime()
SyncQueue.addTask(
`receiveOfflineCompleteTask-${now}`,
async () => await receiveOfflineCompleteTask(
transactionId
)
)
return
}
const send = async function (
mintBalanceToSendFrom: MintBalance,
amountToSend: number,
unit: MintUnit,
memo: string,
selectedProofs: Proof[]
): Promise<void> {
const now = new Date().getTime()
SyncQueue.addPrioritizedTask(
`sendTask-${now}`,
async () => await sendTask(
mintBalanceToSendFrom,
amountToSend,
unit,
memo,
selectedProofs
)
)
return
}
/*
* sendAll moves all proofs to pending to prepare to swap them for standard amount preference
* This decreases the total number of proofs held by the wallet. Used to optimize exported backup size.
*/
const sendAll = async function (): Promise<void> {
log.trace('[sendAll] start')
if (mintsStore.mintCount === 0) {
return
}
// Move all proofs by mint units to pending as in offline mode (do not ask for swap)
for (const mint of mintsStore.allMints) {
// 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 unit of mint.units) {
const proofsToOptimize = proofsStore.getByMint(mint.mintUrl, { isPending: false, unit })
const totalProofsCount = proofsToOptimize.length
const mintBalance = mint.balances
if (totalProofsCount > maxBatchSize) {
let index = 0
for (let i = 0; i < totalProofsCount; i += maxBatchSize) {
index++
const batch = proofsToOptimize.slice(i, i + maxBatchSize)
const batchAmount = CashuUtils.getProofsAmount(batch)
send(
mintBalance!,
batchAmount,
unit,
`Optimize ecash #${index}`,
batch // forces offline mode
)
}
} else {
// If the length is less than or equal to limit, run with all proofs.
const proofsAmount = CashuUtils.getProofsAmount(proofsToOptimize)
send(
mintBalance!,
proofsAmount,
unit,
`Optimize ecash`,
proofsToOptimize // forces offline mode
)
}
}
}
}
const topup = 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 revert = async function (
transaction: Transaction
): Promise<void> {
const now = new Date().getTime()
SyncQueue.addPrioritizedTask(
`revertTask-${now}`,
async () => await revertTask(transaction)
)
return
}
/*
* Checks with all mints whether their proofs kept in pending state by the wallet have been spent.
*/
const syncPendingStateWithMints = async function (): Promise<void> {
log.trace('[syncPendingStateWithMint] start')
if (mintsStore.mintCount === 0) {
return
}
const isPending = true
const maxBatchSize = MAX_SYNC_INPUT_SIZE
// group proofs by mint so that we do max one call per mint
for (const mint of mintsStore.allMints) {
if(pollerExists(`syncPendingStateWithMintPoller-${mint.mintUrl}`)) {
log.trace('[syncPendingStateWithMint] Skipping, poller exists', {mintUrl: mint.mintUrl})
continue
}
const proofsToSync = proofsStore.getByMint(mint.mintUrl, {isPending})
const totalProofsCount = proofsToSync.length
if (totalProofsCount > maxBatchSize) {
for (let i = 0; i < totalProofsCount; i += maxBatchSize) {
const batch = proofsToSync.slice(i, i + maxBatchSize)
syncStateWithMint({ proofsToSync: batch, mintUrl: mint.mintUrl, isPending })
}
} else {
// If the length is less than or equal to 100, run syncStateWithMint with all proofs.
syncStateWithMint({ proofsToSync, mintUrl: mint.mintUrl, isPending });
}
}
}
/*
* Recover stuck wallet if tx error caused spent proof to remain in spendable state by the wallet.
* TODO batching
*/
const syncSpendableStateWithMints = async function (): Promise<void> {
log.trace('[syncSpendableStateWithMint] start')
if (mintsStore.mintCount === 0) {
return
}
const isPending = false
const maxBatchSize = MAX_SYNC_INPUT_SIZE
// 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 > maxBatchSize) {
for (let i = 0; i < totalProofsCount; i += maxBatchSize) {
const batch = proofsToSync.slice(i, i + maxBatchSize)
syncStateWithMint({ proofsToSync: batch, mintUrl: mint.mintUrl, isPending })
}
} else {
// If the length is less than or equal to 100, run syncStateWithMint with all proofs.
syncStateWithMint({ proofsToSync, mintUrl: mint.mintUrl, isPending });
}
}
return
}
/*
* Pass _syncStateWithMintTask function into synchronous queue for safe processing without race conditions on proof counters. *
*/
const syncStateWithMint = async function (
options: {
proofsToSync: Proof[],
mintUrl: string,
isPending: boolean
}
): Promise<void> {
const {mintUrl, isPending, proofsToSync} = options
log.trace('[syncStateWithMint] start', {mintUrl, isPending, proofsToSyncCount: proofsToSync.length})
const now = new Date().getTime()
return SyncQueue.addTask(
`_syncStateWithMintTask-${now}`,
async () => await _syncStateWithMintTask({proofsToSync, mintUrl, isPending})
)
}
// Use only within another queued task!
const syncStateWithMintSync = async function (
options: {
proofsToSync: Proof[],
mintUrl: string,
isPending: boolean
},
): Promise<SyncStateTaskResult> {
const {mintUrl, isPending, proofsToSync} = options
log.trace('[syncStateWithMintSync] start', {mintUrl, isPending, proofsToSyncCount: proofsToSync.length})
return 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 optional proofs to check the spent / pending status, otherwise all mint proofs are synced
* @returns WalletTaskResult
*/
const _syncStateWithMintTask = async function (
options: {
proofsToSync: Proof[],
mintUrl: string,
isPending: boolean
}
): Promise<SyncStateTaskResult> {
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: '_syncStateWithMintTask',
mintUrl,
message,
transactionStateUpdates: [],
completedTransactionIds: [],
errorTransactionIds: [],
revertedTransactionIds: []
} as SyncStateTaskResult
}
const {
spent: spentByMintProofs,
pending: pendingByMintProofs
} = await walletStore.getSpentOrPendingProofsFromMint(
mintUrl,
mint && mint.units ? mint.units[0] : 'sat', // likely not to be unit-dependent
proofsToSync
)
const unspentByMintProofs = CashuUtils.getProofsSubset(proofsToSync, [...spentByMintProofs, ...pendingByMintProofs])
if(mint) {
mint.setStatus(MintStatus.ONLINE)
}
const spentByMintAmount = CashuUtils.getProofsAmount(spentByMintProofs)
const pendingByMintAmount = CashuUtils.getProofsAmount(pendingByMintProofs)
const unspentByMintAmount = CashuUtils.getProofsAmount(unspentByMintProofs)
log.debug('[_syncStateWithMintTask]', `${isPending ? 'Pending' : ''} spent and pending by mint amounts`, {
spentByMintAmount,
pendingByMintAmount,
unspentByMintAmount,
isPending
})
// If some of the pending by wallet proofs are neither pending nor spent by mint
if (unspentByMintProofs.length > 0 && isPending) {
// remove it from pending proofs in the wallet
proofsStore.removeProofs(unspentByMintProofs as Proof[], true, true)
// add proofs back to the spendable wallet
proofsStore.addProofs(unspentByMintProofs as Proof[])
}
// 1. Complete transactions with their proofs becoming spent by mint
if (spentByMintProofs.length > 0) {
// Remove spent proofs model instances
proofsStore.removeProofs(spentByMintProofs 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(spentByMintProofs.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 spentByMintProofs) {
// 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) {
// if spent amount does not cover matched tx amount
// we do nothing because our proofsToSync might not be all pending proofs, only those selected for the tx that failed and we sync
// from exception handler.
// We move all unspent to spendable already above.
if(spentByMintTxAmount === tx.amount) {
completedTransactionIds.push(Number(tId))
return {
tId: Number(tId),
amount: tx.amount,
spentByMintAmount: spentByMintTxAmount as number,
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)
// 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 (pendingByMintProofs.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 = pendingByMintProofs.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 pending 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(pendingByMintProofs.length > 0) {
// Filter remainingSecrets to get those that do not exist in pendingByMintProofs
const pendingByMintSecrets = new Set(pendingByMintProofs.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: '_syncStateWithMintTask',
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: '_syncStateWithMintTask',
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 handleInFlight = async function (): Promise<void> {
log.trace('[handleInFlight] start')
if (mintsStore.mintCount === 0) {
return
}
for (const mint of mintsStore.allMints) {
const now = new Date().getTime()
SyncQueue.addTask(
`_handleInFlightByMintTask-${now}`,
async () => await _handleInFlightByMintTask(mint)
)
}
return
}
const handleInFlightSync = async function (mint: Mint): Promise<WalletTaskResult> {
return await _handleInFlightByMintTask(mint)
}
/*
* 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 proofsCounter = mint.findInFlightProofsCounter?.()
if(!proofsCounter) {
const message = 'No inFlight proofs to restore, skipping mint call...'
log.trace('[_handleInFlightByMintTask]', message, {mintUrl})
return {
taskFunction: '_handleInFlightByMintTask',
mintUrl,
message,
proofsCount: 0,
proofsAmount: 0,
} as WalletTaskResult
}
const transactionId = proofsCounter.inFlightTid!
try {
const seed = await walletStore.getSeed()
if(!seed) {
throw new Error('Missing seed')
}
log.info('[_handleInFlightByMintTask]', `Restoring from ${mint.hostname}...`)
log.debug('[_handleInFlightByMintTask]', proofsCounter)
const { proofs } = await walletStore.restore(
mint.mintUrl,
seed as Uint8Array,
{
indexFrom: proofsCounter.inFlightFrom as number,
indexTo: proofsCounter.inFlightTo as number,
keysetId: proofsCounter.keyset
}
)
if (proofs.length === 0) {
proofsCounter.resetInFlight(transactionId)
return {
taskFunction: '_handleInFlightByMintTask',
mintUrl,
message: 'No proofs were recovered.',
proofsCount: 0,
proofsAmount: 0,
} as WalletTaskResult
}
const {spent, pending} = await walletStore.getSpentOrPendingProofsFromMint(
mint.mintUrl,
mint.units ? mint.units[0] : 'sat',
proofs as Proof[]
)
const spentCount = spent.length
const pendingCount = pending.length
const spentAmount = CashuUtils.getProofsAmount(spent as Proof[])
const pendingAmount = CashuUtils.getProofsAmount(pending as Proof[])
const unspent = proofs.filter((proof: Proof) => !spent.includes(proof))
const unspentCount = unspent.length
const unspentAmount = CashuUtils.getProofsAmount(unspent as Proof[])
log.debug('[_handleInFlightByMintTask]', `Restored proofs`, {
spentCount,
spentAmount,
pendingCount,
pendingAmount,
unspentCount,
unspentAmount
})
if(unspentCount === 0) {
const message = 'Recovered proofs are already spent.'
log.debug('[_handleInFlightByMintTask]', message)
proofsCounter.resetInFlight(transactionId)
return {
taskFunction: '_handleInFlightByMintTask',
mintUrl,
message,
proofsCount: 0,
proofsAmount: 0,
} as WalletTaskResult
}
const { addedAmount, addedProofs } = WalletUtils.addCashuProofs(
mint.mintUrl,
unspent,
{
unit: proofsCounter.unit,
transactionId,
isPending: false
}
)
// release the lock
proofsCounter.resetInFlight(transactionId)
const walletTaskResult: WalletTaskResult = {
taskFunction: '_handleInFlightByMintTask',
mintUrl,
message: 'In flight proofs were recovered.',
proofsCount: addedProofs.length,
proofsAmount: addedAmount,
inFlightFrom: proofsCounter.inFlightFrom,
inFlightTo: proofsCounter.inFlightTo,
}
const transactionDataUpdate = {
status: TransactionStatus.RECOVERED,
walletTaskResult,
message: 'This transaction failed to receive expected funds from the mint, but the wallet suceeded to recover them.',
createdAt: new Date(),
}
await transactionsStore.updateStatuses(
[transactionId],
TransactionStatus.RECOVERED, // has been most likely DRAFT
JSON.stringify(transactionDataUpdate),
)
log.debug('[_handleInFlightByMintTask]', `Completed`, {walletTaskResult})
return walletTaskResult
} catch (e: any) {
// silent
log.error('[_handleInFlightByMintTask]', e.name, {message: e.message, mintUrl})
// make sure we release the lock
proofsCounter.resetInFlight(transactionId)
return {
taskFunction: '_handleInFlightByMintTask',
mintUrl,
message: `Error when handling inflight proof: ${e.message}`,
proofsCount: 0,
proofsAmount: 0,
} as WalletTaskResult
}
}
const handlePendingTopups = async function (): Promise<void> {
const paymentRequests: PaymentRequest[] = paymentRequestsStore.allOutgoing
log.trace('[handlePendingTopups] start', {paymentRequests})
if (paymentRequests.length === 0) {
log.trace('[handlePendingTopups]', 'No outgoing payment requests in store - skipping task send to the queue...')
return
}
for (const pr of paymentRequests) {
// skip pr if active poller exists
if(pollerExists(`handlePendingTopupTaskPoller-${pr.paymentHash}`)) {
log.trace('[handlePendingTopups] Skipping check of paymentRequest, poller exists', {paymentHash: pr.paymentHash})
continue
}
const now = new Date().getTime()
SyncQueue.addTask(
`_handlePendingTopupTask-${now}`,
async () => await _handlePendingTopupTask({paymentRequest: pr})
)
}
}
const handlePendingTopup = async function (params: {paymentRequest: PaymentRequest}): Promise<void> {
const {paymentRequest} = params
log.trace('[handlePendingTopup] start', {paymentHash: paymentRequest.paymentHash})
const now = new Date().getTime()
SyncQueue.addTask(
`_handlePendingTopupTask-${now}`,
async () => await _handlePendingTopupTask({paymentRequest})
)
}
const _handlePendingTopupTask = async function (params: {paymentRequest: PaymentRequest}): Promise<WalletTaskResult> {
const {paymentRequest: pr} = params
const transactionId = {...pr}.transactionId || 0// copy
const mintUrl = {...pr}.mint // copy
const unit = {...pr}.mintUnit // copy, unit of proofs to be received
const amount = {...pr}.amountToTopup // copy, amount of proofs to be received
const paymentHash = {...pr}.paymentHash // copy
const mintQuote = {...pr}.mintQuote // copy
const mintInstance = mintsStore.findByUrl(mintUrl as string)
const transaction = transactionsStore.findById(transactionId)
let lockedProofsCounter: MintProofsCounter | undefined = undefined
try {
if(!mintInstance || !mintQuote || !unit || !amount || !transaction) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing mint, mint quote, mintUnit, amountToTopup or transaction', {mintUrl})
}
const amountPreferences = getDefaultAmountPreference(amount)
const countOfInFlightProofs = CashuUtils.getAmountPreferencesCount(amountPreferences)
log.trace('[_handlePendingTopupTask]', 'paymentHash', paymentHash)
log.trace('[_handlePendingTopupTask]', 'amountPreferences', amountPreferences)
log.trace('[_handlePendingTopupTask]', 'countOfInFlightProofs', countOfInFlightProofs)
// 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 (state !== MintQuoteState.PAID) {
log.trace('[_handlePendingTopupTask] Quote not paid', {mintUrl, mintQuote})
if (isBefore(pr.expiresAt as Date, new Date())) {
log.debug('[_handlePendingTopupTask]', `Invoice expired, removing: ${pr.paymentHash}`)
// expire related tx - but only if it has not been completed before this check
if(transaction.status !== TransactionStatus.COMPLETED) {
const transactionDataUpdate = {
status: TransactionStatus.EXPIRED,
message: 'Invoice expired',
createdAt: new Date(),
}
await transactionsStore.updateStatuses(
[transactionId],
TransactionStatus.EXPIRED,
JSON.stringify(transactionDataUpdate),
)
}
stopPolling(`handlePendingTopupPoller-${paymentHash}`)
paymentRequestsStore.removePaymentRequest(pr)
}
return {
taskFunction: '_handlePendingTopupTask',
transaction,
mintUrl,
unit,
amount,
paymentHash,
message: `Quote ${mintQuote} has not yet been paid`,
} as WalletTaskResult
}
// temp increase the counter + acquire lock and set inFlight values
lockedProofsCounter = await WalletUtils.lockAndSetInFlight(
mintInstance,
unit,
countOfInFlightProofs,
transactionId,
)
let proofs: ProofV3[] = []
try {
proofs = (await walletStore.mintProofs(
mintUrl as string,
amount,
unit,
mintQuote,
{
preference: amountPreferences,
counter: lockedProofsCounter.inFlightFrom as number
}
)) as ProofV3[]
} catch (e: any) {
if(e.message.includes('outputs have already been signed before')) {
log.error('[_handlePendingTopupTask] Increasing proofsCounter outdated values and repeating mintProofs.')
lockedProofsCounter.resetInFlight(transactionId)
lockedProofsCounter.increaseProofsCounter(10)
lockedProofsCounter = await WalletUtils.lockAndSetInFlight(
mintInstance,
unit,
countOfInFlightProofs,
transactionId
)
proofs = (await walletStore.mintProofs(
mintUrl as string,
amount,
unit,
mintQuote,
{
preference: amountPreferences,
counter: lockedProofsCounter.inFlightFrom as number
}
)) as ProofV3[]
} else {
throw e
}
}
lockedProofsCounter.decreaseProofsCounter(countOfInFlightProofs)
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: receivedAmount} = WalletUtils.addCashuProofs(
mintUrl as string,
proofs,
{
unit,
transactionId,
isPending: false
}
)
// release lock and cleanup
lockedProofsCounter.resetInFlight(transactionId)
stopPolling(`handlePendingTopupTaskPoller-${paymentHash}`)
const currencyCode = getCurrency(pr.mintUnit!).code
if (receivedAmount !== amount) {
throw new AppError(
Err.VALIDATION_ERROR,
`Received amount ${formatCurrency(amount, currencyCode)} ${currencyCode} is not equal to the requested amount ${formatCurrency(amount, currencyCode)} ${currencyCode}.`,
)
}
// update related tx
const transactionDataUpdate = {
status: TransactionStatus.COMPLETED,
createdAt: new Date(),
}
// await for final status
await transactionsStore.updateStatuses(
[transactionId],
TransactionStatus.COMPLETED,
JSON.stringify(transactionDataUpdate),
)
// payment has been sent from payment request receiver
if(pr.contactTo) {
transaction.setSentFrom(
pr.contactTo.nip05handle ?? pr.contactTo.name!
)
transaction.setProfile(
JSON.stringify(getSnapshot(pr.contactTo))
)
}
// Update tx with current total balance of topup unit/currency
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance!
transaction.setBalanceAfter(balanceAfter)
_sendTopupNotification(pr)
paymentRequestsStore.removePaymentRequest(pr)
return {
taskFunction: '_handlePendingTopupTask',
mintUrl,
unit,
amount,
paymentHash,
transaction,
message: `Your invoice has been paid and your wallet balance credited with ${formatCurrency(amount, currencyCode)} ${currencyCode}.`,
} as TransactionTaskResult
} catch (e: any) {
// release lock
if(lockedProofsCounter) {
lockedProofsCounter.resetInFlight(transactionId)
}
return {
taskFunction: '_handlePendingTopupTask',
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 handleClaim = async function (): Promise<void> {
log.info('[handleClaim] start')
const {walletId, seedHash, pubkey} = walletProfileStore
const {isBatchClaimOn} = userSettingsStore
let recoveredWalletId: string | null = null
if(!seedHash || !pubkey) {
throw new AppError(
Err.VALIDATION_ERROR,
'Skipping claim of ecash received to your lightning address, missing profile data. Reinstall wallet to fix it.',
{walletId, seedHash, pubkey}
)
}
if(!walletId) {
// fix immediately in case only walletId missing in walletProfile
const profile = await MinibitsClient.getWalletProfileBySeedHash(seedHash)
if(profile) {
recoveredWalletId = profile.walletId
walletProfileStore.setWalletId(recoveredWalletId)
}
}
// Based on user setting, ask for batched token if more then 5 payments are waiting to be claimed
const claimedTokens = await MinibitsClient.createClaim(
walletId || recoveredWalletId as string,
seedHash,
pubkey,
isBatchClaimOn ? 5 : undefined
)
if(claimedTokens.length === 0) {
log.debug('[handleClaim] No claimed invoices returned from the server...')
return
} else {
log.debug(`[handleClaim] 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: TokenV3 | 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 = CashuUtils.decodeToken(encodedToken)
const amountToReceive = CashuUtils.getTokenAmounts(decoded).totalAmount
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) {
transaction.setProfile(zapSenderProfile as string)
try {
const profile: NostrProfile = JSON.parse(zapSenderProfile)
transaction.setSentFrom(profile.nip05 ?? profile.name)
} catch(e: any) {}
}
if (zapRequest) {
transaction.setZapRequest(zapRequest as string)
}
}
}
return {
mintUrl: decoded.token[0].mint,
taskFunction: '_handleClaimTask',
message: result.error ? result.error.message : 'Ecash sent to your lightning address has been received.',
error: result.error || undefined,
proofsCount: decoded.token[0].proofs.length,
proofsAmount: result.transaction?.amount,
} as WalletTaskResult
} catch (e: any) {
log.error(e.name, e.message)
return {
mintUrl: decoded ? decoded.token[0].mint : '',
taskFunction: '_handleClaimTask',
message: e.message,
error: WalletUtils.formatError(e),
} as WalletTaskResult
}
}
const _sendTopupNotification = async function (pr: PaymentRequest) {
const currencyCode = getCurrency(pr.mintUnit!).code
await NotificationService.createLocalNotification(
`⚡ ${formatCurrency(pr.amountToTopup!, currencyCode)} ${currencyCode} received!`,
`Your invoice has been paid and your wallet balance credited with ${formatCurrency(pr.amountToTopup!, currencyCode)} ${currencyCode}.`,
)
}
/*
* Checks with NOSTR relays whether there is ecash to be received or an invoice to be paid.
*/
const receiveEventsFromRelays = async function (): Promise<void> {
log.trace('[receiveEventsFromRelays] start')
if(!walletProfileStore.pubkey) {
const message = `No wallet profile created.`
log.trace('[receiveEventsFromRelays]', message)
return
}
try {
const { lastPendingReceivedCheck } = contactsStore
const filter = {
kinds: [NostrKinds.EncryptedDirectMessage],
"#p": [walletProfileStore.pubkey],
since: lastPendingReceivedCheck || 0
}
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 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 (event: NostrEvent): Promise<WalletTaskResult> {
// decrypt message content
const decrypted = await NostrClient.decryptNip04(event.pubkey, event.content)
log.trace('[_handleReceivedEventTask]', 'Received event', {id: event.id, created_at: event.created_at})
// 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 = event.pubkey
let sentFrom = NostrClient.getFirstTagValue(event.tags, 'from')
let sentFromNpub = NostrClient.getNpubkey(sentFromPubkey)
let contactFrom: Contact | undefined = undefined
let zapSenderProfile: NostrProfile | undefined = undefined
let sentFromPicture: string | undefined = undefined
// add ecash 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(decrypted)
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
// 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(decrypted)
log.trace('[_handleReceivedEventTask]', 'Incoming data', {incoming})
//
// Receive token start
//
if(incoming.type === IncomingDataType.CASHU) {
const decoded = CashuUtils.decodeToken(incoming.encoded)
const amountToReceive = CashuUtils.getTokenAmounts(decoded).totalAmount
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.setProfile(JSON.stringify(contactFrom))
transaction.setSentFrom(sentFrom)
}
if (zapSenderProfile) {
transaction.setProfile(JSON.stringify(zapSenderProfile))
transaction.setSentFrom(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.token[0].mint,
taskFunction: '_handleReceivedEventTask',
message: 'Incoming ecash token has been received.',
proofsCount: decoded.token[0].proofs.length,
proofsAmount: receivedAmount,
notificationInputs: {
event,
decrypted,
transaction,
receivedAmount
} // not used`
} as WalletTaskResult
}
//
// Receive bolt11 invoice start
//
if (incoming.type === IncomingDataType.INVOICE) {
// receiver 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(decrypted)
const paymentRequest = paymentRequestsStore.addPaymentRequest({
type: PaymentRequestType.INCOMING,
status: PaymentRequestStatus.ACTIVE,
encodedInvoice: incoming.encoded,
invoicedUnit: 'sat', // bolt11
invoicedAmount: amount || 0,
description: maybeMemo ? maybeMemo : description,
paymentHash,
contactFrom: contactFrom || {pubkey: sentFromPubkey, npub: sentFromNpub},
contactTo,
expiry,
createdAt: timestamp ? new Date(timestamp * 1000) : new Date()
})
_sendPaymentRequestNotification(paymentRequest)
return {
mintUrl: '',
taskFunction: '_processReceiveEvent',
message: 'Incoming payment request been received.',
proofsCount: 0,
proofsAmount: amount,
paymentRequest
}
}
if (incoming.type === IncomingDataType.LNURL) {
throw new AppError(Err.NOTFOUND_ERROR, 'LNURL support is not yet implemented.', {caller: '_handleReceivedEventTask'})
}
throw new AppError(Err.NOTFOUND_ERROR, 'Received unknown message', incoming)
}
const _sendReceiveNotification = async function (
receivedAmount: number,
unit: MintUnit,
isZap: boolean,
sentFrom: string,
sentFromPicture?: string
): Promise<void> {
const enabled = await NotificationService.areNotificationsEnabled()
if(!enabled) {
return
}
//
// Send notification event
//
const currencyCode = getCurrency(unit).code
if(receivedAmount && receivedAmount > 0) {
await NotificationService.createLocalNotification(
`<b>⚡${formatCurrency(receivedAmount, currencyCode)} ${currencyCode}</b> received!`,
`${isZap ? 'Zap' : 'Ecash'} from <b>${sentFrom || 'unknown payer'}</b> is now in your wallet.`,
sentFromPicture
)
}
return
}
const _sendPaymentRequestNotification = async function (pr: PaymentRequest) {
await NotificationService.createLocalNotification(
`⚡ Please pay <b>${formatCurrency(pr.invoicedAmount, getCurrency(pr.invoicedUnit!).code)} ${getCurrency(pr.invoicedUnit!).code}</b>!`,
`${pr.contactFrom.nip05 || 'Unknown'} has sent you a request to pay an invoice.`,
pr.contactFrom.picture,
)
}
const _extractZapSenderData = function (str: string) {
const match = str.match(/\{[^}]*\}/);
return match ? match[0] : null;
}
export const WalletTask: WalletTaskService = {
syncPendingStateWithMints,
syncSpendableStateWithMints,
syncStateWithMint,
syncStateWithMintSync,
handleInFlight,
handleInFlightSync,
handlePendingTopups,
handlePendingTopup,
handleClaim,
receiveEventsFromRelays,
receive,
receiveBatch,
receiveOfflinePrepare,
receiveOfflineComplete,
send,
sendAll,
transfer,
topup,
revert
}