mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 11:18:24 +00:00
One melt lifecycle, two rails. TransferOperationApi now handles both bolt11 (NUT-05) and onchain (NUT-30) melts; what differs between them is resolved once in resolveTransferMethod rather than branched on at each site that needs a fee or an expiry. There is deliberately one copy of the proof reservation, the preemptive swap, and the execute-error recovery matrix. cashu-ts' prepareMelt is already method-agnostic (it derives the NUT-08 blank count from inputs - quote.amount, not from fee_reserve), and fee_index rides along as extraPayload on completeMelt. So the prepare -> persist meltPreview -> complete split that melt-change recovery depends on survives intact. Substance, beyond the plumbing: - Onchain change can arrive at PENDING. The mint knows its miner fee the moment it builds the transaction, so it may return the unclaimed reserve with the spec-mandated PENDING response. bolt11's PENDING branch drops change on the floor (correctly - there is none yet); doing that here would strand signed proofs that nothing would ever look for again. execute() now commits change if present, and _finalizePaid subtracts what was already returned so banked change is not reported as fee. - Settlement is quote-driven, not proof-driven. The mint spending our inputs means it BROADCAST, not that the transaction confirmed. sync's _dispatchFinalize therefore routes TRANSFER_ONCHAIN to refresh() (which asks the mint and only completes on PAID) rather than finalize(), and sync now reports the status the dispatch actually reached instead of assuming COMPLETED - otherwise it would announce a Bitcoin payment as landed while it sat unconfirmed in the mempool. - Onchain transfers are never expired. The melt quote's expiry bounds executing the quote, not confirming the payment, which can outlive it by many blocks. - Mainnet only. The CDK fakewallet hands out regtest deposit addresses for topup quotes, so testers end up with one in their clipboard; pasting it back into Pay must not spend. Refused in the parser and again in prepare(), so a screen that forgets the check cannot move money. No websocket and no poller for onchain: settlement is bounded by block times, so the existing ~60s pending sweep is already finer-grained than what it waits for. Melts go through SyncQueue for the same counter-serialisation reason mints do. 87 tsc errors (unchanged baseline), 310/310 tests, i18n clean.
771 lines
29 KiB
TypeScript
771 lines
29 KiB
TypeScript
import {isAlive} from 'mobx-state-tree'
|
||
import {getDecodedToken} from '@cashu/cashu-ts'
|
||
import {log} from '../../logService'
|
||
import {NetworkError} from '../../../utils/AppError'
|
||
import {rootStoreInstance} from '../../../models'
|
||
import {Proof, ProofState} from '../../../models/Proof'
|
||
import {MintStatus} from '../../../models/Mint'
|
||
import {
|
||
Transaction,
|
||
TransactionData,
|
||
TransactionStatus,
|
||
TransactionType,
|
||
} from '../../../models/Transaction'
|
||
import {CashuUtils} from '../../cashu/cashuUtils'
|
||
import {SyncQueue} from '../../syncQueueService'
|
||
import {stopPolling} from '../../../utils/poller'
|
||
import {sendTask} from '../sendTask'
|
||
import {createQueueAwaitable} from '../queueHelper'
|
||
import {receiveBatchTask} from './receiveOperations'
|
||
import {SendOperationApi} from './sendOperationApi'
|
||
import {TransferOperationApi} from './transferOperationApi'
|
||
import {
|
||
MAX_SWAP_INPUT_SIZE,
|
||
MAX_SYNC_INPUT_SIZE,
|
||
SWAP_ALL_TASK,
|
||
SWAP_DENOMINATION_TASK,
|
||
SYNC_STATE_WITH_ALL_MINTS_TASK,
|
||
SYNC_STATE_WITH_MINT_TASK,
|
||
SyncStateTaskResult,
|
||
TransactionStateUpdate,
|
||
WalletTaskResult,
|
||
} from '../types'
|
||
|
||
const {
|
||
mintsStore,
|
||
proofsStore,
|
||
transactionsStore,
|
||
walletStore,
|
||
} = rootStoreInstance
|
||
|
||
/**
|
||
* Sync wallet proof state with mint reality (SPENT / PENDING / UNSPENT)
|
||
* Used both for pending proof resolution and recovery of broken wallet state.
|
||
*/
|
||
const syncStateWithMintTask = async function (
|
||
options: {
|
||
proofsToSync: Proof[]
|
||
mintUrl: string
|
||
proofState: ProofState
|
||
},
|
||
): Promise<SyncStateTaskResult> {
|
||
const {proofsToSync, mintUrl, proofState} = options
|
||
const mint = mintsStore.findByUrl(mintUrl)
|
||
|
||
log.trace('[syncStateWithMintTask] start', {mintUrl, proofCount: proofsToSync.length, proofState})
|
||
|
||
const transactionStateUpdates: TransactionStateUpdate[] = []
|
||
const completedTxIds: number[] = []
|
||
const errorTxIds: number[] = []
|
||
const pendingTxIds: number[] = []
|
||
const revertedTxIds: number[] = []
|
||
|
||
try {
|
||
const aliveProofs = proofsToSync.filter(p => isAlive(p))
|
||
|
||
if (aliveProofs.length === 0) {
|
||
const message = `No ${proofState === 'PENDING' ? 'pending ' : ''}proofs to sync with mint`
|
||
log.trace('[syncStateWithMintTask]', message)
|
||
return {
|
||
taskFunction: SYNC_STATE_WITH_MINT_TASK,
|
||
mintUrl,
|
||
message,
|
||
transactionStateUpdates,
|
||
completedTransactionIds: [],
|
||
errorTransactionIds: [],
|
||
revertedTransactionIds: [],
|
||
}
|
||
}
|
||
|
||
const statesFromMint = await walletStore.getProofsStatesFromMint(
|
||
mintUrl,
|
||
mint?.units?.[0] ?? 'sat',
|
||
aliveProofs,
|
||
)
|
||
|
||
if (mint) mint.setStatus(MintStatus.ONLINE)
|
||
|
||
const byMintState = {
|
||
SPENT: statesFromMint.SPENT.map(p => p.secret),
|
||
PENDING: statesFromMint.PENDING.map(p => p.secret),
|
||
UNSPENT: statesFromMint.UNSPENT.map(p => p.secret),
|
||
}
|
||
|
||
const secrets = {
|
||
spent: new Set(byMintState.SPENT),
|
||
pending: new Set(byMintState.PENDING),
|
||
unspent: new Set(byMintState.UNSPENT),
|
||
}
|
||
|
||
log.debug('[syncStateWithMintTask] Mint state', {
|
||
spent: secrets.spent.size,
|
||
pending: secrets.pending.size,
|
||
unspent: secrets.unspent.size,
|
||
proofState,
|
||
})
|
||
|
||
const groupByTId = (proofs: Proof[]) => {
|
||
const map = new Map<number, {proofs: Proof[]; amount: number}>()
|
||
for (const p of proofs) {
|
||
if (!p.tId) continue
|
||
const entry = map.get(p.tId) ?? {proofs: [], amount: 0}
|
||
entry.proofs.push(p)
|
||
entry.amount += p.amount
|
||
map.set(p.tId, entry)
|
||
}
|
||
return map
|
||
}
|
||
|
||
// 1. Proofs now SPENT at mint → transaction succeeded
|
||
//
|
||
// Bulk-move proofs to SPENT (one SQL write for N proofs), then
|
||
// dispatch per-tx to the appropriate operation API's `finalize`. Each
|
||
// finalize is idempotent and sync-aware: it sees no PENDING proofs
|
||
// left (we just bulk-moved them) and just flips the tx status, with
|
||
// any side-effects (e.g. melt change recovery for TRANSFER) handled
|
||
// atomically alongside the status update.
|
||
if (secrets.spent.size > 0) {
|
||
const spentProofs = aliveProofs.filter(p => secrets.spent.has(p.secret))
|
||
const spentByTx = groupByTId(spentProofs)
|
||
proofsStore.moveToSpent(spentProofs)
|
||
|
||
for (const [tId, {amount: spentAmount}] of spentByTx) {
|
||
const tx = transactionsStore.findById(tId)
|
||
if (!tx) {
|
||
errorTxIds.push(tId)
|
||
transactionStateUpdates.push({
|
||
tId,
|
||
updatedStatus: TransactionStatus.ERROR,
|
||
message: 'Transaction not found in DB',
|
||
})
|
||
continue
|
||
}
|
||
|
||
if (spentAmount < tx.amount) {
|
||
// Partial spend has no clean lifecycle equivalent — we stamp
|
||
// ERROR directly and release any still-UNSPENT siblings
|
||
// back to the spendable pool.
|
||
_markErrorBySync(tx, 'Partial spend detected – proofs reused')
|
||
errorTxIds.push(tId)
|
||
transactionStateUpdates.push({
|
||
tId,
|
||
amount: tx.amount,
|
||
spentByMintAmount: spentAmount,
|
||
updatedStatus: TransactionStatus.ERROR,
|
||
message: 'Partial spend detected – proofs reused',
|
||
})
|
||
|
||
if (proofState === 'PENDING') {
|
||
const stillUnspent = aliveProofs.filter(p => secrets.unspent.has(p.secret))
|
||
proofsStore.revertToSpendable(stillUnspent)
|
||
}
|
||
continue
|
||
}
|
||
|
||
if (tx.status === TransactionStatus.REVERTED) {
|
||
// Reclaim already happened — leave as REVERTED.
|
||
continue
|
||
}
|
||
|
||
try {
|
||
await _dispatchFinalize(tx)
|
||
|
||
// Report the status the dispatch actually reached, not the one the
|
||
// SPENT proofs implied. They are the same on every rail but one:
|
||
// an onchain melt whose inputs are spent has only been BROADCAST,
|
||
// and `refresh` deliberately leaves it PENDING until the mint
|
||
// reports the transaction confirmed. Announcing COMPLETED here
|
||
// would tell the user their Bitcoin payment had landed while it was
|
||
// still sitting unconfirmed in the mempool.
|
||
const settled = transactionsStore.findById(tId) ?? tx
|
||
const reachedStatus = settled.status
|
||
|
||
if (reachedStatus === TransactionStatus.COMPLETED) {
|
||
completedTxIds.push(tId)
|
||
} else {
|
||
pendingTxIds.push(tId)
|
||
}
|
||
|
||
transactionStateUpdates.push({
|
||
tId,
|
||
amount: tx.amount,
|
||
spentByMintAmount: spentAmount,
|
||
updatedStatus: reachedStatus,
|
||
})
|
||
} catch (e: any) {
|
||
log.error('[syncStateWithMintTask] finalize dispatch failed', {
|
||
tId,
|
||
type: tx.type,
|
||
error: e.message,
|
||
})
|
||
_markErrorBySync(tx, e.message)
|
||
errorTxIds.push(tId)
|
||
transactionStateUpdates.push({
|
||
tId,
|
||
amount: tx.amount,
|
||
spentByMintAmount: spentAmount,
|
||
updatedStatus: TransactionStatus.ERROR,
|
||
message: `Finalize failed: ${e.message}`,
|
||
})
|
||
}
|
||
}
|
||
|
||
if (completedTxIds.length > 0 || errorTxIds.length > 0) {
|
||
stopPolling(`syncStateWithMintPoller-${mintUrl}`)
|
||
}
|
||
}
|
||
|
||
// 2. Proofs still PENDING at mint → register as mint-pending, mark
|
||
// owning tx PENDING. No lifecycle equivalent — this is a sub-state
|
||
// (locally PENDING + mint-side PENDING) tracked via pendingByMintSecrets
|
||
// so branch 3 can later detect lightning failures.
|
||
if (secrets.pending.size > 0) {
|
||
const newPendingProofs = aliveProofs.filter(p => secrets.pending.has(p.secret) && !proofsStore.pendingByMintSecrets.includes(p.secret))
|
||
|
||
if (newPendingProofs.length > 0) {
|
||
proofsStore.registerAsPendingAtMint(newPendingProofs)
|
||
const pendingByTx = groupByTId(newPendingProofs)
|
||
|
||
for (const [tId, {amount: pendingAmount}] of pendingByTx) {
|
||
if (!pendingTxIds.includes(tId)) pendingTxIds.push(tId)
|
||
transactionStateUpdates.push({
|
||
tId,
|
||
pendingByMintAmount: pendingAmount,
|
||
updatedStatus: TransactionStatus.PENDING,
|
||
})
|
||
}
|
||
|
||
if (proofState !== 'PENDING') {
|
||
proofsStore.moveToPending(newPendingProofs)
|
||
}
|
||
|
||
if (pendingTxIds.length > 0) {
|
||
await transactionsStore.updateStatuses(
|
||
pendingTxIds,
|
||
TransactionStatus.PENDING,
|
||
JSON.stringify({
|
||
message: 'Waiting for a payment to settle',
|
||
pendingStateUpdates: transactionStateUpdates.filter(u => u.updatedStatus === TransactionStatus.PENDING),
|
||
createdAt: new Date(),
|
||
}),
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. Proofs no longer pending at mint → lightning failed.
|
||
//
|
||
// Dispatch each affected tx to `TransferOperationApi.refresh` — its
|
||
// UNPAID branch reverts proofs to spendable and stamps the tx
|
||
// REVERTED. (Only TRANSFER txs ever hit this branch; melts are the
|
||
// only operation that registers mint-pending state.)
|
||
const noLongerPendingSecrets = proofsStore.pendingByMintSecrets.filter(
|
||
s => !secrets.pending.has(s),
|
||
)
|
||
|
||
if (noLongerPendingSecrets.length > 0) {
|
||
// Unregister all at once; per-tx refresh handles the proof revert.
|
||
const txsToRefresh = new Map<number, number>()
|
||
for (const secret of noLongerPendingSecrets) {
|
||
const proof = proofsStore.getBySecret(secret)
|
||
if (!proof || !proof.tId) continue
|
||
txsToRefresh.set(proof.tId, (txsToRefresh.get(proof.tId) ?? 0) + proof.amount)
|
||
}
|
||
proofsStore.unregisterFromPendingAtMint(new Set(noLongerPendingSecrets))
|
||
|
||
for (const [tId, amount] of txsToRefresh) {
|
||
const tx = transactionsStore.findById(tId)
|
||
if (!tx) continue
|
||
|
||
if (
|
||
tx.type !== TransactionType.TRANSFER &&
|
||
tx.type !== TransactionType.TRANSFER_ONCHAIN
|
||
) {
|
||
log.warn(
|
||
'[syncStateWithMintTask] Unexpected non-TRANSFER tx in branch 3',
|
||
{tId, type: tx.type},
|
||
)
|
||
continue
|
||
}
|
||
|
||
try {
|
||
await TransferOperationApi.refresh(tId)
|
||
if (!revertedTxIds.includes(tId)) revertedTxIds.push(tId)
|
||
transactionStateUpdates.push({
|
||
tId,
|
||
movedToSpendableAmount: amount,
|
||
updatedStatus: TransactionStatus.REVERTED,
|
||
})
|
||
} catch (e: any) {
|
||
log.error('[syncStateWithMintTask] refresh dispatch failed', {
|
||
tId,
|
||
error: e.message,
|
||
})
|
||
_markErrorBySync(tx, e.message)
|
||
errorTxIds.push(tId)
|
||
transactionStateUpdates.push({
|
||
tId,
|
||
movedToSpendableAmount: amount,
|
||
updatedStatus: TransactionStatus.ERROR,
|
||
message: `Refresh failed: ${e.message}`,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
taskFunction: SYNC_STATE_WITH_MINT_TASK,
|
||
mintUrl,
|
||
message: `Sync completed for ${aliveProofs.length} ${proofState === 'PENDING' ? 'pending ' : ''}proofs`,
|
||
transactionStateUpdates,
|
||
completedTransactionIds: completedTxIds,
|
||
errorTransactionIds: errorTxIds,
|
||
pendingTransactionIds: pendingTxIds,
|
||
revertedTransactionIds: revertedTxIds,
|
||
}
|
||
} catch (e: any) {
|
||
log.error('[syncStateWithMintTask] failed', {mintUrl, error: e.message, stack: e.stack})
|
||
|
||
if (mint && e instanceof NetworkError) {
|
||
mint.setStatus(MintStatus.OFFLINE)
|
||
}
|
||
|
||
return {
|
||
taskFunction: SYNC_STATE_WITH_MINT_TASK,
|
||
mintUrl,
|
||
message: `Sync failed: ${e.message}`,
|
||
error: e,
|
||
transactionStateUpdates,
|
||
completedTransactionIds: completedTxIds,
|
||
errorTransactionIds: errorTxIds,
|
||
pendingTransactionIds: pendingTxIds,
|
||
revertedTransactionIds: revertedTxIds,
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Route a sync-confirmed-SPENT transaction to the appropriate operation API. Sync has
|
||
* already bulk-moved the proofs to SPENT, so each finalize sees an empty PENDING set
|
||
* and just stamps the tx COMPLETED (and, for TRANSFER, recovers melt change atomically
|
||
* with the status update).
|
||
*
|
||
* Only SEND, TRANSFER and TRANSFER_ONCHAIN are expected here — other types don't park
|
||
* proofs in PENDING that sync could later observe as SPENT.
|
||
*
|
||
* TRANSFER_ONCHAIN goes to `refresh`, NOT `finalize`, and the distinction is the whole
|
||
* point. On every other rail, the mint spending our inputs IS settlement. On onchain it
|
||
* is not: the mint takes the inputs when it BROADCASTS, and the payment is not settled
|
||
* until the transaction is mined — which may be many blocks later, or never, if the
|
||
* transaction is dropped. Finalizing here would report an unconfirmed payment as
|
||
* COMPLETED at exactly the moment it is least certain. `refresh` asks the mint for the
|
||
* quote state and only completes on PAID, leaving the transaction PENDING otherwise.
|
||
*/
|
||
async function _dispatchFinalize(tx: Transaction): Promise<void> {
|
||
switch (tx.type) {
|
||
case TransactionType.SEND:
|
||
await SendOperationApi.finalize(tx.id)
|
||
return
|
||
case TransactionType.TRANSFER:
|
||
await TransferOperationApi.finalize(tx.id)
|
||
return
|
||
case TransactionType.TRANSFER_ONCHAIN:
|
||
await TransferOperationApi.refresh(tx.id)
|
||
return
|
||
default:
|
||
log.warn('[syncStateWithMintTask] Unexpected tx type in finalize dispatch', {
|
||
tId: tx.id,
|
||
type: tx.type,
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Stamp a transaction as ERROR with the supplied reason. Used for sync's
|
||
* type-agnostic failure paths (partial spend, finalize/refresh dispatch
|
||
* throws) — there's no lifecycle-method equivalent for "external system
|
||
* forced this tx into an error state."
|
||
*/
|
||
function _markErrorBySync(tx: Transaction, reason: string): void {
|
||
let txData: TransactionData[] = []
|
||
try { txData = JSON.parse(tx.data) } catch {}
|
||
txData.push({
|
||
status: TransactionStatus.ERROR,
|
||
message: reason,
|
||
createdAt: new Date(),
|
||
})
|
||
tx.update({status: TransactionStatus.ERROR, data: JSON.stringify(txData)})
|
||
}
|
||
|
||
const syncStateWithAllMintsTask = async function (options: {
|
||
proofState: ProofState
|
||
}): Promise<SyncStateTaskResult> {
|
||
log.trace('[syncStateWithAllMintsTask] start')
|
||
if (mintsStore.mintCount === 0) {
|
||
return {
|
||
taskFunction: SYNC_STATE_WITH_ALL_MINTS_TASK,
|
||
transactionStateUpdates: [],
|
||
completedTransactionIds: [],
|
||
errorTransactionIds: [],
|
||
revertedTransactionIds: [],
|
||
message: 'No mints',
|
||
}
|
||
}
|
||
|
||
const {proofState} = options
|
||
const maxBatchSize = MAX_SYNC_INPUT_SIZE
|
||
const transactionStateUpdates: TransactionStateUpdate[] = []
|
||
const completedTransactionIds: number[] = []
|
||
const errorTransactionIds: number[] = []
|
||
const revertedTransactionIds: number[] = []
|
||
const errors: string[] = []
|
||
|
||
for (const mint of mintsStore.allMints) {
|
||
const proofsToSync = proofsStore.getByMint(mint.mintUrl, {state: proofState})
|
||
const totalProofsCount = proofsToSync.length
|
||
|
||
if (totalProofsCount === 0) {
|
||
log.trace('[syncStateWithAllMintsTask] No proofs to sync, skipping...', {mint: mint.mintUrl})
|
||
continue
|
||
}
|
||
|
||
if (totalProofsCount > maxBatchSize) {
|
||
for (let i = 0; i < totalProofsCount; i += maxBatchSize) {
|
||
const batch = proofsToSync.slice(i, i + maxBatchSize)
|
||
const result = await syncStateWithMintTask({proofsToSync: batch, mintUrl: mint.mintUrl, proofState})
|
||
|
||
transactionStateUpdates.push(...result.transactionStateUpdates)
|
||
completedTransactionIds.push(...result.completedTransactionIds)
|
||
errorTransactionIds.push(...result.errorTransactionIds)
|
||
revertedTransactionIds.push(...result.revertedTransactionIds)
|
||
|
||
if (result.error) {
|
||
errors.push(result.error.message)
|
||
}
|
||
}
|
||
} else {
|
||
const result = await syncStateWithMintTask({proofsToSync, mintUrl: mint.mintUrl, proofState})
|
||
|
||
transactionStateUpdates.push(...result.transactionStateUpdates)
|
||
completedTransactionIds.push(...result.completedTransactionIds)
|
||
errorTransactionIds.push(...result.errorTransactionIds)
|
||
revertedTransactionIds.push(...result.revertedTransactionIds)
|
||
|
||
if (result.error) {
|
||
errors.push(result.error.message)
|
||
}
|
||
}
|
||
}
|
||
|
||
let totalSpent = 0
|
||
let message = ''
|
||
|
||
for (const update of transactionStateUpdates) {
|
||
if (update.spentByMintAmount) {
|
||
totalSpent += update.spentByMintAmount
|
||
}
|
||
}
|
||
|
||
if (proofState === 'PENDING') {
|
||
message = 'Pending proofs were synced with the mints.'
|
||
} else {
|
||
message = `Sync completed with ${errors.length} errors. Spent ecash with ${totalSpent} amount was cleaned.`
|
||
}
|
||
|
||
return {
|
||
taskFunction: SYNC_STATE_WITH_ALL_MINTS_TASK,
|
||
transactionStateUpdates,
|
||
completedTransactionIds,
|
||
errorTransactionIds,
|
||
revertedTransactionIds,
|
||
errors,
|
||
message,
|
||
}
|
||
}
|
||
|
||
const syncStateWithAllMintsQueueAwaitable = (
|
||
options: {proofState: ProofState},
|
||
): Promise<SyncStateTaskResult> =>
|
||
createQueueAwaitable<SyncStateTaskResult>({
|
||
taskFunction: 'syncSpendableStateTask',
|
||
timeoutMessage: 'Sync all mints state timed out',
|
||
task: () => syncStateWithAllMintsTask({proofState: options.proofState}),
|
||
})
|
||
|
||
const syncStateWithMintQueueAwaitable = (
|
||
options: {
|
||
proofsToSync: Proof[]
|
||
mintUrl: string
|
||
proofState: ProofState
|
||
},
|
||
): Promise<SyncStateTaskResult> => {
|
||
log.trace('[syncStateWithMintQueueAwaitable] start', {
|
||
mintUrl: options.mintUrl,
|
||
proofState: options.proofState,
|
||
proofsToSyncCount: options.proofsToSync.length,
|
||
})
|
||
|
||
return createQueueAwaitable<SyncStateTaskResult>({
|
||
taskFunction: SYNC_STATE_WITH_MINT_TASK,
|
||
prioritized: false,
|
||
timeoutMessage: 'Sync mint state timed out',
|
||
task: () => syncStateWithMintTask(options),
|
||
})
|
||
}
|
||
|
||
/*
|
||
* swapAllTask sends all proofs to pending and swaps them with the mint for standard amount preference
|
||
* This decreases the total number of proofs held by the wallet. Used to optimize exported backup size.
|
||
* Heavy, needs to run using the foreground service. May freeze the wallet.
|
||
*/
|
||
const swapAllTask = async function (): Promise<WalletTaskResult> {
|
||
log.trace('[swapAllTask] start')
|
||
|
||
if (mintsStore.mintCount === 0) {
|
||
return {
|
||
taskFunction: SWAP_ALL_TASK,
|
||
message: 'No mints to swap with.',
|
||
}
|
||
}
|
||
|
||
let initialProofsCount = 0
|
||
let finalProofsCount = 0
|
||
const errors: string[] = []
|
||
const maxBatchSize = MAX_SWAP_INPUT_SIZE
|
||
|
||
for (const mint of mintsStore.allMints) {
|
||
for (const unit of mint.units) {
|
||
const proofsToOptimize = proofsStore.getByMint(mint.mintUrl, {state: 'UNSPENT', unit, ascending: true})
|
||
|
||
if (proofsToOptimize.length === 0) {
|
||
continue
|
||
}
|
||
|
||
initialProofsCount += proofsToOptimize.length
|
||
const mintBalance = mint.balances
|
||
|
||
if (proofsToOptimize.length > maxBatchSize) {
|
||
let index = 0
|
||
for (let i = 0; i < proofsToOptimize.length; i += maxBatchSize) {
|
||
index++
|
||
const batch = proofsToOptimize.slice(i, i + maxBatchSize)
|
||
const batchAmount = CashuUtils.getProofsAmount(batch)
|
||
|
||
const sendResult = await sendTask(
|
||
mintBalance!,
|
||
batchAmount,
|
||
unit,
|
||
`Optimize ecash #${index}`,
|
||
batch,
|
||
)
|
||
|
||
const encodedTokenToReceive: string = sendResult.transaction?.outputToken ?? ''
|
||
|
||
const tokenToReceive = getDecodedToken(encodedTokenToReceive, mint.keysetIds)
|
||
const tokenAmount = CashuUtils.getProofsAmount(tokenToReceive.proofs)
|
||
|
||
const receiveResult = await receiveBatchTask(
|
||
tokenToReceive,
|
||
tokenAmount,
|
||
tokenToReceive.memo as string,
|
||
encodedTokenToReceive,
|
||
)
|
||
|
||
await syncStateWithMintTask({proofsToSync: batch, mintUrl: mint.mintUrl, proofState: 'PENDING'})
|
||
|
||
if (receiveResult.receivedProofsCount && receiveResult.receivedProofsCount > 0) {
|
||
finalProofsCount += receiveResult.receivedProofsCount
|
||
}
|
||
|
||
if (receiveResult.error) {
|
||
errors.push(receiveResult.error.message)
|
||
}
|
||
}
|
||
} else {
|
||
const proofsAmount = CashuUtils.getProofsAmount(proofsToOptimize)
|
||
|
||
const sendResult = await sendTask(
|
||
mintBalance!,
|
||
proofsAmount,
|
||
unit,
|
||
`Optimize ecash`,
|
||
proofsToOptimize,
|
||
)
|
||
|
||
const encodedTokenToReceive: string = sendResult.transaction?.outputToken ?? ''
|
||
const tokenToReceive = getDecodedToken(encodedTokenToReceive, mint.keysetIds)
|
||
const tokenAmount = CashuUtils.getProofsAmount(tokenToReceive.proofs)
|
||
|
||
const receiveResult = await receiveBatchTask(
|
||
tokenToReceive,
|
||
tokenAmount,
|
||
tokenToReceive.memo as string,
|
||
encodedTokenToReceive,
|
||
)
|
||
|
||
if (receiveResult.receivedProofsCount && receiveResult.receivedProofsCount > 0) {
|
||
finalProofsCount += receiveResult.receivedProofsCount
|
||
}
|
||
|
||
if (receiveResult.error) {
|
||
errors.push(receiveResult.error.message)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
taskFunction: SWAP_ALL_TASK,
|
||
message: `Proofs optimization completed with ${errors.length} errors. Proofs number went from ${initialProofsCount} to ${finalProofsCount}`,
|
||
initialProofsCount,
|
||
finalProofsCount,
|
||
errors,
|
||
}
|
||
}
|
||
|
||
/*
|
||
* swapByDenominationTask swaps only proofs with the given denomination (amount)
|
||
* for a single mint identified by mintUrl.
|
||
*/
|
||
const swapByDenominationTask = async function (
|
||
denomination: number,
|
||
mintUrl: string,
|
||
): Promise<WalletTaskResult> {
|
||
log.trace('[swapByDenominationTask] start', {denomination, mintUrl})
|
||
|
||
if (mintsStore.mintCount === 0) {
|
||
return {
|
||
taskFunction: SWAP_DENOMINATION_TASK,
|
||
message: 'No mints to swap with.',
|
||
}
|
||
}
|
||
|
||
const mint = mintsStore.findByUrl(mintUrl)
|
||
|
||
if (!mint) {
|
||
return {
|
||
taskFunction: SWAP_DENOMINATION_TASK,
|
||
message: `Mint ${mintUrl} not found.`,
|
||
}
|
||
}
|
||
|
||
let initialProofsCount = 0
|
||
let finalProofsCount = 0
|
||
const errors: string[] = []
|
||
const maxBatchSize = MAX_SWAP_INPUT_SIZE
|
||
|
||
for (const unit of mint.units) {
|
||
const allProofs = proofsStore.getByMint(mint.mintUrl, {state: 'UNSPENT', unit, ascending: true})
|
||
const proofsToOptimize = allProofs.filter(p => p.amount === denomination)
|
||
|
||
if (proofsToOptimize.length === 0) {
|
||
continue
|
||
}
|
||
|
||
initialProofsCount += proofsToOptimize.length
|
||
const mintBalance = mint.balances
|
||
|
||
if (proofsToOptimize.length > maxBatchSize) {
|
||
let index = 0
|
||
for (let i = 0; i < proofsToOptimize.length; i += maxBatchSize) {
|
||
index++
|
||
const batch = proofsToOptimize.slice(i, i + maxBatchSize)
|
||
const batchAmount = CashuUtils.getProofsAmount(batch)
|
||
|
||
const sendResult = await sendTask(
|
||
mintBalance!,
|
||
batchAmount,
|
||
unit,
|
||
`Optimize denomination ${denomination} #${index}`,
|
||
batch,
|
||
)
|
||
|
||
const encodedTokenToReceive: string = sendResult.transaction?.outputToken ?? ''
|
||
const tokenToReceive = getDecodedToken(encodedTokenToReceive, mint.keysetIds)
|
||
const tokenAmount = CashuUtils.getProofsAmount(tokenToReceive.proofs)
|
||
|
||
const receiveResult = await receiveBatchTask(
|
||
tokenToReceive,
|
||
tokenAmount,
|
||
tokenToReceive.memo as string,
|
||
encodedTokenToReceive,
|
||
)
|
||
|
||
await syncStateWithMintTask({proofsToSync: batch, mintUrl: mint.mintUrl, proofState: 'PENDING'})
|
||
|
||
if (receiveResult.receivedProofsCount && receiveResult.receivedProofsCount > 0) {
|
||
finalProofsCount += receiveResult.receivedProofsCount
|
||
}
|
||
if (receiveResult.error) {
|
||
errors.push(receiveResult.error.message)
|
||
}
|
||
}
|
||
} else {
|
||
const proofsAmount = CashuUtils.getProofsAmount(proofsToOptimize)
|
||
|
||
const sendResult = await sendTask(
|
||
mintBalance!,
|
||
proofsAmount,
|
||
unit,
|
||
`Optimize denomination ${denomination}`,
|
||
proofsToOptimize,
|
||
)
|
||
|
||
const encodedTokenToReceive: string = sendResult.transaction?.outputToken ?? ''
|
||
const tokenToReceive = getDecodedToken(encodedTokenToReceive, mint.keysetIds)
|
||
const tokenAmount = CashuUtils.getProofsAmount(tokenToReceive.proofs)
|
||
|
||
const receiveResult = await receiveBatchTask(
|
||
tokenToReceive,
|
||
tokenAmount,
|
||
tokenToReceive.memo as string,
|
||
encodedTokenToReceive,
|
||
)
|
||
|
||
await syncStateWithMintTask({proofsToSync: proofsToOptimize, mintUrl: mint.mintUrl, proofState: 'PENDING'})
|
||
|
||
if (receiveResult.receivedProofsCount && receiveResult.receivedProofsCount > 0) {
|
||
finalProofsCount += receiveResult.receivedProofsCount
|
||
}
|
||
if (receiveResult.error) {
|
||
errors.push(receiveResult.error.message)
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
taskFunction: SWAP_DENOMINATION_TASK,
|
||
message: `Denomination ${denomination} optimization completed with ${errors.length} errors. Proofs went from ${initialProofsCount} to ${finalProofsCount}`,
|
||
initialProofsCount,
|
||
finalProofsCount,
|
||
errors,
|
||
}
|
||
}
|
||
|
||
const swapAllQueue = async function (): Promise<void> {
|
||
const now = new Date().getTime()
|
||
return SyncQueue.addPrioritizedTask(
|
||
`swapAllTask-${now}`,
|
||
async () => await swapAllTask(),
|
||
)
|
||
}
|
||
|
||
const swapByDenominationQueue = async function (
|
||
denomination: number,
|
||
mintUrl: string,
|
||
): Promise<void> {
|
||
const now = new Date().getTime()
|
||
return SyncQueue.addPrioritizedTask(
|
||
`swapDenominationTask-${denomination}-${now}`,
|
||
async () => await swapByDenominationTask(denomination, mintUrl),
|
||
)
|
||
}
|
||
|
||
export const SyncOperationService = {
|
||
syncStateWithAllMintsQueueAwaitable,
|
||
syncStateWithMintQueueAwaitable,
|
||
syncStateWithMintTask,
|
||
swapAllQueue,
|
||
swapByDenominationQueue,
|
||
}
|