Important speedup and promise of improved reliability for zaps sent over NWC and paid from Minibits mint

This commit is contained in:
minibits-cash
2024-11-19 23:11:11 +01:00
parent 125b4bd040
commit 66e2878019
18 changed files with 934 additions and 479 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "minibits_wallet",
"version": "0.1.9-beta.34",
"version": "0.1.9-beta.35",
"private": true,
"scripts": {
"android:clean": "cd android && ./gradlew clean",
@@ -19,7 +19,7 @@
"postinstall": "patch-package --exclude 'nothing'"
},
"dependencies": {
"@cashu/cashu-ts": "minibits-cash/cashu-ts#commit=1a60b7859627829dc29293d78a945095c335de63",
"@cashu/cashu-ts": "minibits-cash/cashu-ts#commit=8dcac7ce6d192c419aee5547f0db03c23b593a57",
"@exodus/borc": "^2.1.1",
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/free-brands-svg-icons": "^6.6.0",
+1 -1
View File
@@ -379,7 +379,7 @@ export const MintModel = types
// Calculate the fees
const feeReserve = Math.max(Math.floor((totalInputFees + 999) / 1000), 0)
log.debug('[getMintFeeReserve]', {feeReserve})
log.trace('[getMintFeeReserve]', {feeReserve})
return feeReserve
}
}))
+64 -40
View File
@@ -34,6 +34,8 @@ import { roundUp } from '../utils/number'
import { PaymentRequest } from './PaymentRequest'
import { PaymentRequests } from './PaymentRequestsStore'
import { transaction } from 'mobx'
import { MINIBITS_MINT_URL } from '@env'
import { MintBalance } from './Mint'
type NwcError = {
result_type: string,
@@ -75,6 +77,9 @@ const getConnectionRelays = function () {
return minibitsRelays
}
const MIN_LIGHTNING_FEE = 2 // sats
const LIGHTNING_FEE_PERCENT = 1
const getSupportedMethods = function () {
return [
'pay_invoice',
@@ -209,7 +214,7 @@ export const NwcConnectionModel = types.model('NwcConnection', {
log.debug('[handleTransferTaskResult] Got transfer task result', {
connection: self.name,
meltQuote: result.meltQuote?.quote,
txId: result.transaction?.id
txId: result.transaction?.id,
})
if(result.meltQuote?.quote === self.lastMeltQuoteId) {
@@ -453,12 +458,18 @@ export const NwcConnectionModel = types.model('NwcConnection', {
} as NwcResponse
},
handlePayInvoice: flow(function* handlePayInvoice(nwcRequest: NwcRequest, requestEvent: NostrEvent) {
log.trace('[Nwc.handlePayInvoice] start')
log.debug('[Nwc.handlePayInvoice] start')
try {
const encoded = nwcRequest.params.invoice
const walletStore = self.getWalletStore()
const proofsStore = self.getProofsStore()
const proofsStore = self.getProofsStore()
// reset daily limit if day changed while keeping live connection
if(!isSameDay(self.currentDay, new Date())) {
self.setRemainingDailyLimit(self.dailyLimit)
self.setCurrentDay()
}
const invoice = LightningUtils.decodeInvoice(encoded)
@@ -470,34 +481,25 @@ export const NwcConnectionModel = types.model('NwcConnection', {
} = LightningUtils.getInvoiceData(invoice)
const invoiceExpiry = addSeconds(new Date(timestamp as number * 1000), expiry as number)
// Calculated on device to avoid mintQuote call for minibits mint
const feeReserve = Math.max(MIN_LIGHTNING_FEE, amountToPay * LIGHTNING_FEE_PERCENT / 100)
const totalAmountToPay = amountToPay + feeReserve
const mintBalance = proofsStore.getMintBalanceWithMaxBalance('sat')
let mintBalance: MintBalance | undefined = undefined
let isMinibitsMintSelected: boolean = false
const minibitsBalance = proofsStore.getMintBalance(MINIBITS_MINT_URL)
if(minibitsBalance && minibitsBalance.balances.sat! >= totalAmountToPay) {
mintBalance = minibitsBalance
isMinibitsMintSelected = true
} else {
mintBalance = proofsStore.getMintBalanceWithMaxBalance('sat')
}
const availableBalanceSat = mintBalance?.balances.sat || 0
if(!mintBalance || availableBalanceSat < amountToPay) { // decoded amount is in sat
const message = `Insufficient balance to pay this invoice`
return {
result_type: nwcRequest.method,
error: { code: 'INSUFFICIENT_BALANCE', message}
} as NwcError
}
// melt quote
const meltQuote: MeltQuoteResponse = yield walletStore.createLightningMeltQuote(
mintBalance.mintUrl,
'sat',
encoded,
)
const totalAmountToPay = meltQuote.amount + meltQuote.fee_reserve
// reset daily limit if day changed while keeping live connection
if(!isSameDay(self.currentDay, new Date())) {
self.setRemainingDailyLimit(self.dailyLimit)
self.setCurrentDay()
}
if(availableBalanceSat < totalAmountToPay) {
if(!mintBalance || availableBalanceSat < totalAmountToPay) {
const message = `Insufficient balance to pay this invoice.`
return {
result_type: nwcRequest.method,
@@ -512,18 +514,38 @@ export const NwcConnectionModel = types.model('NwcConnection', {
error: { code: 'QUOTA_EXCEEDED', message}
} as NwcError
}
// Jachyme, hod ho do stroje!
WalletTask.transfer(
mintBalance,
amountToPay,
'sat',
meltQuote,
description || '',
invoiceExpiry as Date,
encoded,
requestEvent
)
if(isMinibitsMintSelected) {
// Process the payment partly on the server side to avoid android background processing failures
WalletTask.nwcTransfer(
mintBalance,
amountToPay,
feeReserve,
'sat',
description || '',
invoiceExpiry as Date,
encoded,
requestEvent
)
} else {
// Full process on device for other mints so that minibits server does not touch foreign ecash
const meltQuote: MeltQuoteResponse = yield walletStore.createLightningMeltQuote(
mintBalance.mintUrl,
'sat',
encoded,
)
WalletTask.transfer(
mintBalance,
amountToPay,
'sat',
meltQuote,
description || '',
invoiceExpiry as Date,
encoded,
requestEvent
)
}
} catch (e: any) {
log.error(`[NwcConnection.handlePayInvoice] ${e.message}`)
@@ -773,6 +795,7 @@ export const NwcStoreModel = types
})
EventEmitter.on('ev_transferTask_result', self.handleTransferResult)
EventEmitter.on('ev_nwcTransferTask_result', self.handleTransferResult)
EventEmitter.on('ev_topupTask_result', self.handleTopupResult)
} catch (e: any) {
@@ -794,6 +817,7 @@ export const NwcStoreModel = types
yield targetConnection.handleRequest(event)
EventEmitter.on('ev_transferTask_result', self.handleTransferResult)
EventEmitter.on('ev_nwcTransferTask_result', self.handleTransferResult)
EventEmitter.on('ev_topupTask_result', self.handleTopupResult)
})
+3 -3
View File
@@ -174,7 +174,7 @@ export const ProofsStoreModel = types
proofsCounter.increaseProofsCounter(keysetProofs.length)
}
log.info('[addProofs]', `Added new ${addedProofs.length}${isPending ? ' pending' : ''} proofs to the ProofsStore`)
log.trace('[addProofs]', `Added new ${addedProofs.length}${isPending ? ' pending' : ''} proofs to the ProofsStore`)
if (addedProofs.length > 0) {
Database.addOrUpdateProofs(addedProofs, isPending) // isSpent = false
@@ -210,7 +210,7 @@ export const ProofsStoreModel = types
proofs.replace(proofs.filter(proof => !proofsToRemove.some(removed => removed.secret === proof.secret)))
log.debug('[removeProofs]', `${count} ${(isPending) ? 'pending' : ''} proofs removed from ProofsStore`)
log.trace('[removeProofs]', `${count} ${(isPending) ? 'pending' : ''} proofs removed from ProofsStore`)
} catch (e: any) {
throw new AppError(Err.STORAGE_ERROR, e.message.toString())
@@ -411,7 +411,7 @@ export const ProofsStoreModel = types
},
getMintBalanceWithMaxBalance: (unit: MintUnit) => {
const balances = self.getBalances().mintBalances
let maxBalance = null;
let maxBalance = undefined;
let maxAmount = -Infinity;
for (const balance of balances) {
+3 -2
View File
@@ -39,6 +39,7 @@ export enum TransactionType {
RECEIVE_NOSTR = 'RECEIVE_NOSTR', // not used
TOPUP = 'TOPUP',
TRANSFER = 'TRANSFER',
NWC_TRANSFER = 'NWC_TRANSFER',
}
export enum TransactionStatus {
@@ -140,12 +141,12 @@ export const TransactionModel = types
setInputToken(inputToken: string) {
Database.updateInputToken(self.id!, inputToken)
self.inputToken = inputToken
log.debug('[setInputToken]', 'Transaction inputToken updated', {id: self.id, inputToken})
log.trace('[setInputToken]', 'Transaction inputToken updated', {id: self.id, inputToken})
},
setOutputToken(outputToken: string) {
Database.updateOutputToken(self.id!, outputToken)
self.outputToken = outputToken
log.trace('[setOutputToken]', 'Transaction outputToken updated', {id: self.id, outputToken})
log.trace('[setOutputToken]', 'Transaction outputToken updated', {id: self.id})
},
setProof(proof: string) {
Database.updateProof(self.id!, proof)
+1 -1
View File
@@ -189,7 +189,7 @@ export const TransactionsStoreModel = types
self.history.unshift(reference!)
self.recentByUnit.unshift(reference!)
log.debug('[addTransaction]', 'New transaction added to the TransactionsStore', {id})
// log.trace('[addTransaction]', 'New transaction added to the TransactionsStore', {id})
// Purge the oldest references from cache, but keep some for each mint
self.pruneRecentByUnit(newTransaction.unit)
+10 -12
View File
@@ -214,8 +214,7 @@ export const WalletStoreModel = types
keysetId?: string
withSeed: boolean
}
) {
log.trace('[WalletStore.getWallet] start', {mintUrl})
) {
// syncs mint model in wallet state and returns cashu-ts mint class instance
const cashuMint = yield self.getMint(mintUrl)
@@ -315,8 +314,7 @@ export const WalletStoreModel = types
log.trace('[WalletStore.getWallet]', 'Returning NEW cashuWallet instance', {mintUrl})
return newWallet
}),
getMintKeysets: flow(function* getMintKeysets(mintUrl: string) {
log.trace('[getMintKeysets] start')
getMintKeysets: flow(function* getMintKeysets(mintUrl: string) {
const cashuMint: CashuMint = yield self.getMint(mintUrl)
try {
@@ -350,7 +348,7 @@ export const WalletStoreModel = types
mintUrl: string,
unit: MintUnit,
decodedToken: TokenV3,
mintFeeReserve: number,
swapFeeReserve: number,
options: {
preference: AmountPreference[],
counter: number
@@ -368,7 +366,7 @@ export const WalletStoreModel = types
const proofs = yield cashuWallet.receive(
decodedToken,
mintFeeReserve,
swapFeeReserve,
{
keysetId: cashuWallet.keys.id,
preference: options.preference,
@@ -378,9 +376,9 @@ export const WalletStoreModel = types
})
const receivedAmount: number = CashuUtils.getProofsAmount(proofs as Proof[])
const mintFeePaid = amountToReceive - receivedAmount
const swapFeePaid = amountToReceive - receivedAmount
return {proofs, mintFeePaid}
return {proofs, swapFeePaid}
} catch (e: any) {
throw new AppError(
Err.MINT_ERROR,
@@ -391,7 +389,7 @@ export const WalletStoreModel = types
}),
send: flow(function* send(mintUrl: string,
amountToSend: number,
mintFeeReserve: number,
swapFeeReserve: number,
unit: MintUnit,
proofsToSendFrom: Proof[],
options: {
@@ -410,7 +408,7 @@ export const WalletStoreModel = types
const {returnChange, send} = yield cashuWallet.send(
amountToSend,
mintFeeReserve,
swapFeeReserve,
proofsToSendFrom,
{
keysetId: cashuWallet.keys.id,
@@ -436,13 +434,13 @@ export const WalletStoreModel = types
)
}
const mintFeePaid = totalAmountToSendFrom - amountToSend - returnedAmount
const swapFeePaid = totalAmountToSendFrom - amountToSend - returnedAmount
// we normalize naming of returned parameters
return {
returnedProofs: returnChange as Proof[],
proofsToSend: send as Proof[],
mintFeePaid
swapFeePaid
}
} catch (e: any) {
let message = 'The mint could not return signatures necessary for this transaction'
+21 -7
View File
@@ -185,7 +185,7 @@ export const TranDetailScreen: FC<TransactionsStackScreenProps<'TranDetail'>> =
return `-${formatCurrency(transaction.amount, getCurrency(transaction.unit).code)}`
case TransactionType.TOPUP:
return `+${formatCurrency(transaction.amount, getCurrency(transaction.unit).code)}`
case TransactionType.TRANSFER:
case TransactionType.TRANSFER || TransactionType.NWC_TRANSFER:
return `-${formatCurrency(transaction.amount, getCurrency(transaction.unit).code)}`
default:
return `${formatCurrency(transaction.amount, getCurrency(transaction.unit).code)}`
@@ -305,6 +305,14 @@ export const TranDetailScreen: FC<TransactionsStackScreenProps<'TranDetail'>> =
colorScheme={colorScheme}
/>
)}
{transaction.type === TransactionType.NWC_TRANSFER && (
<TransferInfoBlock
transaction={transaction}
isDataParsable={isDataParsable}
mint={mint}
colorScheme={colorScheme}
/>
)}
{isDataParsable && (
<Card
labelTx='tranDetailScreen.auditTrail'
@@ -652,6 +660,7 @@ const ReceiveInfoBlock = function (props: {
<>
<Card
style={$dataCard}
label='Transaction data'
ContentComponent={
<>
<TranItem
@@ -891,6 +900,7 @@ const ReceiveOfflineInfoBlock = function (props: {
return (
<>
<Card
label='Transaction data'
style={$dataCard}
ContentComponent={
<>
@@ -1124,6 +1134,7 @@ const SendInfoBlock = function (props: {
return (
<>
<Card
label='Transaction data'
style={$dataCard}
ContentComponent={
<>
@@ -1338,6 +1349,7 @@ const TopupInfoBlock = function (props: {
return (
<>
<Card
label='Transaction data'
style={$dataCard}
ContentComponent={
<>
@@ -1610,12 +1622,14 @@ const TransferInfoBlock = function (props: {
label="tranDetailScreen.type"
value={transaction.type as string}
/>
<TranItem
label="tranDetailScreen.fee"
value={transaction.fee}
unit={transaction.unit}
isCurrency={true}
/>
{transaction.status === TransactionStatus.COMPLETED && (
<TranItem
label="tranDetailScreen.fee"
value={transaction.fee}
unit={transaction.unit}
isCurrency={true}
/>
)}
{transaction.status === TransactionStatus.PREPARED ? (
<View
style={{flexDirection: 'row', justifyContent: 'space-between'}}
@@ -67,7 +67,7 @@ export const TransactionListItem = observer(function (
: tx.sentFrom
? translate('transactionCommon.receivedFrom', {sender: getProfileName(tx.sentFrom)}).slice(0, 30)
: translate('transactionCommon.youReceived')).slice(0, 30)
case TransactionType.TRANSFER:
case TransactionType.TRANSFER || TransactionType.NWC_TRANSFER:
return (tx.memo && tx.memo !== 'LNbits'
? tx.memo.slice(0, 30)
: tx.sentTo
@@ -234,7 +234,7 @@ export const TransactionListItem = observer(function (
)}
</>
)}
{([TransactionType.SEND, TransactionType.TRANSFER].includes(tx.type)) && (
{([TransactionType.SEND, TransactionType.TRANSFER, TransactionType.NWC_TRANSFER].includes(tx.type)) && (
<CurrencyAmount
amount={-1 * tx.amount}
mintUnit={tx.unit}
+7 -2
View File
@@ -366,8 +366,13 @@ const findExactMatch = function (requestedAmount: number, proofs: Proof[]): Proo
return null;
}
const findMinExcess = function (requestedAmount: number, proofs: Proof[]): Proof[] {
proofs.sort((a, b) => b.amount - a.amount);
const findMinExcess = function (requestedAmount: number, proofs: Proof[], preference: 'SMALL' | 'BIG' = 'SMALL'): Proof[] {
if(preference === 'SMALL') {
proofs.sort((a, b) => a.amount - b.amount);
} else {
proofs.sort((a, b) => b.amount - a.amount);
}
const selectedProofs: Proof[] = [];
let currentAmount = 0;
+27
View File
@@ -7,6 +7,9 @@ import {
} from '@env'
import { WalletProfile, WalletProfileRecord } from "../models/WalletProfileStore"
import { CurrencyCode } from "./wallet/currency"
import { ProofV3, TokenV3 } from "./cashu/cashuUtils"
import { NostrEvent } from "./nostrService"
import { MeltQuoteResponse, MeltQuoteState } from "@cashu/cashu-ts"
type MinibitsRequestArgs = {
@@ -255,6 +258,29 @@ const checkDonationPaid = async function (paymentHash: string, pubkey: string) {
}
const payNwcTransfer = async function (encodedInvoice: string, tokenToPayFrom: TokenV3) {
const url = MINIBITS_SERVER_API_HOST + '/payment/payInvoice'
const method = 'POST'
const body = {
encodedInvoice,
tokenToPayFrom,
}
const response: {
meltQuote: MeltQuoteResponse,
tokenToReturn?: TokenV3
} = await fetchApi(url, {
method,
body
})
log.debug(`[minibitsClient.payNwcTransfer] Got response`, {response})
return response
}
const createClaim = async function (walletId: string, seedHash: string, pubkey: string, batchFrom?: number) {
const url = MINIBITS_SERVER_API_HOST + '/claim'
const method = 'POST'
@@ -362,6 +388,7 @@ export const MinibitsClient = {
createDonation,
checkDonationPaid,
createClaim,
payNwcTransfer,
getExchangeRate,
getPublicHeaders,
fetchApi,
+425
View File
@@ -0,0 +1,425 @@
import {CashuUtils, ProofV3, TokenEntryV3} from '../cashu/cashuUtils'
import AppError, {Err} from '../../utils/AppError'
import {MeltQuoteResponse, MeltQuoteState} 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, 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 { MinibitsClient } from '../minibitsService'
import { getSnapshot } from 'mobx-state-tree'
import { MINIBITS_MINT_URL } from '@env'
import { receiveSync } from './receiveTask'
const {
transactionsStore,
mintsStore,
proofsStore,
} = rootStoreInstance
const NWC_TRANSFER = 'nwcTransferTask'
export const nwcTransferTask = async function (
mintBalanceToTransferFrom: MintBalance,
amountToTransfer: number,
feeReserve: number,
unit: MintUnit,
memo: string,
invoiceExpiry: Date,
encodedInvoice: string,
nwcEvent: NostrEvent
) : Promise<TransactionTaskResult> {
const mintUrl = mintBalanceToTransferFrom.mintUrl
const mintInstance = mintsStore.findByUrl(mintUrl)
if(mintUrl !== MINIBITS_MINT_URL) {
throw new AppError(
Err.VALIDATION_ERROR,
'Payment of NWC invoice on the Minibits server is supported only for ecash issued by Minibits mint.',
{mintBalanceToTransferFrom}
)
}
// TODO refresh - balance might be outdated if it waits in queue before other txs
log.debug('[nwcTransfer]', 'mintBalanceToTransferFrom', {mintBalanceToTransferFrom})
log.debug('[nwcTransfer]', 'amountToTransfer', {amountToTransfer, feeReserve})
// create draft transaction
const transactionData: TransactionData[] = [
{
status: TransactionStatus.DRAFT,
mintBalanceToTransferFrom,
amountToTransfer,
feeReserve,
unit,
encodedInvoice,
isNwc: nwcEvent ? true : false,
createdAt: new Date(),
}
]
let transaction: Transaction | undefined = undefined
let meltQuote: MeltQuoteResponse | undefined = undefined
let proofsToMeltFrom: Proof[] = []
let proofsToMeltFromAmount: number = 0
try {
const newTransaction = {
type: TransactionType.NWC_TRANSFER,
amount: amountToTransfer,
fee: feeReserve,
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
if (amountToTransfer + feeReserve > 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)
let proofsToMelt = CashuUtils.getProofsToSend(
amountToTransfer + feeReserve,
proofsFromMint
)
let meltFeeReserve = mintInstance.getMintFeeReserve(proofsToMelt)
const amountWithFees = amountToTransfer + feeReserve + meltFeeReserve
if (totalAmountFromMint < amountWithFees) {
throw new AppError(
Err.VALIDATION_ERROR,
'There is not enough funds to send this amount.',
{totalAmountFromMint, amountWithFees, transactionId, caller: 'nwcTransferTask'},
)
}
// exact match or min number of proofs that matches the amount
proofsToMeltFrom = CashuUtils.getProofsToSend(
amountWithFees,
proofsFromMint
)
proofsToMeltFromAmount = CashuUtils.getProofsAmount(proofsToMeltFrom)
proofsStore.removeProofs(proofsToMeltFrom)
WalletUtils.addCashuProofs(
mintUrl,
proofsToMeltFrom,
{
unit,
transactionId: transaction.id,
isPending: true
}
)
log.trace('[nwcTransfer]', 'Prepared proofsToMeltFrom proofs', {
proofsToMeltFromAmount,
transactionId,
unit,
})
// Update transaction status
transactionData.push({
status: TransactionStatus.PREPARED,
proofsToMeltFromAmount,
lightningFeeReserve: feeReserve,
meltFeeReserve,
createdAt: new Date(),
})
transaction.setStatus(
TransactionStatus.PREPARED,
JSON.stringify(transactionData),
)
const cleanedproofsToMeltFrom = proofsToMeltFrom.map(proof => {
const {mintUrl, unit, tId, ...rest} = getSnapshot(proof)
return rest
})
const inputTokenEntry: TokenEntryV3 = {
mint: mintUrl,
proofs: cleanedproofsToMeltFrom,
}
const tokenToPayFrom = {
token: [inputTokenEntry],
unit
}
const inputToken = CashuUtils.encodeToken(tokenToPayFrom)
transaction.setInputToken(inputToken)
const result = await MinibitsClient.payNwcTransfer(
encodedInvoice,
{
token: [inputTokenEntry],
unit
}
)
meltQuote = result.meltQuote
const tokenToReturn = result.tokenToReturn // this is server wallet ecash, needs swap
if (meltQuote.state === MeltQuoteState.PAID) {
log.debug('[nwcTransfer] Invoice PAID', {
transactionId
})
// Save preimage asap
if(meltQuote.payment_preimage) {
transaction.setProof(meltQuote.payment_preimage)
}
// If noting was returned, all reserves were spent on fees
let totalFeePaid = proofsToMeltFromAmount - amountToTransfer
let lightningFeePaid = totalFeePaid - meltFeeReserve
let returnedAmount = 0
if (tokenToReturn) {
// Save in case receive fails
const outputToken = CashuUtils.encodeToken(tokenToReturn)
transaction.setOutputToken(outputToken)
// Swap received feeSaved proofs as they were issued to the server wallet thus are not linked to wallet seed
const {
receivedProofs: returnedProofs
} = await receiveSync(
mintUrl,
tokenToReturn,
memo,
transactionId
)
// Spend pending proofs that were used to settle the lightning invoice
proofsStore.removeProofs(proofsToMeltFrom as Proof[], true, false)
returnedAmount = CashuUtils.getProofsAmount(returnedProofs)
const returnedTokenEntry: TokenEntryV3 = {
mint: mintUrl,
proofs: returnedProofs,
}
const swappedOutputToken = CashuUtils.encodeToken({
token: [returnedTokenEntry],
unit,
})
transaction.setOutputToken(swappedOutputToken)
totalFeePaid = totalFeePaid - returnedAmount
lightningFeePaid = totalFeePaid - meltFeeReserve
} else {
// Spend pending proofs that were used to settle the lightning invoice
proofsStore.removeProofs(proofsToMeltFrom as Proof[], true, false)
}
// Save final fee in db
if(totalFeePaid !== transaction.fee) {
transaction.setFee(totalFeePaid)
}
// Update transaction status
transactionData.push({
status: TransactionStatus.COMPLETED,
lightningFeeReserve: meltQuote.fee_reserve,
lightningFeePaid,
mintFeesPaid: totalFeePaid - lightningFeePaid,
preimage: meltQuote.payment_preimage,
createdAt: new Date(),
})
transaction.setStatus(
TransactionStatus.COMPLETED,
JSON.stringify(transactionData),
)
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance!
transaction.setBalanceAfter(balanceAfter)
return {
taskFunction: NWC_TRANSFER,
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,
mintFees: totalFeePaid - lightningFeePaid,
totalFeePaid,
meltQuote,
preimage: meltQuote.payment_preimage,
nwcEvent
} as TransactionTaskResult
} else if(meltQuote.state === MeltQuoteState.PENDING) {
log.debug('[transfer] Invoice PENDING', {
meltQuote,
transactionId
})
return {
taskFunction: NWC_TRANSFER,
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.', {
state: meltQuote.state,
transactionId
})
}
} catch (e: any) {
if (transaction) {
let message = e.message
if (proofsToMeltFrom.length > 0) {
// check with the mint the real status of the proofs involved in transaction
await WalletTask.syncStateWithMintSync(
{
proofsToSync: proofsToMeltFrom,
mintUrl,
isPending: true
}
)
// force refresh just in case above method did not update the model?
const refreshed = transactionsStore.findById(transaction.id)
if(refreshed?.status === TransactionStatus.PENDING) {
log.warn('[transfer]', 'proofsToPay from transfer with error are pending by mint', {
proofsToMeltFromAmount,
unit,
transactionId: transaction.id
})
message = 'Lightning payment did not complete in time. Your ecash will remain pending until the payment completes or fails.'
return {
taskFunction: NWC_TRANSFER,
mintUrl,
transaction,
message,
meltQuote,
nwcEvent
} as TransactionTaskResult
} else if(refreshed?.status === TransactionStatus.COMPLETED) {
// Likely receiving of change failed due to wallet error. We keep completed as status.
log.error('[nwcTransfer]', 'NWC Transfer throwed error but the payment suceeded', {
error: e.message,
proofsToMeltFromAmount,
unit,
transactionId: transaction.id
})
if(meltQuote?.payment_preimage) {
refreshed.setProof(meltQuote?.payment_preimage)
}
const message = `Lightning invoice has been successfully paid, however some error occured: ${e.message}`
return {
taskFunction: NWC_TRANSFER,
mintUrl,
transaction,
message,
meltQuote,
preimage: meltQuote?.payment_preimage,
nwcEvent
} as TransactionTaskResult
} else {
// syncStateWithMintSync returns to spendable only proofs that were pending by mint before
// so we need to take care of our transfer here.
log.warn('[transfer]', 'proofsToPay from transfer with error to be returned to spendable wallet', {
proofsToMeltFromAmount,
unit,
transactionId: transaction.id
})
// remove it from pending proofs in the wallet
proofsStore.removeProofs(proofsToMeltFrom as Proof[], true, true)
// add proofs back to the spendable wallet with internal references
WalletUtils.addCashuProofs(
mintUrl,
proofsToMeltFrom,
{
unit,
transactionId: transaction.id,
isPending: false
}
)
}
}
transactionData.push({
status: TransactionStatus.ERROR,
error: WalletUtils.formatError(e),
createdAt: new Date()
})
transaction.setStatus(
TransactionStatus.ERROR,
JSON.stringify(transactionData),
)
}
log.error('[transfer]', e.name, e.message, e.params)
return {
taskFunction: NWC_TRANSFER,
mintUrl,
transaction,
message: e.message,
error: WalletUtils.formatError(e),
meltQuote,
nwcEvent
} as TransactionTaskResult
}
}
+151 -195
View File
@@ -37,8 +37,7 @@ export const receiveTask = async function (
const transactionData: TransactionData[] = []
let transaction: Transaction | undefined = undefined
let mintToReceive: string | undefined = undefined
const unit = token.unit as MintUnit || 'sat'
let lockedProofsCounter: MintProofsCounter | undefined = undefined
const unit = token.unit as MintUnit || 'sat'
try {
const tokenMints: string[] = CashuUtils.getMintsFromToken(token)
@@ -103,94 +102,19 @@ export const receiveTask = async function (
} as TransactionTaskResult
}
// Handle missing mint, we add it automatically
const alreadyExists = mintsStore.alreadyExists(mintToReceive)
if (!alreadyExists) {
await mintsStore.addMint(mintToReceive)
}
const mintInstance = mintsStore.findByUrl(mintToReceive)
if(!mintInstance) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing mint', {mintToReceive})
}
// Increase the proofs counter before the mint call so that in case the response
// is not received our recovery index counts for sigs the mint has already issued
const amountPreferences = getDefaultAmountPreference(amountToReceive)
const countOfInFlightProofs = CashuUtils.getAmountPreferencesCount(amountPreferences)
const proofsToReceive = token.token.flatMap(entry => entry.proofs)
const mintFeeReserve = mintInstance.getMintFeeReserve(proofsToReceive)
log.trace('[receiveTask]', 'amountPreferences', {amountPreferences, transactionId: transaction.id})
log.trace('[receiveTask]', 'countOfInFlightProofs', {countOfInFlightProofs, transactionId: transaction.id})
// log.trace('[receiveTask]', {tokenEntries})
// temp increase the counter + acquire lock and set inFlight values
lockedProofsCounter = await WalletUtils.lockAndSetInFlight(
mintInstance,
unit,
countOfInFlightProofs,
transaction.id!
)
const receivedResult = await walletStore.receive(
const {
receivedAmount,
outputToken,
swapFeeReserve,
swapFeePaid,
counter
} = await receiveSync(
mintToReceive,
unit as MintUnit,
token,
mintFeeReserve,
{
preference: amountPreferences,
counter: lockedProofsCounter.inFlightFrom as number // MUST be counter value before increase
}
token.memo || '',
transaction.id
)
const receivedProofs = receivedResult.proofs
const mintFeePaid = receivedResult.mintFeePaid
// log.trace('[receiveTask]', {receivedProofs})
// If we've got valid response, decrease proofsCounter and let it be increased back in next step when adding proofs
lockedProofsCounter.decreaseProofsCounter(countOfInFlightProofs)
// store swapped proofs as encoded token in tx data
const receivedTokenEntry = {
mint: mintToReceive,
proofs: receivedProofs,
}
const outputToken = CashuUtils.encodeToken({
token: [receivedTokenEntry],
unit,
memo,
})
// Update transaction status
transactionData.push({
status: TransactionStatus.PREPARED,
mintFeeReserve,
mintFeePaid,
createdAt: new Date(),
})
transaction.setStatus(
TransactionStatus.PREPARED,
JSON.stringify(transactionData),
)
const { addedAmount: receivedAmount } = WalletUtils.addCashuProofs(
mintToReceive,
receivedProofs,
{
unit,
transactionId: transaction.id!,
isPending: false
}
)
// release lock
lockedProofsCounter.resetInFlight(transaction.id!)
// Update tx amount if full amount was not received
if (receivedAmount !== amountToReceive) {
@@ -199,10 +123,12 @@ export const receiveTask = async function (
// Finally, update completed transaction
transactionData.push({
status: TransactionStatus.COMPLETED,
status: TransactionStatus.COMPLETED,
swapFeeReserve,
swapFeePaid,
receivedAmount,
unit,
counter: lockedProofsCounter.counter,
counter,
createdAt: new Date(),
})
@@ -216,13 +142,13 @@ export const receiveTask = async function (
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance!
transaction.setBalanceAfter(balanceAfter)
if(mintFeePaid > 0) {
transaction.setFee(mintFeePaid)
if(swapFeePaid > 0) {
transaction.setFee(swapFeePaid)
}
return {
taskFunction: RECEIVE,
mintUrl: mintInstance.mintUrl,
mintUrl: mintToReceive,
transaction,
message: `You've received ${formatCurrency(receivedAmount, getCurrency(unit).code)} ${getCurrency(unit).code} to your Minibits wallet.`,
receivedAmount,
@@ -230,9 +156,6 @@ export const receiveTask = async function (
} catch (e: any) {
if (transaction) {
if(lockedProofsCounter) {
lockedProofsCounter.resetInFlight(transaction.id!)
}
transactionData.push({
status: TransactionStatus.ERROR,
@@ -250,7 +173,7 @@ export const receiveTask = async function (
return {
taskFunction: RECEIVE,
mintUrl: mintToReceive || '',
mintUrl: mintToReceive,
transaction,
message: e.message,
error: WalletUtils.formatError(e),
@@ -355,7 +278,7 @@ export const receiveOfflinePrepareTask = async function (
error: WalletUtils.formatError(e),
})
await transaction.setStatus(
transaction.setStatus(
TransactionStatus.ERROR,
JSON.stringify(transactionData),
)
@@ -377,8 +300,7 @@ export const receiveOfflinePrepareTask = async function (
export const receiveOfflineCompleteTask = async function (
transactionId: number
) {
let mintToReceive = ''
let lockedProofsCounter: MintProofsCounter | undefined = undefined
let mintToReceive = ''
const transaction = transactionsStore.findById(transactionId)
try {
@@ -388,7 +310,6 @@ export const receiveOfflineCompleteTask = async function (
const transactionData = JSON.parse(transaction.data)
if (!transaction.inputToken) {
throw new AppError(Err.VALIDATION_ERROR, 'Could not find ecash token to redeem', {caller: 'receiveOfflineComplete'})
}
@@ -420,6 +341,95 @@ export const receiveOfflineCompleteTask = async function (
} as TransactionTaskResult
}
const {
receivedAmount,
outputToken,
swapFeeReserve,
swapFeePaid,
counter
} = await receiveSync(
mintToReceive,
token,
token.memo || '',
transaction.id
)
// Update tx amount if full amount was not received
if (receivedAmount !== transaction.amount) {
transaction.setReceivedAmount(receivedAmount)
}
// Finally, update completed transaction
transactionData.push({
status: TransactionStatus.COMPLETED,
receivedAmount,
swapFeeReserve,
swapFeePaid,
unit,
counter,
createdAt: new Date(),
})
transaction.setStatus(
TransactionStatus.COMPLETED,
JSON.stringify(transactionData),
)
transaction.setOutputToken(outputToken)
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance!
transaction.setBalanceAfter(balanceAfter)
if(swapFeePaid > 0) {
transaction.setFee(swapFeePaid)
}
return {
taskFunction: RECEIVE_OFFLINE_COMPLETE,
mintUrl: mintToReceive,
transaction,
message: `You've received ${formatCurrency(receivedAmount, getCurrency(unit).code)} ${getCurrency(unit).code} to your Minibits wallet.`,
receivedAmount,
} as TransactionTaskResult
} catch (e: any) {
// release lock
if(transaction) {
const transactionData = JSON.parse(transaction.data)
transactionData.push({
status: TransactionStatus.ERROR,
error: WalletUtils.formatError(e),
})
transaction.setStatus(
TransactionStatus.ERROR,
JSON.stringify(transactionData),
)
}
return {
taskFunction: RECEIVE_OFFLINE_COMPLETE,
mintUrl: mintToReceive,
transaction,
message: '',
error: WalletUtils.formatError(e),
} as TransactionTaskResult
}
}
export const receiveSync = async function (
mintToReceive: string,
token: TokenV3,
memo: string,
transactionId: number
) {
const unit = token.unit as MintUnit || 'sat'
let lockedProofsCounter: MintProofsCounter | undefined = undefined
try {
// Handle missing mint, we add it automatically
const alreadyExists = mintsStore.alreadyExists(mintToReceive)
@@ -434,31 +444,29 @@ export const receiveOfflineCompleteTask = async function (
}
// Increase the proofs counter before the mint call so that in case the response
// is not received our recovery index counts for sigs the mint has already issued (prevents duplicate b_b bug)
const tokenEntries: TokenEntryV3[] = token.token
const proofsToReceive = tokenEntries[0].proofs as Proof[]
const mintFeeReserve = mintInstance.getMintFeeReserve(proofsToReceive)
// We will receive amount less fees paid for a swap for fresh ecash
const amountPreferences = getDefaultAmountPreference(transaction.amount - mintFeeReserve)
const countOfInFlightProofs = CashuUtils.getAmountPreferencesCount(amountPreferences)
log.trace('[receiveOfflineCompleteTask]', 'amountPreferences', amountPreferences)
log.trace('[receiveOfflineCompleteTask]', 'countOfInFlightProofs', countOfInFlightProofs)
// is not received our recovery index counts for sigs the mint has already issued
const amountToReceive = CashuUtils.getTokenAmounts(token).totalAmount
const amountPreferences = getDefaultAmountPreference(amountToReceive)
const countOfInFlightProofs = CashuUtils.getAmountPreferencesCount(amountPreferences)
const proofsToReceive = token.token.flatMap(entry => entry.proofs)
const swapFeeReserve = mintInstance.getMintFeeReserve(proofsToReceive)
log.trace('[receiveSync]', 'amountPreferences', {amountPreferences, transactionId})
log.trace('[receiveSync]', 'countOfInFlightProofs', {countOfInFlightProofs, transactionId})
// temp increase the counter + acquire lock and set inFlight values
lockedProofsCounter = await WalletUtils.lockAndSetInFlight(
mintInstance,
unit,
countOfInFlightProofs,
transaction.id as number
transactionId
)
const receivedResult = await walletStore.receive(
mintToReceive,
unit as MintUnit,
token,
mintFeeReserve,
swapFeeReserve,
{
preference: amountPreferences,
counter: lockedProofsCounter.inFlightFrom as number // MUST be counter value before increase
@@ -466,110 +474,58 @@ export const receiveOfflineCompleteTask = async function (
)
const receivedProofs = receivedResult.proofs
const mintFeePaid = receivedResult.mintFeePaid
const swapFeePaid = receivedResult.swapFeePaid
// log.trace('[receiveTask]', {receivedProofs})
// If we've got valid response, decrease proofsCounter and let it be increased back in next step when adding proofs
lockedProofsCounter.decreaseProofsCounter(countOfInFlightProofs)
// store swapped proofs as encoded token in tx data
const receivedTokenEntry: TokenEntryV3 = {
mint: mintToReceive,
proofs: receivedProofs,
}
const outputToken = CashuUtils.encodeToken({
token: [receivedTokenEntry],
unit,
})
// Update transaction status
transactionData.push({
status: TransactionStatus.PREPARED,
mintFeeReserve,
mintFeePaid,
createdAt: new Date(),
})
transaction.setStatus(
TransactionStatus.PREPARED,
JSON.stringify(transactionData),
)
const { addedAmount: receivedAmount } = WalletUtils.addCashuProofs(
mintToReceive,
receivedProofs,
{
unit,
transactionId: transaction.id as number,
transactionId: transactionId,
isPending: false
}
)
// release lock
lockedProofsCounter.resetInFlight(transaction.id as number)
// Update tx amount if full amount was not received
if (receivedAmount !== transaction.amount) {
transaction.setReceivedAmount(receivedAmount)
lockedProofsCounter.resetInFlight(transactionId)
// store swapped proofs as encoded token in tx data
const receivedTokenEntry = {
mint: mintToReceive,
proofs: receivedProofs,
}
// Finally, update completed transaction
transactionData.push({
status: TransactionStatus.COMPLETED,
const outputToken = CashuUtils.encodeToken({
token: [receivedTokenEntry],
unit,
memo,
})
return {
receivedAmount,
unit,
createdAt: new Date(),
})
transaction.setStatus(
TransactionStatus.COMPLETED,
JSON.stringify(transactionData),
)
transaction.setOutputToken(outputToken)
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance!
transaction.setBalanceAfter(balanceAfter)
if(mintFeePaid > 0) {
transaction.setFee(mintFeePaid)
receivedProofs,
outputToken,
swapFeeReserve,
swapFeePaid,
counter: lockedProofsCounter?.counter
}
return {
taskFunction: RECEIVE_OFFLINE_COMPLETE,
mintUrl: mintInstance.mintUrl,
transaction,
message: `You've received ${formatCurrency(receivedAmount, getCurrency(unit).code)} ${getCurrency(unit).code} to your Minibits wallet.`,
receivedAmount,
} as TransactionTaskResult
} catch (e: any) {
// release lock
if(transaction) {
if(lockedProofsCounter) {
lockedProofsCounter.resetInFlight(transaction.id as number)
if (transactionId) {
if(lockedProofsCounter) {
lockedProofsCounter.resetInFlight(transactionId)
}
const transactionData = JSON.parse(transaction.data)
transactionData.push({
status: TransactionStatus.ERROR,
error: WalletUtils.formatError(e),
})
transaction.setStatus(
TransactionStatus.ERROR,
JSON.stringify(transactionData),
)
}
log.error(e.name, e.message)
return {
taskFunction: RECEIVE_OFFLINE_COMPLETE,
mintUrl: mintToReceive,
transaction,
message: '',
error: WalletUtils.formatError(e),
} as TransactionTaskResult
if (e instanceof AppError) {
throw e
} else {
throw new AppError(Err.WALLET_ERROR, e.message, e.stack.slice(0, 200))
}
}
}
+52 -70
View File
@@ -11,7 +11,7 @@ import AppError, {Err} from '../../utils/AppError'
import {
MintKeyset,
} from '@cashu/cashu-ts'
import { getDefaultAmountPreference, isObj } from '@cashu/cashu-ts/src/utils'
import { getDefaultAmountPreference } from '@cashu/cashu-ts/src/utils'
import { MAX_SWAP_INPUT_SIZE, TransactionTaskResult, WalletTask } from '../walletService'
import { Mint, MintBalance, MintProofsCounter } from '../../models/Mint'
import { Proof } from '../../models/Proof'
@@ -76,8 +76,8 @@ export const sendTask = async function (
// get ready proofs to send and update proofs and pending proofs storage
const {
proofs: proofsToSend,
mintFeePaid,
mintFeeReserve,
swapFeePaid,
swapFeeReserve,
isSwapNeeded,
counter
} = await sendFromMintSync(
@@ -91,8 +91,8 @@ export const sendTask = async function (
// Update transaction status
transactionData.push({
status: TransactionStatus.PREPARED,
mintFeeReserve,
mintFeePaid,
swapFeeReserve,
swapFeePaid,
isSwapNeeded,
createdAt: new Date(),
})
@@ -134,8 +134,8 @@ export const sendTask = async function (
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance!
transaction.setBalanceAfter(balanceAfter)
if(mintFeePaid > 0) {
transaction.setFee(mintFeePaid)
if(swapFeePaid > 0) {
transaction.setFee(swapFeePaid)
}
log.trace('[send] totalBalance after', balanceAfter)
@@ -165,7 +165,7 @@ export const sendTask = async function (
transaction,
message: '',
encodedTokenToSend: outputToken,
mintFeePaid
swapFeePaid
} as TransactionTaskResult
} catch (e: any) {
@@ -263,13 +263,17 @@ export const sendFromMintSync = async function (
)
}
for (const proof of selectedProofs) {
proof.setTransactionId(transactionId) // update txId
}
// move sent proofs to pending
proofsStore.removeProofs(selectedProofs)
proofsStore.addProofs(selectedProofs, true) // pending true
// move sent proofs to pending and add tx references
proofsStore.removeProofs(selectedProofs)
WalletUtils.addCashuProofs(
mintUrl,
selectedProofs,
{
unit,
transactionId: transactionId,
isPending: true
}
)
// Clean private properties to not to send them out. This returns plain js array, not model objects.
const cleanedProofsToSend = selectedProofs.map(proof => {
@@ -280,8 +284,8 @@ export const sendFromMintSync = async function (
// We return cleaned proofs to be encoded as a sendable token
return {
proofs: cleanedProofsToSend,
mintFeeReserve: 0,
mintFeePaid: 0
swapFeeReserve: 0,
swapFeePaid: 0
}
}
@@ -312,9 +316,9 @@ export const sendFromMintSync = async function (
let proofsToSendFromAmount = CashuUtils.getProofsAmount(proofsToSendFrom)
// swap will happen if we could not select proofs equal to amountToSend
let mintFeeReserve: number = 0
let swapFeeReserve: number = 0
let returnedAmount = 0
let mintFeePaid: number = 0
let swapFeePaid: number = 0
let proofsToSend: Proof[] = []
let returnedProofs: Proof[] = []
@@ -327,26 +331,30 @@ export const sendFromMintSync = async function (
*/
if(isSwapNeeded) {
// Calculate feeReserve from mint fee rate
mintFeeReserve = mintInstance.getMintFeeReserve(proofsToSendFrom)
// This is expected to get back from mint as a split remainder - we deduct fee that a mint will keep
returnedAmount = proofsToSendFromAmount - amountToSend - mintFeeReserve
swapFeeReserve = mintInstance.getMintFeeReserve(proofsToSendFrom)
const amountWithFees = amountToSend + swapFeeReserve
log.debug('[sendFromMintSync] Swap is needed.', {mintFeeReserve, returnedAmount, transactionId})
// if we did not selected enough proofs to cover the fees we need some more
if(returnedAmount < 0) {
const proofsToPayFees = getProofsToPayFees(
Math.abs(returnedAmount),
proofsFromMint,
proofsToSendFrom
if (totalAmountFromMint < amountWithFees) {
throw new AppError(
Err.VALIDATION_ERROR,
'There is not enough funds to send this amount.',
{totalAmountFromMint, amountWithFees, transactionId, caller: 'transferTask'},
)
// add more proofs into inputs and recalculate amounts
proofsToSendFrom.push(...proofsToPayFees)
proofsToSendFromAmount = CashuUtils.getProofsAmount(proofsToSendFrom)
returnedAmount = proofsToSendFromAmount - amountToSend - mintFeeReserve
}
// exact match or min number of proofs that matches the amount
proofsToSendFrom = CashuUtils.getProofsToSend(
amountWithFees,
proofsFromMint
)
proofsToSendFromAmount = CashuUtils.getProofsAmount(proofsToSendFrom)
// This is expected to get back from mint as a split remainder - we deduct fee that a mint will keep
returnedAmount = proofsToSendFromAmount - (amountToSend + swapFeeReserve)
log.debug('[sendFromMintSync] Swap is needed.', {swapFeeReserve, returnedAmount, transactionId})
// Output denominations we ask for to get back
const amountPreferences = getDefaultAmountPreference(amountToSend)
// Output denominations we are about to get as a split remainder
@@ -355,8 +363,7 @@ export const sendFromMintSync = async function (
const countOfProofsToSend = CashuUtils.getAmountPreferencesCount(amountPreferences)
const countOfReturnedProofs = CashuUtils.getAmountPreferencesCount(returnedAmountPreferences)
const countOfInFlightProofs = countOfProofsToSend + countOfReturnedProofs
log.trace('[sendFromMintSync]', 'amountPreferences', {amountPreferences, returnedAmountPreferences})
log.trace('[sendFromMintSync]', 'countOfInFlightProofs', countOfInFlightProofs)
// Increase the proofs counter before the mint call so that in case the response
@@ -374,7 +381,7 @@ export const sendFromMintSync = async function (
const sendResult = await walletStore.send(
mintUrl,
amountToSend,
mintFeeReserve,
swapFeeReserve,
unit,
proofsToSendFrom,
{
@@ -385,13 +392,14 @@ export const sendFromMintSync = async function (
returnedProofs = sendResult.returnedProofs // TODO types - these are ProofsV3 indeed
proofsToSend = sendResult.proofsToSend // TODO types - these are ProofsV3 indeed
mintFeePaid = sendResult.mintFeePaid
swapFeePaid = sendResult.swapFeePaid
// If we've got valid response, decrease proofsCounter and let it be increased back in next step when adding proofs
lockedProofsCounter.decreaseProofsCounter(countOfInFlightProofs)
// add proofs returned by the mint after the split
log.trace('[sendFromMintSync] add returned proofs to spendable')
WalletUtils.addCashuProofs(
mintUrl,
returnedProofs,
@@ -400,8 +408,7 @@ export const sendFromMintSync = async function (
transactionId,
isPending: false
}
)
)
// release lock
lockedProofsCounter.resetInFlight(transactionId)
@@ -410,7 +417,7 @@ export const sendFromMintSync = async function (
* SWAP is NOT needed, we've found denominations that match exact amount
*
*/
log.debug('[sendFromMintSync] Swap is not necessary, all proofsToSendFrom will be sent.', {transactionId})
log.debug('[sendFromMintSync] Swap is not necessary.', {transactionId})
// If we selected whole balance, check if it is not above limit acceptable by wallet and mints.
if(proofsToSendFrom.length > MAX_SWAP_INPUT_SIZE) {
@@ -452,8 +459,8 @@ export const sendFromMintSync = async function (
// We return cleaned proofs to be encoded as a sendable token + fees
return {
proofs: cleanedProofsToSend as ProofV3[],
mintFeeReserve,
mintFeePaid,
swapFeeReserve,
swapFeePaid,
isSwapNeeded,
counter: lockedProofsCounter?.counter
}
@@ -562,29 +569,4 @@ const getInactiveKeysetIds = function(mint: Mint) {
}
return []
}
const getProofsToPayFees = function (
feeAmount: number,
proofsFromMint: Proof[],
proofsToSendFrom: Proof[]
) {
const remainingProofs = proofsStore.getProofsSubset(proofsFromMint, proofsToSendFrom)
const remainingProofsAmount = CashuUtils.getProofsAmount(remainingProofs)
if(feeAmount > remainingProofsAmount) {
throw new AppError(
Err.VALIDATION_ERROR,
'There is not enough funds to cover the expected fee for this payment.',
{feeAmount, remainingProofsAmount},
)
}
return CashuUtils.getProofsToSend(
feeAmount,
remainingProofs
)
}
}
+111 -134
View File
@@ -1,4 +1,4 @@
import {CashuUtils, ProofV3, TokenEntryV3} from '../cashu/cashuUtils'
import {CashuUtils, TokenEntryV3} from '../cashu/cashuUtils'
import AppError, {Err} from '../../utils/AppError'
import {MeltQuoteResponse, MeltQuoteState} from '@cashu/cashu-ts'
import {rootStoreInstance} from '../../models'
@@ -9,7 +9,6 @@ import { Transaction, TransactionData, TransactionRecord, TransactionStatus, Tra
import { log } from '../logService'
import { WalletUtils } from './utils'
import {isBefore} from 'date-fns'
import { sendFromMintSync } from './sendTask'
import { MintUnit, formatCurrency, getCurrency } from './currency'
import { NostrEvent } from '../nostrService'
@@ -38,7 +37,8 @@ export const transferTask = async function (
const mintInstance = mintsStore.findByUrl(mintUrl)
let lockedProofsCounter: MintProofsCounter | undefined = undefined
log.debug('[transfer]', 'mintBalanceToTransferFrom', {mintBalanceToTransferFrom})
// 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})
@@ -57,8 +57,8 @@ export const transferTask = async function (
]
let transaction: Transaction | undefined = undefined
let proofsToPay: ProofV3[] = []
let proofsToPayAmount: number = 0
let proofsToMeltFrom: Proof[] = []
let proofsToMeltFromAmount: number = 0
try {
const newTransaction = {
@@ -98,101 +98,58 @@ export const transferTask = async function (
'Could not find mint', {mintUrl, transactionId}
)
}
// if previous transaction got interrupted by android background processing, resync their proofs state with the mint
// unfinished transactions cause outputs already signed error
const previousTransaction = transactionsStore.findById(transactionId - 1)
// if zap remained stuck in DRAFT
if(previousTransaction
&& previousTransaction.status === TransactionStatus.DRAFT
&& previousTransaction.type === TransactionType.TRANSFER
) {
log.error(`[transfer] Previous TRANSFER transaction ${previousTransaction.id} is stuck in DRAFT status.`)
// we check if proofsCounter is locked so we very likely fired swap request
const proofsCounter = mintInstance.findInFlightProofsCounterByTId(previousTransaction.id)
if(proofsCounter && proofsCounter.inFlightTid === previousTransaction.id) {
log.error(`[transfer] Found previous DARFT TRANSFER locked proofsCounter.`, {proofsCounter, prevId: previousTransaction.id})
await WalletTask.handleInFlightSync(mintInstance)
}
}
// if zap remained stuck in PREPARED we sync pending proofs so that we set proofsCounters to correct values
if(previousTransaction
&& previousTransaction.status === TransactionStatus.PREPARED
&& previousTransaction.type === TransactionType.TRANSFER
) {
log.error(`[transfer] Previous TRANSFER transaction ${previousTransaction.id} is stuck in PREPARED status.`)
const proofsCounter = mintInstance.findInFlightProofsCounterByTId(previousTransaction.id)
if(proofsCounter && proofsCounter.inFlightTid === previousTransaction.id) {
log.error(`[transfer] Found previous PREPARED TRANSFER locked proofsCounter.`, {proofsCounter, prevId: previousTransaction.id})
await WalletTask.handleInFlightSync(mintInstance)
}
const proofsToSync = proofsStore.getByTransactionId(previousTransaction.id, true) // pending
log.error(`[transfer] Syncing PREPARED TRANSFER pending proofs with the mint.`, {prevId: previousTransaction.id, proofsToSyncCount: proofsToSync.length})
if(proofsToSync.length > 0) {
await WalletTask.syncStateWithMintSync({proofsToSync, mintUrl, isPending: true})
}
}
// 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)
let proofsToMelt = CashuUtils.getProofsToSend(
const proofsToMelt = CashuUtils.getProofsToSend(
amountToTransfer + meltQuote.fee_reserve,
proofsFromMint
)
let meltFeeReserve = mintInstance.getMintFeeReserve(proofsToMelt)
let meltFeeReserve = mintInstance.getMintFeeReserve(proofsToMelt)
const amountWithFees = amountToTransfer + meltQuote.fee_reserve + meltFeeReserve
log.trace('[transfer]', {
meltFeeReserve,
lightningFeeReserve: meltQuote.fee_reserve,
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'},
)
}
// get proofs ready to be paid to the mint
const swapResult = await sendFromMintSync(
mintBalanceToTransferFrom,
amountToTransfer + meltQuote.fee_reserve + meltFeeReserve,
unit,
[],
transactionId,
// exact match or min number of proofs that matches the amount
proofsToMeltFrom = CashuUtils.getProofsToSend(
amountWithFees,
proofsFromMint
)
const {
proofs: proofsToPay,
mintFeePaid,
mintFeeReserve,
isSwapNeeded,
counter
} = swapResult
proofsToPayAmount = CashuUtils.getProofsAmount(proofsToPay)
proofsToMeltFromAmount = CashuUtils.getProofsAmount(proofsToMeltFrom)
// TODO in case of swap from inactive keysets, different meltFees might apply than above calculated meltFeeReserve
// In such case, we might need to add / substract the fee difference to / from proofsToPay
proofsStore.removeProofs(proofsToMeltFrom)
WalletUtils.addCashuProofs(
mintUrl,
proofsToMeltFrom,
{
unit,
transactionId: transaction.id,
isPending: true
}
)
log.info('[transfer]', 'Prepared poofsToPay proofs', {
proofsToPayAmount,
log.trace('[transfer]', 'Prepared proofsToMeltFrom proofs', {
proofsToMeltFromAmount,
transactionId,
unit,
transactionId,
isSwapNeeded,
counter
})
// Update transaction status
transactionData.push({
status: TransactionStatus.PREPARED,
mintFeeReserve,
mintFeePaid,
proofsToPayAmount,
isSwapNeeded,
counter,
proofsToMeltFromAmount,
lightningFeeReserve: meltQuote.fee_reserve,
meltFeeReserve,
createdAt: new Date(),
})
@@ -203,7 +160,7 @@ export const transferTask = async function (
const inputTokenEntry: TokenEntryV3 = {
mint: mintUrl,
proofs: proofsToPay,
proofs: proofsToMeltFrom,
}
const inputToken = CashuUtils.encodeToken({
@@ -213,10 +170,10 @@ export const transferTask = async function (
transaction.setInputToken(inputToken)
// number of outputs we can get back with returned lightning fees
// number of outputs we can get back as a change
let countOfInFlightProofs = 1
if(meltQuote.fee_reserve > 1) {
countOfInFlightProofs = Math.ceil(Math.log2(meltQuote.fee_reserve))
if(proofsToMeltFromAmount - amountToTransfer > 1) {
countOfInFlightProofs = Math.ceil(Math.log2(proofsToMeltFromAmount - amountToTransfer))
}
// temp increase the counter + acquire lock and set inFlight values
@@ -227,11 +184,11 @@ export const transferTask = async function (
transaction.id
)
const {state, preimage, change: feeSavedProofs} = await walletStore.payLightningMelt(
const {state, preimage, change: returnedProofs} = await walletStore.payLightningMelt(
mintUrl,
unit,
meltQuote,
proofsToPay,
proofsToMeltFrom,
{
counter: lockedProofsCounter.inFlightFrom as number
}
@@ -241,22 +198,26 @@ export const transferTask = async function (
if (state === MeltQuoteState.PAID) {
log.debug('[transfer] Invoice PAID', {
state,
meltQuote,
preimage,
log.debug('[transfer] Invoice PAID', {
transactionId
})
// Save preimage asap
if(preimage) {
transaction.setProof(preimage)
}
// Spend pending proofs that were used to settle the lightning invoice
proofsStore.removeProofs(proofsToPay as Proof[], true, false)
proofsStore.removeProofs(proofsToMeltFrom as Proof[], true, false)
let totalFeePaid = proofsToMeltFromAmount - amountToTransfer
let lightningFeePaid = totalFeePaid - meltFeeReserve
let returnedAmount = CashuUtils.getProofsAmount(returnedProofs)
let lightningFeePaid = meltQuote.fee_reserve
if (feeSavedProofs.length > 0) {
const {addedAmount: feeSaved} = WalletUtils.addCashuProofs(
if(returnedProofs.length > 0) {
WalletUtils.addCashuProofs(
mintUrl,
feeSavedProofs,
returnedProofs,
{
unit,
transactionId: transaction.id,
@@ -264,45 +225,44 @@ export const transferTask = async function (
}
)
const feeSavedTokenEntry: TokenEntryV3 = {
const returnedTokenEntry: TokenEntryV3 = {
mint: mintUrl,
proofs: feeSavedProofs,
proofs: returnedProofs,
}
const outputToken = CashuUtils.encodeToken({
token: [feeSavedTokenEntry],
token: [returnedTokenEntry],
unit,
})
transaction.setOutputToken(outputToken)
lightningFeePaid = meltQuote.fee_reserve - feeSaved
transaction.setOutputToken(outputToken)
totalFeePaid = totalFeePaid - returnedAmount
lightningFeePaid = totalFeePaid - meltFeeReserve
} else {
// Spend pending proofs that were used to settle the lightning invoice
proofsStore.removeProofs(proofsToMeltFrom as Proof[], true, false)
}
// release lock
lockedProofsCounter.resetInFlight(transactionId)
// Save preimage
if(preimage) {
transaction.setProof(preimage)
}
// Save final fee in db
if(lightningFeePaid + mintFeePaid !== meltQuote.fee_reserve) {
transaction.setFee(lightningFeePaid + mintFeePaid)
if(totalFeePaid !== transaction.fee) {
transaction.setFee(totalFeePaid)
}
// Update transaction status
transactionData.push({
status: TransactionStatus.COMPLETED,
lightningFeeReserve: meltQuote.fee_reserve,
status: TransactionStatus.COMPLETED,
lightningFeePaid,
mintFeesPaid: totalFeePaid - lightningFeePaid,
returnedAmount,
preimage,
counter: lockedProofsCounter.counter,
createdAt: new Date(),
})
// this overwrites transactionData and COMPLETED status already set by syncStateWithMintSync
})
transaction.setStatus(
TransactionStatus.COMPLETED,
JSON.stringify(transactionData),
@@ -315,9 +275,10 @@ export const transferTask = async function (
taskFunction: TRANSFER,
mintUrl,
transaction,
message: `Lightning invoice has been successfully paid and settled with your Minibits ecash. Fee has been ${formatCurrency(lightningFeePaid + mintFeePaid, getCurrency(unit).code)} ${getCurrency(unit).code}.`,
lightningFeePaid,
mintFeePaid,
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,
mintFees: totalFeePaid - lightningFeePaid,
totalFeePaid,
meltQuote,
preimage,
nwcEvent
@@ -337,9 +298,7 @@ export const transferTask = async function (
mintUrl,
transaction,
message: `Lightning payment did not complete in time. Your ecash will remain pending until the payment completes or fails.`,
mintFeePaid,
meltQuote,
preimage,
meltQuote,
nwcEvent
} as TransactionTaskResult
@@ -359,16 +318,12 @@ export const transferTask = async function (
}
let message = e.message
// If Exception was trigerred most likely by walletStore.payLightningMelt()
if (proofsToPay.length > 0) {
// check with the mint if proofs are not pending by mint, if yes,
// sync sets transaction status as PENDING (timeout-ed/hodled lightning payments)
const proofsToSync = proofsStore.getByMint(mintUrl, {isPending: true})
if (proofsToMeltFrom.length > 0) {
// check with the mint the real status of the proofs involved in transaction
await WalletTask.syncStateWithMintSync(
{
proofsToSync,
proofsToSync: proofsToMeltFrom,
mintUrl,
isPending: true
}
@@ -378,27 +333,49 @@ export const transferTask = async function (
const refreshed = transactionsStore.findById(transaction.id)
if(refreshed?.status === TransactionStatus.PENDING) {
log.warn('[transfer]', 'proofsToPay from transfer with error are pending by mint', {
proofsToPayAmount,
log.warn('[transfer]', 'proofsToMeltFrom from transfer with error are pending by mint', {
proofsToMeltFromAmount,
unit,
transactionId: transaction.id
})
message = 'Lightning payment did not complete in time. Your ecash will remain pending until the payment completes or fails.'
} else if(refreshed?.status === TransactionStatus.COMPLETED) {
// Likely receiving of change failed due to wallet error. We keep completed as status.
log.error('[transfer]', 'Transfer throwed error but the payment suceeded', {
error: e.message,
proofsToMeltFromAmount,
unit,
transactionId: transaction.id
})
const message = `Lightning invoice has been successfully paid, however some error occured: ${e.message}`
return {
taskFunction: TRANSFER,
mintUrl,
transaction,
message,
meltQuote,
preimage: meltQuote?.payment_preimage,
nwcEvent
} as TransactionTaskResult
} else {
// syncStateWithMintSync returns to spendable only proofs that were pending by mint before so we need to take care of our transfer here.
log.warn('[transfer]', 'proofsToPay from transfer with error to be returned to spendable wallet', {
proofsToPayAmount,
proofsToMeltFromAmount,
unit,
transactionId: transaction.id
})
// remove it from pending proofs in the wallet
proofsStore.removeProofs(proofsToPay as Proof[], true, true)
proofsStore.removeProofs(proofsToMeltFrom, true, true)
// add proofs back to the spendable wallet with internal references
WalletUtils.addCashuProofs(
mintUrl,
proofsToPay,
proofsToMeltFrom,
{
unit,
transactionId: transaction.id,
+5 -1
View File
@@ -29,7 +29,11 @@ const lockAndSetInFlight = async function (
const walletInstance = await walletStore.getWallet(mint.mintUrl, unit, {withSeed: true})
const currentCounter = mint.getProofsCounterByKeysetId!(walletInstance.keys.id)
log.info('[lockAndSetInFlight] Before lock', {transactionId, counter: currentCounter.counter})
log.info('[lockAndSetInFlight] Before lock', {
transactionId,
counter: currentCounter.counter,
countOfInFlightProofs
})
if(!currentCounter) {
throw new AppError(Err.VALIDATION_ERROR, 'Missing ProofsCounter.')
+45 -3
View File
@@ -26,11 +26,13 @@ import { receiveTask, receiveOfflinePrepareTask, receiveOfflineCompleteTask} fro
import { sendTask } from './wallet/sendTask'
import { topupTask } from './wallet/topupTask'
import { transferTask } from './wallet/transferTask'
import { nwcTransferTask } from './wallet/nwcTransferTask'
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'
import { revertTask } from './wallet/revertTask'
export const MAX_SWAP_INPUT_SIZE = 100
@@ -71,6 +73,16 @@ type WalletTaskService = {
encodedInvoice: string,
nwcEvent?: NostrEvent
) => Promise<void>
nwcTransfer: (
mintBalanceToTransferFrom: MintBalance,
amountToTransfer: number,
feeReserve: number,
unit: MintUnit,
memo: string,
invoiceExpiry: Date,
encodedInvoice: string,
nwcEvent: NostrEvent
) => Promise<void>
receive: (
token: TokenV3,
amountToReceive: number,
@@ -123,8 +135,9 @@ export interface WalletTaskResult {
export interface TransactionTaskResult extends WalletTaskResult {
transaction?: Transaction
swapFeePaid?: number
lightningFeePaid?: number
mintFeePaid?: number
meltFeePaid?: number
meltQuote?: MeltQuoteResponse
nwcEvent?: NostrEvent
}
@@ -199,6 +212,34 @@ const transfer = async function (
}
const nwcTransfer = async function (
mintBalanceToTransferFrom: MintBalance,
amountToTransfer: number,
feeReserve: number,
unit: MintUnit,
memo: string,
invoiceExpiry: Date,
encodedInvoice: string,
nwcEvent: NostrEvent
): Promise<void> {
const now = new Date().getTime()
SyncQueue.addPrioritizedTask(
`nwcTransferTask-${now}`,
async () => await nwcTransferTask(
mintBalanceToTransferFrom,
amountToTransfer,
feeReserve,
unit,
memo,
invoiceExpiry,
encodedInvoice,
nwcEvent
)
)
return
}
const receive = async function (
token: TokenV3,
amountToReceive: number,
@@ -1806,7 +1847,8 @@ export const WalletTask: WalletTaskService = {
receiveOfflineComplete,
send,
sendAll,
transfer,
transfer,
nwcTransfer,
topup,
revert
}
+4 -4
View File
@@ -1648,9 +1648,9 @@ __metadata:
languageName: node
linkType: hard
"@cashu/cashu-ts@minibits-cash/cashu-ts#commit=1a60b7859627829dc29293d78a945095c335de63":
"@cashu/cashu-ts@minibits-cash/cashu-ts#commit=8dcac7ce6d192c419aee5547f0db03c23b593a57":
version: 1.0.0-minibits.6
resolution: "@cashu/cashu-ts@https://github.com/minibits-cash/cashu-ts.git#commit=1a60b7859627829dc29293d78a945095c335de63"
resolution: "@cashu/cashu-ts@https://github.com/minibits-cash/cashu-ts.git#commit=8dcac7ce6d192c419aee5547f0db03c23b593a57"
dependencies:
"@cashu/crypto": ^0.2.6
"@noble/curves": ^1.3.0
@@ -1658,7 +1658,7 @@ __metadata:
"@scure/bip32": ^1.3.3
"@scure/bip39": ^1.2.2
buffer: ^6.0.3
checksum: 79a83bca6644836268903d45ca5e43e863dbc48fc8ac6073fb218cf1c4ad05d9b70fcfa9e83a1e5edbe55bbc6128c281a6a769c490a36e7bc3d3ebcb5c48be4d
checksum: dfeebbfe10b99429da21848257eb8a320cb9936909487686d2847e2cdcc61390140ca4fd8da87227c66e3e1c972dc0c1e5689f7d197e55b873f202138a28189c
languageName: node
linkType: hard
@@ -9426,7 +9426,7 @@ __metadata:
"@babel/core": ^7.20.0
"@babel/preset-env": ^7.20.0
"@babel/runtime": ^7.20.0
"@cashu/cashu-ts": "minibits-cash/cashu-ts#commit=1a60b7859627829dc29293d78a945095c335de63"
"@cashu/cashu-ts": "minibits-cash/cashu-ts#commit=8dcac7ce6d192c419aee5547f0db03c23b593a57"
"@exodus/borc": ^2.1.1
"@fortawesome/fontawesome-svg-core": ^6.6.0
"@fortawesome/free-brands-svg-icons": ^6.6.0