Files
minibits_wallet/src/services/walletService.ts
T

1967 lines
68 KiB
TypeScript

import {deriveKeysetId, getEncodedToken} from '@cashu/cashu-ts'
import {addSeconds, isBefore} from 'date-fns'
import {getSnapshot, isStateTreeNode} from 'mobx-state-tree'
import {log} from './logService'
import {MintClient, MintKeys, MintKeySets} from './cashuMintClient'
import {Proof} from '../models/Proof'
import {
Transaction,
TransactionData,
TransactionRecord,
TransactionStatus,
TransactionType,
} from '../models/Transaction'
import {rootStoreInstance} from '../models'
import {CashuUtils} from './cashu/cashuUtils'
import {LightningUtils} from './lightning/lightningUtils'
import AppError, {Err} from '../utils/AppError'
import {MintBalance, MintStatus} from '../models/Mint'
import {Token} from '../models/Token'
import {
type TokenEntry as CashuTokenEntry,
type Proof as CashuProof,
} from '@cashu/cashu-ts'
import {Mint} from '../models/Mint'
import {poller, stopPolling} from '../utils/poller'
import EventEmitter from '../utils/eventEmitter'
import { NostrClient, NostrEvent, NostrFilter } from './nostrService'
import { MINIBITS_NIP05_DOMAIN, MINIBITS_SERVER_API_HOST } from '@env'
import { PaymentRequest, PaymentRequestStatus, PaymentRequestType } from '../models/PaymentRequest'
import { IncomingDataType, IncomingParser } from './incomingParser'
import { Contact, ContactType } from '../models/Contact'
import { MinibitsClient } from './minibitsService'
type WalletService = {
checkPendingSpent: () => Promise<void>
checkPendingReceived: () => Promise<void>
checkSpent: () => Promise<
{spentCount: number; spentAmount: number} | undefined
>
checkPendingTopups: () => Promise<void>
transfer: (
mintBalanceToTransferFrom: MintBalance,
amountToTransfer: number,
estimatedFee: number,
invoiceExpiry: Date,
memo: string,
encodedInvoice: string,
) => Promise<TransactionResult>
receive: (
token: Token,
amountToReceive: number,
memo: string,
encodedToken: string,
) => Promise<TransactionResult>
receiveOfflinePrepare: (
token: Token,
amountToReceive: number,
memo: string,
encodedToken: string,
) => Promise<TransactionResult>
receiveOfflineComplete: (
transaction: Transaction
) => Promise<TransactionResult>
send: (
mintBalanceToSendFrom: MintBalance,
amountToSend: number,
memo: string,
selectedProofs: Proof[]
) => Promise<TransactionResult>
topup: (
mintBalanceToTopup: MintBalance,
amountToTopup: number,
memo: string,
contactToSendTo?: Contact
) => Promise<TransactionResult>
}
export type TransactionResult = {
transaction: Transaction | undefined
message: string
error?: AppError
[key: string]: any
}
export type ReceivedEventResult = {
status: TransactionStatus | PaymentRequestStatus,
title: string,
message: string,
memo?: string,
picture?: string
paymentRequest?: PaymentRequest
token?: Token
}
const {
walletProfileStore,
mintsStore,
proofsStore,
transactionsStore,
paymentRequestsStore,
contactsStore,
relaysStore,
} = rootStoreInstance
/*
* Checks with all mints whether their pending proofs have been spent.
*/
async function checkPendingSpent() {
if (mintsStore.mintCount === 0) {
return
}
// group proofs by mint so that we do max one call per mint
for (const mint of mintsStore.allMints) {
const result = await _checkSpentByMint(mint.mintUrl, true) // pending true
let status: MintStatus = MintStatus.ONLINE
if(!result) {
// assume online if check exits because there are no pending proofs or none were spent
mint.setStatus(status)
continue
}
if(result && result.error) {
// if error looks like mint is offline
if(result.error.name === Err.MINT_ERROR) {
status = MintStatus.OFFLINE
}
}
mint.setStatus(status)
}
}
/*
* Checks with NOSTR relays whether there is ecash to be received or an invoice to be paid.
*/
const checkPendingReceived = async function () {
if(!walletProfileStore.pubkey) {
return
}
// clean expired paymentRequests
try {
const { lastPendingReceivedCheck } = contactsStore
const filter: NostrFilter = [{
kinds: [4],
"#p": [walletProfileStore.pubkey],
since: lastPendingReceivedCheck || 0
}]
contactsStore.setLastPendingReceivedCheck()
log.trace('[checkPendingReceived]', 'Creating Nostr subscription...', filter)
const pool = NostrClient.getRelayPool()
let relaysToConnect = relaysStore.allUrls
// TODO cleanup
const defaultPublicRelays = NostrClient.getDefaultRelays()
const minibitsRelays = NostrClient.getMinibitsRelays()
if(!relaysStore.alreadyExists(minibitsRelays[0])) {
relaysToConnect.push(minibitsRelays[0])
}
if(!relaysStore.alreadyExists(defaultPublicRelays[0])) {
relaysToConnect.push(defaultPublicRelays[0])
}
const sub = pool.sub(relaysToConnect , filter)
const relaysConnections = pool._conn
for (const url in relaysConnections) {
if (relaysConnections.hasOwnProperty(url)) {
const relay = relaysConnections[url]
relay.on('error', (error: string) => {
const {url, status} = relay
relaysStore.addOrUpdateRelay({url, status, error})
})
relay.on('connect', () => {
const {url, status} = relay
relaysStore.addOrUpdateRelay({url, status})
})
relay.on('disconnect', () => {
const {url, status} = relay
relaysStore.addOrUpdateRelay({url, status})
})
}
}
let events: NostrEvent[] = []
let result: ReceivedEventResult | undefined = undefined
sub.on('event', async (event: NostrEvent) => {
try {
// ignore all kinds of duplicate events
if(events.some(ev => ev.id === event.id)) {
log.error(Err.ALREADY_EXISTS_ERROR, 'Duplicate event received by this subscription, skipping...', event.id)
return
}
events.push(event)
if(contactsStore.eventAlreadyReceived(event.id)) {
log.error(Err.ALREADY_EXISTS_ERROR, 'Event has been processed in the past, skipping...', {id: event.id})
return
}
// decrypt message content
const decrypted = await NostrClient.decryptNip04(event.pubkey, event.content)
// get sender profile and save it as a contact
const sentFrom = getTagValue(event.tags, 'from')
const sentFromPubkey = event.pubkey
const sentFromNpub = NostrClient.getNpubkey(sentFromPubkey)
const sentFromName = NostrClient.getNameFromNip05(sentFrom as string)
let sentFromPicture: string | undefined
if(sentFrom && 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...
const contactFrom = {
pubkey: sentFromPubkey,
npub: sentFromNpub,
nip05: sentFrom || undefined,
name: sentFromName || undefined,
picture: sentFromPicture || undefined,
isExternalDomain: sentFrom && sentFrom.includes(MINIBITS_NIP05_DOMAIN) ? false : true
} as Contact
contactsStore.addContact(contactFrom)
// parse incoming message
const incoming = IncomingParser.findAndExtract(decrypted)
if(incoming.type === IncomingDataType.CASHU) {
const {
error,
receivedAmount,
memo,
} = await receiveFromNostrEvent(incoming.encoded, event)
let picture: string | undefined = undefined
if(sentFrom) {
picture = MINIBITS_SERVER_API_HOST + '/profile/avatar/' + sentFromPubkey
}
if(receivedAmount > 0) {
result = {
status: TransactionStatus.COMPLETED,
title: `⚡${receivedAmount} sats received!`,
message: `Ecash from <b>${sentFrom}</b> is now in your wallet.`,
memo,
picture,
token: CashuUtils.decodeToken(incoming.encoded)
}
EventEmitter.emit('receiveTokenCompleted', result)
}
if(error) {
throw new AppError(Err.MINT_ERROR, `Error while receiving transaction: ${error.message}`)
}
return
}
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 = findMemo(decrypted)
const paymentRequest = paymentRequestsStore.addPaymentRequest({
type: PaymentRequestType.INCOMING,
status: PaymentRequestStatus.ACTIVE,
encodedInvoice: incoming.encoded,
amount: amount || 0,
description: maybeMemo ? maybeMemo : description,
paymentHash: paymentHash || '',
contactFrom,
contactTo,
expiry: expiry || 600,
createdAt: timestamp ? new Date(timestamp * 1000) : new Date()
})
result = {
status: PaymentRequestStatus.ACTIVE,
title: `⚡ Please pay ${paymentRequest.amount} sats!`,
message: `${sentFrom} has sent you a request to pay an invoice.`,
memo: (maybeMemo) ? maybeMemo : paymentRequest.description,
picture: sentFromPicture,
paymentRequest,
}
EventEmitter.emit('receivePaymentRequest', result)
return
}
if (incoming.type === IncomingDataType.LNURL) {
throw new AppError(Err.NOTFOUND_ERROR, 'LNURL support is not yet implemented.')
}
throw new AppError(Err.NOTFOUND_ERROR, 'Received unknown message', incoming)
} catch(e: any) {
const result = {
status: TransactionStatus.ERROR,
message: e.message
}
EventEmitter.emit('receiveTokenOrInvoiceError', result) // so far not used
log.error(e.name, e.message)
}
})
sub.on('eose', async () => {
log.trace('[checkPendingReceived]', `Eose: Got ${events.length} receive events`)
})
} catch (e: any) {
const result = {
status: TransactionStatus.ERROR,
message: e.message
}
EventEmitter.emit('receiveTokenOrInvoiceError', result) // so far not used
log.error(e.name, e.message)
}
}
function getTagValue(tagsArray: [string, string][], tagName: string): string | undefined {
const tag = tagsArray.find(([name]) => name === tagName)
return tag ? tag[1] : undefined
}
function findMemo(message: string): string | undefined {
// Find the last occurrence of "memo: "
const lastIndex = message.lastIndexOf("memo: ")
if (lastIndex !== -1) {
const memoAfterLast = message.substring(lastIndex + 6) // skip "memo: " itself
return memoAfterLast;
}
return undefined
}
const receiveFromNostrEvent = async function (encoded: string, event: NostrEvent) {
try {
const decoded: Token = CashuUtils.decodeToken(encoded)
const sentFrom = getTagValue(event.tags, 'from')
const sentFromPubkey = event.pubkey
const tokenAmounts = CashuUtils.getTokenAmounts(decoded)
const amountToReceive = tokenAmounts.totalAmount
const memo = decoded.memo || ''
const {transaction, message, error, receivedAmount} =
await receive(
decoded as Token,
amountToReceive,
memo,
encoded as string,
)
if(transaction && transaction.status === TransactionStatus.COMPLETED) {
const updated = JSON.parse(transaction.data)
updated[2].receivedEvent = event
await transactionsStore.updateStatus(
transaction.id as number,
TransactionStatus.COMPLETED,
JSON.stringify(updated),
)
await transactionsStore.updateSentFrom(
transaction.id as number,
sentFrom as string
)
}
contactsStore.addReceivedEventId(event.id)
return {
error: null,
receivedAmount: receivedAmount,
memo,
sentFrom: sentFrom || '',
sentFromPubkey,
}
} catch (e: any) {
return {error: e, receivedAmount: 0}
}
}
/*
* Recover stuck wallet if tx error caused spent proof to remain in wallet.
*/
async function checkSpent() {
if (mintsStore.mintCount === 0) {
return
}
let spentCount = 0
let spentAmount = 0
// group proofs by mint so that we do max one call per mint
for (const mint of mintsStore.allMints) {
const result = await _checkSpentByMint(mint.mintUrl, false) // pending false
spentCount += result?.spentCount || 0
spentAmount += result?.spentAmount || 0
}
return {spentCount, spentAmount}
}
/*
* Checks with the mint whether proofs have been spent.
* 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 live wallet itself.
* This situation occurs as a result of error during SEND or TRANSFER and causes failure of
* subsequent transactions because mint returns "Tokens already spent" if any spent proof is used as an input.
*/
async function _checkSpentByMint(mintUrl: string, isPending: boolean = false) {
try {
const proofsFromMint = proofsStore.getByMint(mintUrl, isPending) as Proof[]
if (proofsFromMint.length < 1) {
log.trace('[_checkSpentByMint]', `No ${isPending ? 'pending' : ''} proofs found for mint`, mintUrl)
return
}
const {spent: spentProofs, pending: pendingProofs} = await MintClient.getSpentOrPendingProofsFromMint(
mintUrl,
proofsFromMint,
)
for (const proof of pendingProofs) {
proofsStore.addToPendingByMint(proof as Proof)
}
const spentCount = spentProofs.length
const spentAmount = CashuUtils.getProofsAmount(spentProofs as Proof[])
const pendingAmount = CashuUtils.getProofsAmount(pendingProofs as Proof[])
log.trace('[_checkSpentByMint]', `spentProofs and pendingProofs amounts`, {spentAmount, pendingAmount, isPending})
if (spentProofs.length > 0) {
// we need to identify txIds to update their statuses, there might be more then one tx to complete
let relatedTransactionIds: number[] = []
for (const spentProof of spentProofs) {
const tId = proofsFromMint.find(
(proof: Proof) => proof.secret === spentProof.secret,
)?.tId
if (tId && !relatedTransactionIds.includes(tId)) {
relatedTransactionIds.push(tId)
}
}
// Clean pendingByMint secrets if in-flight payment completed and they came back as spent
if(proofsStore.pendingByMintSecrets.length > 0) {
log.trace('[_checkSpentByMint]', 'Starting sweep of spent pendingByMint proofs')
for (const proof of spentProofs) {
const secret = proofsStore.pendingByMintSecrets.find((s => s === proof.secret))
if(secret) {
proofsStore.removeFromPendingByMint(proof as Proof)
}
}
}
// remove spent proofs model instances from pending
proofsStore.removeProofs(spentProofs as Proof[], isPending)
// Update related transactions statuses
log.debug('[_checkSpentByMint]', 'Transaction id(s) to complete', relatedTransactionIds.toString())
// Complete related transactions in normal wallet operations
if (isPending) {
const transactionDataUpdate = {
status: TransactionStatus.COMPLETED,
createdAt: new Date(),
}
await transactionsStore.updateStatuses(
relatedTransactionIds,
TransactionStatus.COMPLETED,
JSON.stringify(transactionDataUpdate),
)
EventEmitter.emit('sendCompleted', relatedTransactionIds)
stopPolling('checkSpentByMintPoller')
}
} else {
log.trace('[_checkSpentByMint]', `No spent ${isPending ? 'pending' : ''} proofs returned from the mint`, mintUrl)
}
// Check if remaining pendingByMint secrets are still pending with the mint.
// If not, move related wallet's pending proofs back to spendable as the payment failed and they were not spent.
const remainingSecrets = getSnapshot(proofsStore.pendingByMintSecrets)
log.trace('[_checkSpentByMint]', 'pendingByMintSecrets', remainingSecrets)
if(remainingSecrets.length > 0 && isPending) {
log.trace('[_checkSpentByMint]', 'Starting sweep of pendingByMintSecrets')
const movedProofs: Proof[] = []
for (const secret of remainingSecrets) {
const proofToMove = proofsStore.getBySecret(secret, true) // find the proof in wallet's pending
// only move if it is from current mint
if(proofToMove && proofToMove.mintUrl === mintUrl) {
const stillPendingByMint = pendingProofs.find((p => p.secret === secret))
if(!stillPendingByMint) {
// move it if it is not pending by mint anymore
proofsStore.removeFromPendingByMint(proofToMove as Proof)
movedProofs.push(proofToMove)
}
}
}
if(movedProofs.length > 0) {
log.trace('[_checkSpentByMint]', 'Moving proofs from pending to spendable', movedProofs.length)
// Update related transactions as reverted
let relatedTransactionIds: number[] = []
for (const movedProof of movedProofs) {
const tId = movedProof.tId
if (tId && !relatedTransactionIds.includes(tId)) {
relatedTransactionIds.push(tId)
}
}
// remove it from pending proofs in the wallet
proofsStore.removeProofs(movedProofs, true, true)
// add proofs back to the spendable wallet
proofsStore.addProofs(movedProofs)
if(relatedTransactionIds.length > 0) {
const transactionDataUpdate = {
status: TransactionStatus.REVERTED,
createdAt: new Date(),
}
await transactionsStore.updateStatuses(
relatedTransactionIds,
TransactionStatus.REVERTED,
JSON.stringify(transactionDataUpdate),
)
}
} else {
log.trace('[_checkSpentByMint]', `No moved proofs from pending`, mintUrl)
}
}
return {
mintUrl,
spentCount,
spentAmount
}
} catch (e: any) {
// silent
log.warn('[_checkSpentByMint]', e.name, {message: e.message, mintUrl})
return {
mintUrl,
error: e,
}
}
}
const receive = async function (
token: Token,
amountToReceive: number,
memo: string,
encodedToken: string,
) {
const transactionData: TransactionData[] = []
let transactionId: number = 0
try {
const tokenMints: string[] = CashuUtils.getMintsFromToken(token as Token)
log.trace('[receive]', 'receiveToken tokenMints', tokenMints)
if (tokenMints.length === 0) {
throw new AppError(
Err.VALIDATION_ERROR,
'Could not get any mint information from the ecash token.',
)
}
if (tokenMints.length > 1) {
throw new AppError(
Err.VALIDATION_ERROR,
'Wallet does not support receiving of tokens with entries from multiple mints.',
)
}
const mintToReceive = tokenMints[0]
// Let's create new draft receive transaction in database
transactionData.push({
status: TransactionStatus.DRAFT,
amountToReceive,
createdAt: new Date(),
})
const newTransaction: Transaction = {
type: TransactionType.RECEIVE,
amount: amountToReceive,
data: JSON.stringify(transactionData),
memo,
mint: mintToReceive,
status: TransactionStatus.DRAFT,
}
const draftTransaction: TransactionRecord = await transactionsStore.addTransaction(newTransaction)
transactionId = draftTransaction.id as number
// Handle blocked mint
const isBlocked = mintsStore.isBlocked(mintToReceive)
if (isBlocked) {
const blockedTransaction = await transactionsStore.updateStatus(
transactionId,
TransactionStatus.BLOCKED,
JSON.stringify(transactionData),
)
return {
transaction: blockedTransaction,
message: `The mint ${mintToReceive} is blocked. You can unblock it in Settings.`,
} as TransactionResult
}
// Handle missing mint, we add it automatically
const alreadyExists = mintsStore.alreadyExists(mintToReceive)
if (!alreadyExists) {
const mintKeys: {
keys: MintKeys
keyset: string
} = await MintClient.getMintKeys(mintToReceive)
const newMint: Mint = {
mintUrl: mintToReceive,
keys: mintKeys.keys,
keysets: [mintKeys.keyset]
}
mintsStore.addMint(newMint)
}
// Now we ask all mints to get fresh outputs for their tokenEntries, and create from them new proofs
// 0.8.0-rc3 implements multimints receive however CashuMint constructor still expects single mintUrl
const {updatedToken, errorToken, newKeys} = await MintClient.receiveFromMint(
mintToReceive,
encodedToken as string,
)
if(newKeys) {_updateMintKeys(mintToReceive, newKeys)}
// Update transaction status
transactionData.push({
status: TransactionStatus.PREPARED,
errorToken,
createdAt: new Date(),
})
await transactionsStore.updateStatus(
transactionId,
TransactionStatus.PREPARED,
JSON.stringify(transactionData),
)
let amountWithErrors = 0
if (errorToken && errorToken.token.length > 0) {
amountWithErrors += CashuUtils.getTokenAmounts(errorToken as Token).totalAmount
log.warn('[receive]', 'receiveToken amountWithErrors', amountWithErrors)
}
if (amountWithErrors === amountToReceive) {
throw new AppError(
Err.VALIDATION_ERROR,
'Received ecash token is not valid and can not be redeemed.',
)
}
for (const entry of updatedToken.token) {
// create ProofModel instances and store them into the proofsStore
_addCashuProofs(
entry.proofs,
entry.mint,
transactionId as number
)
}
// This should be amountToReceive - amountWithErrors but let's set it from updated token
const receivedAmount = CashuUtils.getTokenAmounts(updatedToken as Token).totalAmount
log.debug('[receive]', `Received amount: ${receivedAmount}`)
// Update tx amount if full amount was not received
if (receivedAmount !== amountToReceive) {
await transactionsStore.updateReceivedAmount(
transactionId,
receivedAmount,
)
}
// Finally, update completed transaction
transactionData.push({
status: TransactionStatus.COMPLETED,
receivedAmount,
amountWithErrors,
createdAt: new Date(),
})
const completedTransaction = await transactionsStore.updateStatus(
transactionId,
TransactionStatus.COMPLETED,
JSON.stringify(transactionData),
)
const balanceAfter = proofsStore.getBalances().totalBalance
await transactionsStore.updateBalanceAfter(transactionId, balanceAfter)
if (amountWithErrors > 0) {
return {
transaction: completedTransaction,
message: `You've received ${receivedAmount} sats to your minibits wallet. ${amountWithErrors} could not be redeemed from the mint`,
receivedAmount,
} as TransactionResult
}
return {
transaction: completedTransaction,
message: `You've received ${receivedAmount} sats to your minibits wallet.`,
receivedAmount,
} as TransactionResult
} catch (e: any) {
let errorTransaction: TransactionRecord | undefined = undefined
if (transactionId > 0) {
transactionData.push({
status: TransactionStatus.ERROR,
error: _formatError(e),
})
errorTransaction = await transactionsStore.updateStatus(
transactionId,
TransactionStatus.ERROR,
JSON.stringify(transactionData),
)
}
log.error(e.name, e.message)
return {
transaction: errorTransaction || undefined,
message: '',
error: _formatError(e),
} as TransactionResult
}
}
const receiveOfflinePrepare = async function (
token: Token,
amountToReceive: number,
memo: string,
encodedToken: string,
) {
const transactionData: TransactionData[] = []
let transactionId: number = 0
try {
const tokenMints: string[] = CashuUtils.getMintsFromToken(token as Token)
log.trace('[receiveOfflinePrepare]', 'receiveToken tokenMints', tokenMints)
if (tokenMints.length === 0) {
throw new AppError(
Err.VALIDATION_ERROR,
'Could not get any mint information from the ecash token.',
)
}
if (tokenMints.length > 1) {
throw new AppError(
Err.VALIDATION_ERROR,
'Wallet does not support receiving of tokens with entries from multiple mints.',
)
}
const mintToReceive = tokenMints[0]
// Let's create new draft receive transaction in database
transactionData.push({
status: TransactionStatus.DRAFT,
amountToReceive,
createdAt: new Date(),
})
const newTransaction: Transaction = {
type: TransactionType.RECEIVE_OFFLINE,
amount: amountToReceive,
data: JSON.stringify(transactionData),
memo,
mint: mintToReceive,
status: TransactionStatus.DRAFT,
}
const draftTransaction: TransactionRecord = await transactionsStore.addTransaction(newTransaction)
transactionId = draftTransaction.id as number
// Handle blocked mint
const isBlocked = mintsStore.isBlocked(mintToReceive)
if (isBlocked) {
const blockedTransaction = await transactionsStore.updateStatus(
transactionId,
TransactionStatus.BLOCKED,
JSON.stringify(transactionData),
)
return {
transaction: blockedTransaction,
message: `The mint ${mintToReceive} is blocked. You can unblock it in Settings.`,
} as TransactionResult
}
// Update transaction status
transactionData.push({
status: TransactionStatus.PREPARED_OFFLINE,
encodedToken, // TODO store in model, MVP initial implementation
createdAt: new Date(),
})
const preparedTransaction = await transactionsStore.updateStatus(
transactionId,
TransactionStatus.PREPARED_OFFLINE,
JSON.stringify(transactionData),
)
// TODO store as proofs in new model and redeem on internet connection?
/* const newProofs: Proof[] = []
for (const entry of token) {
for (const proof of entry.proofs) {
proof.tId = transactionId
proof.mintUrl = entry.mint //multimint support
newProofs.push(proof)
}
} */
return {
transaction: preparedTransaction,
message: `You received ${amountToReceive} sats while offline. You need to redeem them to your wallet when you will be online again.`,
} as TransactionResult
} catch (e: any) {
let errorTransaction: TransactionRecord | undefined = undefined
if (transactionId > 0) {
transactionData.push({
status: TransactionStatus.ERROR,
error: _formatError(e),
})
errorTransaction = await transactionsStore.updateStatus(
transactionId,
TransactionStatus.ERROR,
JSON.stringify(transactionData),
)
}
log.error(e.name, e.message)
return {
transaction: errorTransaction || undefined,
message: '',
error: _formatError(e),
} as TransactionResult
}
}
const receiveOfflineComplete = async function (
transaction: Transaction
) {
try {
const transactionData = JSON.parse(transaction.data)
const {encodedToken} = transactionData.find(
(record: any) => record.status === TransactionStatus.PREPARED_OFFLINE,
)
if (!encodedToken) {
throw new AppError(Err.VALIDATION_ERROR, 'Could not find ecash token to redeem', {caller: 'receiveOfflineComplete'})
}
const token = CashuUtils.decodeToken(encodedToken)
const tokenMints: string[] = CashuUtils.getMintsFromToken(token as Token)
const mintToReceive = tokenMints[0]
// Re-check blocked mint
const isBlocked = mintsStore.isBlocked(mintToReceive)
if (isBlocked) {
const blockedTransaction = await transactionsStore.updateStatus(
transaction.id as number,
TransactionStatus.BLOCKED,
JSON.stringify(transactionData),
)
return {
transaction: blockedTransaction,
message: `The mint ${mintToReceive} is blocked. You can unblock it in Settings.`,
} as TransactionResult
}
// Handle missing mint, we add it automatically
const alreadyExists = mintsStore.alreadyExists(mintToReceive)
if (!alreadyExists) {
const mintKeys: {
keys: MintKeys
keyset: string
} = await MintClient.getMintKeys(mintToReceive)
const newMint: Mint = {
mintUrl: mintToReceive,
keys: mintKeys.keys,
keysets: [mintKeys.keyset]
}
mintsStore.addMint(newMint)
}
// Now we ask all mints to get fresh outputs for their tokenEntries, and create from them new proofs
// 0.8.0-rc3 implements multimints receive however CashuMint constructor still expects single mintUrl
const {updatedToken, errorToken, newKeys} = await MintClient.receiveFromMint(
tokenMints[0],
encodedToken as string,
)
let amountWithErrors = 0
if (errorToken && errorToken.token.length > 0) {
amountWithErrors += CashuUtils.getTokenAmounts(errorToken as Token).totalAmount
log.warn('[receiveOfflineComplete]', 'receiveToken amountWithErrors', amountWithErrors)
}
if (amountWithErrors === transaction.amount) {
throw new AppError(
Err.VALIDATION_ERROR,
'Received ecash token is not valid and can not be redeemed.',
)
}
for (const entry of updatedToken.token) {
// create ProofModel instances and store them into the proofsStore
_addCashuProofs(
entry.proofs,
entry.mint,
transaction.id as number
)
}
// This should be amountToReceive - amountWithErrors but let's set it from updated token
const receivedAmount = CashuUtils.getTokenAmounts(updatedToken as Token).totalAmount
log.debug('[receiveOfflineComplete]', 'Received amount', receivedAmount)
// Update tx amount if full amount was not received
if (receivedAmount !== transaction.amount) {
await transactionsStore.updateReceivedAmount(
transaction.id as number,
receivedAmount,
)
}
// Finally, update completed transaction
transactionData.push({
status: TransactionStatus.COMPLETED,
receivedAmount,
amountWithErrors,
createdAt: new Date(),
})
const completedTransaction = await transactionsStore.updateStatus(
transaction.id as number,
TransactionStatus.COMPLETED,
JSON.stringify(transactionData),
)
const balanceAfter = proofsStore.getBalances().totalBalance
await transactionsStore.updateBalanceAfter(transaction.id as number, balanceAfter)
if (amountWithErrors > 0) {
return {
transaction: completedTransaction,
message: `You received ${receivedAmount} sats to your minibits wallet. ${amountWithErrors} could not be redeemed from the mint`,
receivedAmount,
} as TransactionResult
}
return {
transaction: completedTransaction,
message: `You received ${receivedAmount} sats to your minibits wallet.`,
receivedAmount,
} as TransactionResult
} catch (e: any) {
let errorTransaction: TransactionRecord | undefined = undefined
const transactionData = JSON.parse(transaction.data)
transactionData.push({
status: TransactionStatus.ERROR,
error: _formatError(e),
})
errorTransaction = await transactionsStore.updateStatus(
transaction.id as number,
TransactionStatus.ERROR,
JSON.stringify(transactionData),
)
log.error(e.name, e.message)
return {
transaction: errorTransaction || undefined,
message: '',
error: _formatError(e),
} as TransactionResult
}
}
const _sendFromMint = async function (
mintBalance: MintBalance,
amountToSend: number,
selectedProofs: Proof[],
transactionId: number,
) {
const mintUrl = mintBalance.mint
try {
const proofsFromMint = proofsStore.getByMint(mintUrl) as Proof[]
log.debug('[_sendFromMint]', 'proofsFromMint count', proofsFromMint.length)
if (proofsFromMint.length < 1) {
throw new AppError(
Err.VALIDATION_ERROR,
'Could not find ecash for the selected mint',
)
}
const totalAmountFromMint = CashuUtils.getProofsAmount(proofsFromMint)
if (totalAmountFromMint < amountToSend) {
throw new AppError(
Err.VALIDATION_ERROR,
'There is not enough funds to send this payment',
{totalAmountFromMint, amountToSend},
)
}
const selectedProofsAmount = CashuUtils.getProofsAmount(selectedProofs)
if(selectedProofsAmount > 0 && (amountToSend !== selectedProofsAmount)) { // failsafe for some unknown ecash selection UX error
throw new AppError(Err.VALIDATION_ERROR, 'Requested amount to send does not equal sum of ecash denominations provided.')
}
/*
* if we have selected ecash to send in offline mode, we do not interact with the mint
*/
if(selectedProofsAmount > 0) {
for (const proof of selectedProofs) {
proof.setTransactionId(transactionId) // update txId
}
// move sent proofs to pending
proofsStore.removeProofs(selectedProofs)
proofsStore.addProofs(selectedProofs, true) // pending true
// Clean private properties to not to send them out. This returns plain js array, not model objects.
const cleanedProofsToSend = selectedProofs.map(proof => {
const {mintUrl, tId, ...rest} = getSnapshot(proof)
return rest
})
// We return cleaned proofs to be encoded as a sendable token
return cleanedProofsToSend
}
/*
* if we do not have selected ecash and we might need a split of ecash by the mint to match exact amount
*/
const proofsToSendFrom = proofsStore.getProofsToSend(
amountToSend,
proofsFromMint,
)
log.debug('[_sendFromMint]', 'proofsToSendFrom', proofsToSendFrom)
// if split to required denominations was necessary, this gets it done with the mint and we get the return
const {returnedProofs, proofsToSend, newKeys} = await MintClient.sendFromMint(
mintUrl,
amountToSend,
proofsToSendFrom,
)
log.debug('[_sendFromMint]', 'returnedProofs', returnedProofs)
log.debug('[_sendFromMint]', 'proofsToSend', proofsToSend)
if (newKeys) {_updateMintKeys(mintUrl, newKeys)}
// these might be original proofToSendFrom if they matched the exact amount and split was not necessary
for (const proof of proofsToSend) {
if (isStateTreeNode(proof)) {
proof.setTransactionId(transactionId)
proof.setMintUrl(mintUrl)
} else {
;(proof as Proof).tId = transactionId
;(proof as Proof).mintUrl = mintUrl
}
}
// add proofs returned by the mint after the split
if (returnedProofs.length > 0) {
// these should be fresh proofs from the mint but let's be defensive and cover the case that we over-send proofs while matching denominations
for (const proof of returnedProofs) {
if (isStateTreeNode(proof)) {
proof.setTransactionId(transactionId)
proof.setMintUrl(mintUrl)
} else {
;(proof as Proof).tId = transactionId
;(proof as Proof).mintUrl = mintUrl
}
}
proofsStore.addProofs(returnedProofs)
}
// remove used proofs and move sent proofs to pending
proofsStore.removeProofs(proofsToSendFrom)
proofsStore.addProofs(proofsToSend, true) // pending true
// Clean private properties to not to send them out. This returns plain js array, not model objects.
const cleanedProofsToSend = proofsToSend.map(proof => {
if (isStateTreeNode(proof)) {
const {mintUrl, tId, ...rest} = getSnapshot(proof)
return rest
} else {
const {mintUrl, tId, ...rest} = proof as Proof
return rest
}
})
// We return cleaned proofs to be encoded as a sendable token
return cleanedProofsToSend
} catch (e: any) {
if (e instanceof AppError) {
throw e
} else {
throw new AppError(Err.WALLET_ERROR, e.message, e.stack.slice(0, 100))
}
}
}
const send = async function (
mintBalanceToSendFrom: MintBalance,
amountToSend: number,
memo: string,
selectedProofs: Proof[]
) {
const mintUrl = mintBalanceToSendFrom.mint
log.trace('[send]', 'mintBalanceToSendFrom', mintBalanceToSendFrom)
log.trace('[send]', 'amountToSend', amountToSend)
log.trace('[send]', 'memo', memo)
// create draft transaction
const transactionData: TransactionData[] = [
{
status: TransactionStatus.DRAFT,
mintBalanceToSendFrom,
createdAt: new Date(),
}
]
let transactionId: number = 0
try {
const newTransaction: Transaction = {
type: TransactionType.SEND,
amount: amountToSend,
data: JSON.stringify(transactionData),
memo,
mint: mintUrl,
status: TransactionStatus.DRAFT,
}
// store tx in db and in the model
const storedTransaction: TransactionRecord =
await transactionsStore.addTransaction(newTransaction)
transactionId = storedTransaction.id as number
// get ready proofs to send and update proofs and pending proofs storage
const proofsToSend = await _sendFromMint(
mintBalanceToSendFrom,
amountToSend,
selectedProofs,
transactionId,
)
// Update transaction status
transactionData.push({
status: TransactionStatus.PREPARED,
proofsToSend,
createdAt: new Date(),
})
await transactionsStore.updateStatus(
transactionId,
TransactionStatus.PREPARED,
JSON.stringify(transactionData),
)
// Create sendable encoded token v3
const tokenEntryToSend = {
mint: mintUrl,
proofs: proofsToSend,
}
if (!memo || memo === '') {
memo = 'Sent from Minibits wallet'
}
const encodedTokenToSend = getEncodedToken({
token: [tokenEntryToSend as CashuTokenEntry],
memo,
})
// Update transaction status
transactionData.push({
status: TransactionStatus.PENDING,
encodedTokenToSend,
createdAt: new Date(),
})
const pendingTransaction = await transactionsStore.updateStatus(
transactionId,
TransactionStatus.PENDING,
JSON.stringify(transactionData),
)
const balanceAfter = proofsStore.getBalances().totalBalance
await transactionsStore.updateBalanceAfter(transactionId, balanceAfter)
log.trace('[send] totalBalance after', balanceAfter)
// Start polling for accepted payment, what an ugly piece of code
poller(
'checkSpentByMintPoller',
() => _checkSpentByMint(mintUrl, true),
6 * 1000,
20,
5,
)
.then(() => log.trace('[checkSpentByMintPoller]', 'polling completed'))
.catch(error =>
log.error(
Err.POLLING_ERROR,
error.message,
{caller: '_checkSpentByMint'},
),
)
return {
transaction: pendingTransaction,
message: '',
encodedTokenToSend,
} as TransactionResult
} catch (e: any) {
// Update transaction status if we have any
let errorTransaction: TransactionRecord | undefined = undefined
if (transactionId > 0) {
transactionData.push({
status: TransactionStatus.ERROR,
error: _formatError(e),
})
errorTransaction = await transactionsStore.updateStatus(
transactionId,
TransactionStatus.ERROR,
JSON.stringify(transactionData),
)
}
return {
transaction: errorTransaction || undefined,
error: _formatError(e),
} as TransactionResult
}
}
const transfer = async function (
mintBalanceToTransferFrom: MintBalance,
amountToTransfer: number,
estimatedFee: number,
invoiceExpiry: Date,
memo: string,
encodedInvoice: string,
) {
const mintUrl = mintBalanceToTransferFrom.mint
log.debug('[transfer]', 'mintBalanceToTransferFrom', mintBalanceToTransferFrom)
log.debug('[transfer]', 'amountToTransfer', amountToTransfer)
log.debug('[transfer]', 'estimatedFee', estimatedFee)
if (amountToTransfer + estimatedFee > mintBalanceToTransferFrom.balance) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint balance is insufficient to cover the amount to transfer with expected Lightning fees.')
}
if(isBefore(invoiceExpiry, new Date())) {
throw new AppError(Err.VALIDATION_ERROR, 'This invoice has already expired and can not be paid.', {invoiceExpiry})
}
// create draft transaction
const transactionData: TransactionData[] = [
{
status: TransactionStatus.DRAFT,
mintBalanceToTransferFrom,
encodedInvoice,
amountToTransfer,
estimatedFee,
createdAt: new Date(),
}
]
let transactionId: number = 0
let proofsToPay: CashuProof[] = []
try {
const newTransaction: Transaction = {
type: TransactionType.TRANSFER,
amount: amountToTransfer,
fee: estimatedFee,
data: JSON.stringify(transactionData),
memo,
mint: mintBalanceToTransferFrom.mint,
status: TransactionStatus.DRAFT,
}
// store tx in db and in the model
const storedTransaction: TransactionRecord =
await transactionsStore.addTransaction(newTransaction)
transactionId = storedTransaction.id as number
// get proofs ready to be paid to the mint
proofsToPay = await _sendFromMint(
mintBalanceToTransferFrom,
amountToTransfer + estimatedFee,
[],
transactionId,
)
const proofsAmount = CashuUtils.getProofsAmount(proofsToPay as Proof[])
log.debug('[transfer]', 'Prepared poofsToPay amount', proofsAmount)
// Update transaction status
transactionData.push({
status: TransactionStatus.PREPARED,
createdAt: new Date(),
})
await transactionsStore.updateStatus(
transactionId,
TransactionStatus.PREPARED,
JSON.stringify(transactionData),
)
// Use prepared proofs to settle with the mint the payment of the invoice on wallet behalf
const {feeSavedProofs, isPaid, preimage, newKeys} =
await MintClient.payLightningInvoice(
mintUrl,
encodedInvoice,
proofsToPay,
estimatedFee,
)
if (newKeys) {_updateMintKeys(mintUrl, newKeys)}
// We've sent the proofsToPay to the mint, so we remove those pending proofs from model storage.
// Hopefully mint gets important shit done synchronously.
await _checkSpentByMint(mintUrl, true)
// I have no idea yet if this can happen, return sent Proofs to the store an track tx as Reverted
if (!isPaid) {
const { amountPendingByMint } = await _moveProofsFromPending(proofsToPay, mintUrl, transactionId)
if(amountPendingByMint > 0) {
transactionData.push({
status: TransactionStatus.PENDING,
amountPendingByMint,
})
const pendingTransaction = await transactionsStore.updateStatus(
transactionId,
TransactionStatus.PENDING,
JSON.stringify(transactionData),
)
// There is not clear way to get refund for saved fees later so we treat estimated fees as final
await transactionsStore.updateFee(transactionId, estimatedFee)
return {
transaction: pendingTransaction,
message: 'Lightning payment did not complete in time. Your ecash will remain pending until the payment completes or fails.',
} as TransactionResult
} else {
transactionData.push({
status: TransactionStatus.REVERTED,
createdAt: new Date(),
})
const revertedTransaction = await transactionsStore.updateStatus(
transactionId,
TransactionStatus.REVERTED,
JSON.stringify(transactionData),
)
return {
transaction: revertedTransaction,
message: 'Payment of lightning invoice failed. Coins were returned to your wallet.',
} as TransactionResult
}
}
// If real fees were less then estimated, cash the returned savings.
let finalFee = estimatedFee
if (feeSavedProofs.length) {
const {addedAmount: feeSaved} = _addCashuProofs(
feeSavedProofs,
mintUrl,
transactionId
)
finalFee = estimatedFee - feeSaved
}
// Save final fee in db
await transactionsStore.updateFee(transactionId, finalFee)
// Update transaction status
transactionData.push({
status: TransactionStatus.COMPLETED,
finalFee,
createdAt: new Date(),
})
const completedTransaction = await transactionsStore.updateStatus(
transactionId,
TransactionStatus.COMPLETED,
JSON.stringify(transactionData),
)
const balanceAfter = proofsStore.getBalances().totalBalance
await transactionsStore.updateBalanceAfter(transactionId, balanceAfter)
return {
transaction: completedTransaction,
message: `Lightning invoice has been successfully paid and settled with your minibits coins. Final network fee has been ${finalFee} sats.`,
finalFee,
} as TransactionResult
} catch (e: any) {
// Update transaction status if we have any
let errorTransaction: TransactionRecord | undefined = undefined
if (transactionId > 0) {
// Return tokens intended for payment to the wallet if payment failed with an error
if (proofsToPay.length > 0) {
log.warn('[transfer]', 'Returning proofsToPay to the wallet likely after failed lightning payment.', proofsToPay.length)
const {
amountToMove,
amountPendingByMint
} = await _moveProofsFromPending(proofsToPay, mintUrl, transactionId)
// keep tx as pending if proofs were not added because of a mint that keeps them as pending for timed out in-flight payment
if(amountPendingByMint > 0) {
transactionData.push({
status: TransactionStatus.PENDING,
error: _formatError(e),
amountToMove,
amountPendingByMint,
})
const pendingTransaction = await transactionsStore.updateStatus(
transactionId,
TransactionStatus.PENDING,
JSON.stringify(transactionData),
)
// There is not clear way to get refund for saved fees later so we treat estimated fees as final
await transactionsStore.updateFee(transactionId, estimatedFee)
return {
transaction: pendingTransaction,
message: 'Lightning payment did not complete in time. Your ecash will remain pending until the payment completes or fails.',
error: _formatError(e),
} as TransactionResult
}
}
transactionData.push({
status: TransactionStatus.ERROR,
error: _formatError(e),
})
errorTransaction = await transactionsStore.updateStatus(
transactionId,
TransactionStatus.ERROR,
JSON.stringify(transactionData),
)
}
log.error(e.name, e.message, {}, 'transfer')
return {
transaction: errorTransaction || undefined,
message: '',
error: _formatError(e),
} as TransactionResult
}
}
const _addCashuProofs = function (
proofsToAdd: CashuProof[],
mintUrl: string,
transactionId: number
): {
amountToAdd: number,
addedAmount: number
} {
// Add internal references
for (const proof of proofsToAdd as Proof[]) {
proof.tId = transactionId
proof.mintUrl = mintUrl
}
const amountToAdd = CashuUtils.getProofsAmount(proofsToAdd as Proof[])
// Creates proper model instances and adds them to the wallet
const addedAmount = proofsStore.addProofs(proofsToAdd as Proof[])
log.trace('[_addCashuProofs]', 'Added proofs to the wallet with amount', { amountToAdd, addedAmount })
return {
amountToAdd,
addedAmount
}
}
const _moveProofsFromPending = async function (
proofsToMove: CashuProof[],
mintUrl: string,
transactionId: number,
): Promise<{
amountToMove: number,
amountPendingByMint: number,
movedAmount: number
}> {
// Add internal references
for (const proof of proofsToMove as Proof[]) {
proof.tId = transactionId
proof.mintUrl = mintUrl
}
const amountToMove = CashuUtils.getProofsAmount(proofsToMove as Proof[])
// Here we move proofs from pending back to spendable wallet in case of lightning payment failure
// Check with the mint if the proofs are not marked as pending. This happens when lightning payment fails
// due to the timeout but mint's node keeps the payment as in-flight (e.g. receiving node holds the invoice)
// In this case we need to keep such proofs as pending and not move them back to wallet as in other payment failures.
const {pending: pendingByMint} = await MintClient.getSpentOrPendingProofsFromMint(
mintUrl,
proofsToMove as Proof[],
)
let amountPendingByMint: number = 0
let movedAmount: number = 0
const movedProofs: Proof[] = []
for (const proof of proofsToMove) {
// if proof to return to the wallet is tracked by mint as pending we do not move it from wallet's pending
if(pendingByMint.some(p => p.secret === proof.secret)){
// add it to the list of secrets so we can later handle them based on eventual lightning payment result
if(proofsStore.addToPendingByMint(proof as Proof)) {
amountPendingByMint += proof.amount
}
} else {
movedAmount += proof.amount
movedProofs.push(proof as Proof)
}
}
if(movedProofs.length > 0) {
// remove it from pending proofs in the wallet
proofsStore.removeProofs(movedProofs, true, true)
// add proofs back to the spendable wallet
proofsStore.addProofs(movedProofs)
log.trace('[_moveProofsFromPending]', 'Moved proofs back from pending to spendable', {amountToMove, amountPendingByMint, movedAmount})
}
if(amountPendingByMint > 0 && amountPendingByMint !== amountToMove) {
// This should not happen, monitor
log.warn('[_moveProofsFromPending]', 'Not all proofs to be moved were pending by the mint')
}
return {
amountToMove,
amountPendingByMint,
movedAmount
}
}
const topup = async function (
mintBalanceToTopup: MintBalance,
amountToTopup: number,
memo: string,
contactToSendTo?: Contact
) {
log.info('[topup]', 'mintBalanceToTopup', mintBalanceToTopup)
log.info('[topup]', 'amountToTopup', amountToTopup)
// create draft transaction
const transactionData: TransactionData[] = [
{
status: TransactionStatus.DRAFT,
amountToTopup,
createdAt: new Date(),
},
]
let transactionId: number = 0
try {
const mintUrl = mintBalanceToTopup.mint
const newTransaction: Transaction = {
type: TransactionType.TOPUP,
amount: amountToTopup,
data: JSON.stringify(transactionData),
memo,
mint: mintUrl,
status: TransactionStatus.DRAFT,
}
// store tx in db and in the model
const storedTransaction: TransactionRecord = await transactionsStore.addTransaction(newTransaction)
transactionId = storedTransaction.id as number
const {encodedInvoice, paymentHash} = await MintClient.requestLightningInvoice(mintUrl, amountToTopup)
const decodedInvoice = LightningUtils.decodeInvoice(encodedInvoice)
const {amount, expiry, timestamp} = LightningUtils.getInvoiceData(decodedInvoice)
if (amount !== amountToTopup) {
throw new AppError(
Err.MINT_ERROR,
'Received lightning invoice amount does not equal requested top-up amount.',
)
}
// sender is current wallet profile
const {
pubkey,
npub,
name,
nip05,
picture,
} = walletProfileStore
const contactFrom: Contact = {
pubkey,
npub,
name,
nip05,
picture
}
const newPaymentRequest: PaymentRequest = {
type: PaymentRequestType.OUTGOING,
status: PaymentRequestStatus.ACTIVE,
mint: mintUrl,
encodedInvoice,
amount,
description: memo ? memo : contactToSendTo ? `Pay to ${walletProfileStore.nip05}` : '',
paymentHash,
contactFrom,
contactTo: contactToSendTo ? getSnapshot(contactToSendTo) : undefined,
expiry: expiry || 600,
transactionId,
createdAt: timestamp ? new Date(timestamp * 1000) : new Date()
}
// This calculates and sets expiresAt
const paymentRequest = paymentRequestsStore.addPaymentRequest(newPaymentRequest)
transactionData.push({
status: TransactionStatus.PENDING,
paymentRequest,
})
const pendingTransaction = await transactionsStore.updateStatus(
transactionId,
TransactionStatus.PENDING,
JSON.stringify(transactionData),
)
poller('checkPendingTopupsPoller', checkPendingTopups, 6 * 1000, 20, 5)
.then(() => log.trace('Polling completed', [], 'checkPendingTopups'))
.catch(error =>
log.warn(error.message, [], 'checkPendingTopups'),
)
return {
transaction: pendingTransaction,
message: '',
encodedInvoice,
} as TransactionResult
} catch (e: any) {
let errorTransaction: TransactionRecord | undefined = undefined
if (transactionId > 0) {
transactionData.push({
status: TransactionStatus.ERROR,
error: _formatError(e),
})
errorTransaction = await transactionsStore.updateStatus(
transactionId,
TransactionStatus.ERROR,
JSON.stringify(transactionData),
)
}
log.error(e.name, e.message)
return {
transaction: errorTransaction || undefined,
message: '',
error: _formatError(e),
} as TransactionResult
}
}
const checkPendingTopups = async function () {
const paymentRequests: PaymentRequest[] = paymentRequestsStore.allOutgoing
if (paymentRequests.length === 0) {
log.trace('[checkPendingTopups]', 'No outgoing payment requests in store')
return
}
try {
for (const pr of paymentRequests) {
// claim tokens if invoice is paid
const {proofs, newKeys} = (await MintClient.requestProofs(
pr.mint as string,
pr.amount,
pr.paymentHash,
)) as {proofs: Proof[], newKeys: MintKeys}
if (!proofs || proofs.length === 0) {
log.trace('[checkPendingTopups]', 'No proofs returned from mint')
// remove already expired invoices only after check that they have not been paid
if (isBefore(pr.expiresAt as Date, new Date())) {
log.debug('[checkPendingTopups]', `Invoice expired, removing: ${pr.paymentHash}`)
const transactionId = pr.transactionId
paymentRequestsStore.removePaymentRequest(pr)
// expire related tx - but only if it has not been completed before this check
const transaction = transactionsStore.findById(transactionId as number)
if(transaction && transaction.status !== TransactionStatus.COMPLETED) {
const transactionDataUpdate = {
status: TransactionStatus.EXPIRED,
createdAt: new Date(),
}
transactionsStore.updateStatuses(
[transactionId as number],
TransactionStatus.EXPIRED,
JSON.stringify(transactionDataUpdate),
)
}
}
continue
}
if(newKeys) {_updateMintKeys(pr.mint as string, newKeys)}
// accept to the wallet whatever we've got
const {addedAmount: receivedAmount} = _addCashuProofs(
proofs,
pr.mint as string,
pr.transactionId as number
)
if (receivedAmount !== pr.amount) {
throw new AppError(
Err.VALIDATION_ERROR,
`Received amount ${receivedAmount} sats is not equal to the requested amount ${pr.amount} sats.`,
)
}
// update related tx
const transactionDataUpdate = {
status: TransactionStatus.COMPLETED,
createdAt: new Date(),
}
transactionsStore.updateStatus(
pr.transactionId as number,
TransactionStatus.COMPLETED,
JSON.stringify(transactionDataUpdate),
)
// Fire event that the TopupScreen can listen to
EventEmitter.emit('topupCompleted', pr)
stopPolling('checkPendingTopupsPoller')
// delete paid pr if we've got our cash
paymentRequestsStore.removePaymentRequest(pr)
// Update tx with current balance
const balanceAfter = proofsStore.getBalances().totalBalance
await transactionsStore.updateBalanceAfter(
pr.transactionId as number,
balanceAfter,
)
}
} catch (e: any) {
// silent
log.warn(e.name, e.message, 'checkPendingTopups')
}
}
const _updateMintKeys = function (mintUrl: string, newKeys: MintKeys) {
if(!CashuUtils.validateMintKeys(newKeys)) {
// silent
log.warn('[_updateMintKeys]', 'Invalid mint keys to update, skipping', newKeys)
return
}
const keyset = deriveKeysetId(newKeys)
const mint = mintsStore.findByUrl(mintUrl)
return mint?.updateKeys(keyset, newKeys)
}
const _formatError = function (e: AppError) {
return {
name: e.name,
message: e.message.slice(0, 100),
params: e.params ? e.params.toString().slice(0, 200) : '',
} as AppError
}
export const Wallet: WalletService = {
checkPendingSpent,
checkPendingReceived,
checkSpent,
checkPendingTopups,
transfer,
receive,
receiveOfflinePrepare,
receiveOfflineComplete,
send,
topup,
}