Files
minibits_wallet/src/services/wallet/transferTask.ts
T

411 lines
15 KiB
TypeScript

import {CashuUtils} from '../cashu/cashuUtils'
import AppError, {Err} from '../../utils/AppError'
import {MeltProofsResponse, MeltQuoteResponse, MeltQuoteState, getEncodedToken} from '@cashu/cashu-ts'
import {rootStoreInstance} from '../../models'
import { TransactionTaskResult, WalletTask } from '../walletService'
import { MintBalance, MintProofsCounter } from '../../models/Mint'
import { Proof } from '../../models/Proof'
import { Transaction, TransactionData, TransactionRecord, TransactionStatus, TransactionType } from '../../models/Transaction'
import { log } from '../logService'
import { WalletUtils } from './utils'
import {isBefore} from 'date-fns'
import { MintUnit, formatCurrency, getCurrency } from './currency'
import { NostrEvent } from '../nostrService'
import { LightningUtils } from '../lightning/lightningUtils'
const {
transactionsStore,
mintsStore,
proofsStore,
walletStore,
} = rootStoreInstance
// const {walletStore} = nonPersistedStores
export const TRANSFER_TASK = 'transferTask'
export const transferTask = async function (
mintBalanceToTransferFrom: MintBalance,
amountToTransfer: number,
unit: MintUnit,
meltQuote: MeltQuoteResponse,
memo: string,
invoiceExpiry: Date,
encodedInvoice: string,
nwcEvent?: NostrEvent
) : Promise<TransactionTaskResult> {
const mintUrl = mintBalanceToTransferFrom.mintUrl
const mintInstance = mintsStore.findByUrl(mintUrl)
// TODO refresh - balance might be outdated if it waits in queue before other txs
log.debug('[transfer]', 'mintBalanceToTransferFrom', {mintBalanceToTransferFrom})
log.debug('[transfer]', 'amountToTransfer', {amountToTransfer})
log.debug('[transfer]', 'meltQuote', {meltQuote})
// create draft transaction
const transactionData: TransactionData[] = [
{
status: TransactionStatus.DRAFT,
mintBalanceToTransferFrom,
amountToTransfer,
unit,
meltQuote,
encodedInvoice,
isNwc: nwcEvent ? true : false,
createdAt: new Date(),
}
]
let transaction: Transaction | undefined = undefined
let proofsToMeltFrom: Proof[] = []
let proofsToMeltFromAmount: number = 0
let meltFeeReserve: number = 0
let meltResponse: MeltProofsResponse
try {
const newTransaction = {
type: TransactionType.TRANSFER,
amount: amountToTransfer,
fee: meltQuote.fee_reserve,
unit,
data: JSON.stringify(transactionData),
memo,
mint: mintBalanceToTransferFrom.mintUrl,
status: TransactionStatus.DRAFT,
}
// store tx in db and in the model
transaction = await transactionsStore.addTransaction(newTransaction)
const transactionId = transaction.id
const paymentHash = LightningUtils.getInvoiceData(LightningUtils.decodeInvoice(encodedInvoice)).payment_hash
transaction.setPaymentId(paymentHash)
if (amountToTransfer + meltQuote.fee_reserve > mintBalanceToTransferFrom.balances[unit]!) {
throw new AppError(
Err.VALIDATION_ERROR,
'Mint balance is insufficient to cover the amount to transfer with the expected Lightning fees.',
{transactionId}
)
}
if(isBefore(invoiceExpiry, new Date())) {
throw new AppError(
Err.VALIDATION_ERROR,
'This invoice has already expired and can not be paid.',
{invoiceExpiry, transactionId}
)
}
if (!mintInstance) {
throw new AppError(
Err.VALIDATION_ERROR,
'Could not find mint', {mintUrl, transactionId}
)
}
// calculate fees charged by mint for melt transaction to prepare enough proofs
const proofsFromMint = proofsStore.getByMint(mintUrl, {isPending: false, unit})
const totalAmountFromMint = CashuUtils.getProofsAmount(proofsFromMint)
proofsToMeltFrom = CashuUtils.getProofsToSend(
amountToTransfer + meltQuote.fee_reserve,
proofsFromMint
)
proofsToMeltFromAmount = CashuUtils.getProofsAmount(proofsToMeltFrom)
const walletInstance = await walletStore.getWallet(mintUrl, unit, {withSeed: true})
meltFeeReserve = walletInstance.getFeesForProofs(proofsToMeltFrom)
const amountWithFees = amountToTransfer + meltQuote.fee_reserve + meltFeeReserve
if (totalAmountFromMint < amountWithFees) {
throw new AppError(
Err.VALIDATION_ERROR,
'There is not enough funds to send this amount.',
{totalAmountFromMint, amountWithFees, transactionId, caller: 'transferTask'},
)
}
// exact match or min number of proofs that matches the amount
if(meltFeeReserve > 0) {
proofsToMeltFrom = CashuUtils.getProofsToSend(
amountWithFees,
proofsFromMint
)
proofsToMeltFromAmount = CashuUtils.getProofsAmount(proofsToMeltFrom)
}
// move proofs to PENDING state
proofsStore.removeProofs(proofsToMeltFrom)
WalletUtils.addCashuProofs(
mintUrl,
proofsToMeltFrom,
{
unit,
transactionId: transaction.id,
isPending: true
}
)
log.trace('[transfer]', 'Prepared proofsToMeltFrom proofs', {
proofsToMeltFromAmount,
transactionId,
unit,
})
// Update transaction status
transactionData.push({
status: TransactionStatus.PREPARED,
proofsToMeltFromAmount,
lightningFeeReserve: meltQuote.fee_reserve,
meltFeeReserve,
createdAt: new Date(),
})
transaction.setStatus(
TransactionStatus.PREPARED,
JSON.stringify(transactionData),
)
const inputToken = getEncodedToken({
mint: mintUrl,
proofs: proofsToMeltFrom,
unit
})
transaction.setInputToken(inputToken)
try {
meltResponse = await walletStore.payLightningMelt(
mintUrl,
unit,
meltQuote,
proofsToMeltFrom,
transactionId,
)
} catch (e: any) {
if(e.params && e.params.message.includes('outputs have already been signed before')) {
log.error('[transferTask] Increasing proofsCounter outdated values and repeating payLightningMelt.')
meltResponse = await walletStore.payLightningMelt(
mintUrl,
unit,
meltQuote,
proofsToMeltFrom,
transactionId,
{increaseCounterBy: 10}
)
} else {
throw e
}
}
if (meltResponse.quote.state === MeltQuoteState.PAID) {
log.debug('[transfer] Invoice PAID', {
transactionId
})
// Spend pending proofs that were used to settle the lightning invoice
proofsStore.removeProofs(proofsToMeltFrom as Proof[], true, false)
// Save preimage asap
if(meltResponse.quote.payment_preimage) {
transaction.setProof(meltResponse.quote.payment_preimage)
}
let totalFeePaid = proofsToMeltFromAmount - amountToTransfer
let lightningFeePaid = totalFeePaid - meltFeeReserve
let meltFeePaid = meltFeeReserve
let returnedAmount = CashuUtils.getProofsAmount(meltResponse.change)
if(meltResponse.change.length > 0) {
WalletUtils.addCashuProofs(
mintUrl,
meltResponse.change,
{
unit,
transactionId: transaction.id,
isPending: false
}
)
const outputToken = getEncodedToken({
mint: mintUrl,
proofs: meltResponse.change,
unit,
})
transaction.setOutputToken(outputToken)
totalFeePaid = totalFeePaid - returnedAmount
lightningFeePaid = totalFeePaid - meltFeeReserve
}
// Save final fee in db
if(totalFeePaid !== transaction.fee) {
transaction.setFee(totalFeePaid)
}
// Update transaction status
transactionData.push({
status: TransactionStatus.COMPLETED,
lightningFeePaid,
meltFeePaid,
returnedAmount,
preimage: meltResponse.quote.payment_preimage,
createdAt: new Date(),
})
transaction.setStatus(
TransactionStatus.COMPLETED,
JSON.stringify(transactionData),
)
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance!
transaction.setBalanceAfter(balanceAfter)
return {
taskFunction: TRANSFER_TASK,
mintUrl,
transaction,
message: `Lightning invoice has been successfully paid and settled with your Minibits ecash. Fee has been ${formatCurrency(transaction.fee, getCurrency(unit).code)} ${getCurrency(unit).code}.`,
lightningFeePaid,
meltFeePaid,
totalFeePaid,
meltQuote: meltResponse.quote,
preimage: meltResponse.quote.payment_preimage,
nwcEvent
} as TransactionTaskResult
} else if(meltResponse.quote.state === MeltQuoteState.PENDING) {
log.debug('[transfer] Invoice PENDING', {
metResponseQuote: meltResponse.quote,
transactionId
})
return {
taskFunction: TRANSFER_TASK,
mintUrl,
transaction,
message: `Lightning payment did not complete in time. Your ecash will remain pending until the payment completes or fails.`,
meltQuote,
nwcEvent
} as TransactionTaskResult
} else {
// throw so that proper state of proofs is synced inside the catch block
throw new AppError(Err.MINT_ERROR, 'Lightning payment has not been paid.', {
metResponseQuote: meltResponse.quote,
transactionId
})
}
} catch (e: any) {
let message = e.message
let taskResult: TransactionTaskResult = {
taskFunction: TRANSFER_TASK,
mintUrl,
transaction,
message,
nwcEvent
} as TransactionTaskResult
if (transaction) {
if (proofsToMeltFrom.length > 0) {
const walletInstance = await walletStore.getWallet(mintUrl, unit, {withSeed: true})
const refreshedMeltQuote = await walletInstance.checkMeltQuote(meltQuote.quote)
taskResult.meltQuote = refreshedMeltQuote
if(refreshedMeltQuote.state === MeltQuoteState.PAID) {
message = `Lightning invoice has been successfully paid, however some error occured: ${e.message}`
log.error('[transfer]', message, {
error: e.message,
refreshedMeltQuote,
unit,
transactionId: transaction.id
})
taskResult.preimage = refreshedMeltQuote.payment_preimage
taskResult.message = message
} else if(refreshedMeltQuote.state === MeltQuoteState.PENDING) {
message = 'Lightning payment did not complete in time. Your ecash will remain pending until the payment completes or fails.'
taskResult.message = message
log.error('[transfer]', message, {
error: `${e.message}: ${e.params.message}`,
unit,
transactionId: transaction.id
})
} else {
if (e.params && e.params.message && e.params.message.includes('Token already spent')) {
message = 'Token already spent, going to sync wallet pending proofs with the mint.'
taskResult.message = message
log.error('[transfer]', message, {
transactionId: transaction.id
})
} else if (e.params && e.params.message && e.params.message.includes('Proofs are pending')) {
// if melt quote is UNPAID wallet used pending by mint proofs for this transaction
// we do not want to return them to spendable but keep them pending
message = 'Pending proofs were used for this transaction, going to sync proofsToMeltFrom with the mint.'
taskResult.message = message
log.error('[transfer]', message, {
transactionId: transaction.id
})
await WalletTask.syncStateWithMintTask({
proofsToSync: proofsToMeltFrom,
mintUrl,
isPending: true
})
} else {
// if melt quote is UNPAID return proofs from pending to spendable balance
proofsStore.removeProofs(proofsToMeltFrom, true, true)
proofsStore.addProofs(proofsToMeltFrom)
message = "Ecash reserved for this payment was returned to spendable balance."
log.error('[transfer]', message, {
proofsToMeltFromAmount,
transactionId: transaction.id
})
}
}
await WalletTask.syncStateWithMintTask({
proofsToSync: proofsStore.getByMint(mintUrl, {isPending: true, unit}),
mintUrl,
isPending: true
})
}
transactionData.push({
status: TransactionStatus.ERROR,
error: WalletUtils.formatError(e),
createdAt: new Date()
})
transaction.setStatus(
TransactionStatus.ERROR,
JSON.stringify(transactionData),
)
}
return taskResult
}
}