Files
minibits_wallet/src/services/wallet/operations/receiveOperationApi.ts
T

599 lines
24 KiB
TypeScript

/**
* Receive (cashu-token) operation lifecycle API.
*
* Splits the historical `receiveTask` / `receiveOfflinePrepareTask` /
* `receiveOfflineCompleteTask` trio into explicit lifecycle methods:
*
* prepare() → PreparedReceiveData (DRAFT → PREPARED for online mode, or
* DRAFT → PREPARED_OFFLINE after local
* DLEQ verification for offline mode)
* execute() → CompletedTransaction (PREPARED|PREPARED_OFFLINE → COMPLETED;
* swap proofs with mint, atomic commit)
* cancel() → RevertedTransaction (PREPARED|PREPARED_OFFLINE → REVERTED;
* user abandons before completing)
* reclaim() → never (not applicable — received ecash is
* already in the wallet)
* finalize() → CompletedTransaction (alias for execute — no async wait
* state for receive)
* refresh() → Transaction (no-op; receive doesn't poll)
*
* Mint-block check happens early in prepare(): blocked mints transition the
* tx directly to BLOCKED and skip prepare's normal exit (PREPARED). Callers
* see a BLOCKED transaction and handle the UI accordingly.
*
* Atomic commit on execute(): the proofs INSERT and the tx UPDATE land in
* one SQLite transaction via an empty reservation (same trick as topup
* finalize). Closes the proofs ↔ transactions atomicity window: a crash
* mid-execute used to leave proofs added without the tx COMPLETED stamp.
*/
import {
Token,
getDecodedToken,
getEncodedToken,
normalizeProofAmounts,
} from '@cashu/cashu-ts'
import {log} from '../../logService'
import {decodeTokenWithKeysets} from '../decodeToken'
import {MintError, ValidationError, WalletError} from '../../../utils/AppError'
import {rootStoreInstance} from '../../../models'
import {
Transaction,
TransactionData,
TransactionStatus,
TransactionType,
} from '../../../models/Transaction'
import {
BlockedTransaction,
CompletedTransaction,
PreparedTransaction,
RevertedTransaction,
isBlocked,
isCompleted,
isPrepared,
isReverted,
} from '../../../models/TransactionStates'
import {CashuProof, CashuUtils} from '../../cashu/cashuUtils'
import {MintUnit, formatCurrency, getCurrency} from '../currency'
import {WalletUtils} from '../utils'
import {ReceiveMethodInput} from './receiveMethods'
const {mintsStore, proofsStore, transactionsStore, walletStore} = rootStoreInstance
// ─────────────────────────────────────────────────────────────────────────────
// Public types
// ─────────────────────────────────────────────────────────────────────────────
export interface PrepareReceiveInput {
/** Mint that issued the token (parsed from token.mint; pass here for the wrapper to skip a decode). */
mintUrl: string
/** Amount the user expects to receive (validated against the token total in execute). */
amount: number
unit: MintUnit
memo: string
/** Receive method discriminator (currently only `cashu-token`). */
method: ReceiveMethodInput
}
/**
* Returned by `prepare`. Carries the decoded token and enough metadata for
* execute() to swap proofs without re-decoding.
*
* For BLOCKED outcomes, the wrapper handles the surface — `prepare` resolves
* to a normal `PreparedReceiveData` with `blocked: true` and a tx already in
* `BLOCKED` state. Execute on a blocked tx will refuse.
*/
export interface PreparedReceiveData {
transactionId: number
/**
* Snapshot at prepare time. May be PREPARED, PREPARED_OFFLINE, or BLOCKED
* — execute() narrows back to PreparedTransaction via the status check.
*/
tx: Transaction
mintUrl: string
unit: MintUnit
amountToReceive: number
memo: string
/** True if the mint was blocked at prepare time (tx is in BLOCKED state). */
blocked: boolean
/** True if prepare ran in offline mode (no mint contact yet). */
isOffline: boolean
method: ReceiveMethodInput
}
// ─────────────────────────────────────────────────────────────────────────────
// prepare()
// ─────────────────────────────────────────────────────────────────────────────
async function prepare(input: PrepareReceiveInput): Promise<PreparedReceiveData> {
const {mintUrl, amount, unit, memo, method} = input
if (amount <= 0) {
throw new ValidationError('Amount to receive must be above zero.')
}
if (method.method !== 'cashu-token') {
throw new ValidationError(`Unsupported receive method: ${(method as any).method}`)
}
const {token, encodedToken, offline} = method.options
if (!mintUrl) {
throw new ValidationError('Token is missing a mint param.')
}
const isOffline = !!offline
// ── Create draft tx (RECEIVE_OFFLINE for offline mode, RECEIVE otherwise) ──
const transactionData: TransactionData[] = [
{
status: TransactionStatus.DRAFT,
amountToReceive: amount,
unit,
createdAt: new Date(),
},
]
const transaction = await transactionsStore.addTransaction({
type: isOffline ? TransactionType.RECEIVE_OFFLINE : TransactionType.RECEIVE,
amount,
fee: 0,
unit,
data: JSON.stringify(transactionData),
memo,
mint: mintUrl,
status: TransactionStatus.DRAFT,
})
if (!transaction) {
throw new ValidationError('Failed to create draft transaction')
}
const transactionId = transaction.id
// Stash the encoded token now so an offline-prepared tx can be completed
// later from disk (after restart) without the caller re-supplying it.
transaction.update({inputToken: encodedToken})
// ── Blocked mint short-circuit ───────────────────────────────────────
if (mintsStore.isBlocked(mintUrl)) {
transactionData.push({
status: TransactionStatus.BLOCKED,
mintToReceive: mintUrl,
createdAt: new Date(),
})
transaction.update({
status: TransactionStatus.BLOCKED,
data: JSON.stringify(transactionData),
})
return {
transactionId,
tx: transaction,
mintUrl,
unit,
amountToReceive: amount,
memo,
blocked: true,
isOffline,
method,
}
}
// ── Offline DLEQ verification (no mint contact) ──────────────────────
if (isOffline) {
const mintInstance = mintsStore.findByUrl(mintUrl)
if (!mintInstance) {
throw new ValidationError(
'This token cannot be verified offline because the mint is not saved in your wallet. Go online to add the mint or receive it online.',
{caller: 'ReceiveOperationApi.prepare', mintUrl},
)
}
if (
!mintInstance.keysetIds ||
mintInstance.keysetIds.length === 0 ||
!mintInstance.keys ||
mintInstance.keys.length === 0
) {
throw new ValidationError(
'This token cannot be verified offline because the mint keys are not saved. Sync the mint online first.',
{caller: 'ReceiveOperationApi.prepare', mintUrl},
)
}
CashuUtils.verifyProofsDleqOrThrow(token.proofs, mintInstance.keys)
transactionData.push({
status: TransactionStatus.PREPARED_OFFLINE,
createdAt: new Date(),
})
transaction.update({
status: TransactionStatus.PREPARED_OFFLINE,
data: JSON.stringify(transactionData),
})
} else {
transactionData.push({
status: TransactionStatus.PREPARED,
method: method.method,
createdAt: new Date(),
})
transaction.update({
status: TransactionStatus.PREPARED,
data: JSON.stringify(transactionData),
})
}
if (!isPrepared(transaction)) {
throw new WalletError('Failed to transition transaction to PREPARED', {
transactionId,
status: transaction.status,
})
}
log.debug('[ReceiveOperationApi.prepare]', 'Prepared', {
transactionId,
amount,
mintUrl,
isOffline,
})
return {
transactionId,
tx: transaction,
mintUrl,
unit,
amountToReceive: amount,
memo,
blocked: false,
isOffline,
method,
}
}
// ─────────────────────────────────────────────────────────────────────────────
// execute()
//
// PREPARED|PREPARED_OFFLINE → COMPLETED. Decodes the stored input token,
// swaps proofs with the mint, atomically commits proofs INSERT + tx UPDATE.
// ─────────────────────────────────────────────────────────────────────────────
async function execute(prepared: PreparedReceiveData): Promise<CompletedTransaction> {
const tx = transactionsStore.findById(prepared.transactionId)
if (!tx) {
throw new ValidationError('Transaction not found', {
transactionId: prepared.transactionId,
})
}
if (isBlocked(tx)) {
throw new ValidationError(
`Cannot execute receive: mint ${prepared.mintUrl} is blocked.`,
{transactionId: tx.id},
)
}
if (!isPrepared(tx)) {
throw new ValidationError(
`Cannot execute receive in state ${tx.status}. Expected PREPARED or PREPARED_OFFLINE.`,
{transactionId: tx.id, status: tx.status},
)
}
// Re-check blocked status — a mint could have been blocked between
// prepare (possibly long ago for offline-prepared txs) and execute.
if (mintsStore.isBlocked(prepared.mintUrl)) {
const txData = _parseData(tx)
txData.push({
status: TransactionStatus.BLOCKED,
mintToReceive: prepared.mintUrl,
createdAt: new Date(),
})
tx.update({status: TransactionStatus.BLOCKED, data: JSON.stringify(txData)})
throw new ValidationError(
`The mint ${prepared.mintUrl} is blocked. You can unblock it in Settings.`,
{transactionId: tx.id},
)
}
// Decode the stored token. For online flow this is the same token from
// prepare; for offline-complete-after-restart it comes from disk.
if (!tx.inputToken) {
throw new ValidationError('Could not find ecash token to redeem', {
transactionId: tx.id,
})
}
// Ensure the mint exists (offline-prepared receives from new mints may have
// not added it to the wallet yet — the original code did this too).
if (!mintsStore.alreadyExists(prepared.mintUrl)) {
await mintsStore.addMint(prepared.mintUrl)
}
const mintInstance = mintsStore.findByUrl(prepared.mintUrl)
if (!mintInstance) {
throw new ValidationError('Missing mint', {mintUrl: prepared.mintUrl})
}
// Refreshes the keysets and retries when the stored token turns out to carry a
// keyset id the wallet does not know — an offline-prepared receive can be
// executed long after it was prepared, by which time the mint may have rotated.
const token = await decodeTokenWithKeysets(tx.inputToken, mintInstance.mintUrl)
// ── Swap proofs with the mint (with outputs-error healing retry) ────
const {proofs, swapFeePaid} = await _receiveWithHealing(
prepared.mintUrl,
prepared.unit,
token,
tx.id,
)
const receivedAmount = CashuUtils.getProofsAmount(proofs)
const outputToken = getEncodedToken({
mint: prepared.mintUrl,
proofs: normalizeProofAmounts(proofs),
unit: prepared.unit,
memo: token.memo ?? undefined,
})
const currentSpendable = proofsStore.getUnitBalance(prepared.unit)?.unitBalance ?? 0
const balanceAfter = currentSpendable + receivedAmount
const txData = _parseData(tx)
txData.push({
status: TransactionStatus.COMPLETED,
swapFeePaid,
receivedAmount,
unit: prepared.unit,
createdAt: new Date(),
})
// Atomic commit: proofs INSERT (UNSPENT) + tx UPDATE (→ COMPLETED) in
// one SQLite transaction. Empty reservation used purely as the batch
// primitive (no local proofs to lock for a receive).
const reservation = proofsStore.reserve([], {
transactionId: tx.id,
mintUrl: prepared.mintUrl,
unit: prepared.unit,
operationType: 'receive-finalize',
rollbackTo: 'UNSPENT',
})
proofsStore.commitReservation(reservation, {
newProofs: [{proofs, state: 'UNSPENT', tId: tx.id}],
transactionUpdate: {
id: tx.id,
status: TransactionStatus.COMPLETED,
data: JSON.stringify(txData),
keysetId: proofs[0].id,
outputToken,
balanceAfter,
...(swapFeePaid > 0 && {fee: swapFeePaid}),
},
})
log.debug('[ReceiveOperationApi.execute]', 'Receive completed', {
transactionId: tx.id,
receivedAmount,
swapFeePaid,
})
return _assertCompleted(tx, tx.id)
}
// ─────────────────────────────────────────────────────────────────────────────
// cancel() — PREPARED|PREPARED_OFFLINE → REVERTED
//
// Marks an unfinalized receive as abandoned. The token isn't swapped at the
// mint, so nothing to undo on the mint side — just transitions the local tx.
// Useful for offline-prepared receives the user decides not to redeem.
// ─────────────────────────────────────────────────────────────────────────────
async function cancel(transactionId: number): Promise<RevertedTransaction> {
const tx = transactionsStore.findById(transactionId)
if (!tx) {
throw new ValidationError('Transaction not found', {transactionId})
}
if (!isPrepared(tx)) {
throw new ValidationError(
`Cannot cancel receive in state ${tx.status}. Expected PREPARED or PREPARED_OFFLINE.`,
{transactionId, status: tx.status},
)
}
const txData = _parseData(tx)
txData.push({
status: TransactionStatus.REVERTED,
cancelledBy: 'user',
createdAt: new Date(),
})
tx.update({status: TransactionStatus.REVERTED, data: JSON.stringify(txData)})
log.info('[ReceiveOperationApi.cancel]', 'Cancelled', {transactionId})
return _assertReverted(tx, transactionId)
}
// ─────────────────────────────────────────────────────────────────────────────
// reclaim() — not applicable to receive
//
// Once a receive completes, the ecash is in the wallet (UNSPENT). There's no
// "send it back" — that would be a SEND operation. Before completion, cancel()
// handles abandonment. Kept on the API surface for symmetry.
// ─────────────────────────────────────────────────────────────────────────────
async function reclaim(_transactionId: number): Promise<never> {
throw new ValidationError(
'Receive operations cannot be reclaimed. Use cancel() to abandon a PREPARED receive.',
)
}
// ─────────────────────────────────────────────────────────────────────────────
// finalize() — alias for execute
//
// Receive doesn't have a PENDING state (no async wait); finalize and execute
// are the same thing. Provided for API symmetry with other operations.
// ─────────────────────────────────────────────────────────────────────────────
async function finalize(transactionId: number): Promise<CompletedTransaction> {
const prepared = _reloadPrepared(transactionId)
return execute(prepared)
}
// ─────────────────────────────────────────────────────────────────────────────
// refresh() — no-op for receive (no async state to refresh against the mint)
// ─────────────────────────────────────────────────────────────────────────────
async function refresh(transactionId: number): Promise<Transaction> {
const tx = transactionsStore.findById(transactionId)
if (!tx) {
throw new ValidationError('Transaction not found', {transactionId})
}
return tx
}
// ─────────────────────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────────────────────
/**
* Wraps `walletStore.receive` with the same outputs-error healing retry the
* legacy `receiveSync` had. Returned proofs are plain cashu-ts proofs.
*/
async function _receiveWithHealing(
mintUrl: string,
unit: MintUnit,
token: Token,
transactionId: number,
): Promise<{proofs: CashuProof[]; swapFeePaid: number}> {
try {
return (await walletStore.receive(
mintUrl,
unit,
token,
transactionId,
)) as unknown as {proofs: CashuProof[]; swapFeePaid: number}
} catch (e: any) {
if (WalletUtils.shouldHealOutputsError(e)) {
log.error(
'[ReceiveOperationApi] Increasing proofsCounter outdated values and repeating receive.',
)
return (await walletStore.receive(
mintUrl,
unit,
token,
transactionId,
{increaseCounterBy: 10},
)) as unknown as {proofs: CashuProof[]; swapFeePaid: number}
}
throw e
}
}
/**
* Build a `PreparedReceiveData` from a persisted PREPARED|PREPARED_OFFLINE
* transaction (e.g. after app restart for an offline-prepared receive).
*
* Caller is responsible for ensuring the tx is in a prepared state — this
* helper just stitches together what execute() needs to proceed.
*/
function _reloadPrepared(transactionId: number): PreparedReceiveData {
const tx = transactionsStore.findById(transactionId)
if (!tx) {
throw new ValidationError('Transaction not found', {transactionId})
}
if (!tx.inputToken) {
throw new ValidationError('Transaction is missing input token', {transactionId})
}
// By identity: tx.mint is where the receive was prepared and is frozen, so it
// stops finding the mint once it moves.
const mintInstance = mintsStore.findByTransaction(tx)
if (!mintInstance) {
throw new ValidationError('Transaction mint is no longer in this wallet', {
transactionId,
mintId: tx.mintId,
preparedAtUrl: tx.mint,
})
}
const token = getDecodedToken(tx.inputToken, mintInstance.keysetIds ?? [])
const isOffline = tx.status === TransactionStatus.PREPARED_OFFLINE
return {
transactionId,
tx,
// The mint's url NOW — this is dialled to complete the receive.
mintUrl: mintInstance.mintUrl,
unit: tx.unit,
amountToReceive: tx.amount,
memo: tx.memo ?? '',
blocked: false,
isOffline,
method: {
method: 'cashu-token',
options: {
token,
encodedToken: tx.inputToken,
offline: isOffline,
},
},
}
}
function _parseData(tx: Transaction): TransactionData[] {
try {
return JSON.parse(tx.data)
} catch {
return []
}
}
function _assertReverted(tx: Transaction, transactionId: number): RevertedTransaction {
const refreshed = transactionsStore.findById(transactionId) ?? tx
if (!isReverted(refreshed)) {
throw new WalletError('Transaction did not transition to REVERTED', {
transactionId,
status: refreshed.status,
})
}
return refreshed
}
function _assertCompleted(tx: Transaction, transactionId: number): CompletedTransaction {
const refreshed = transactionsStore.findById(transactionId) ?? tx
if (!isCompleted(refreshed)) {
throw new WalletError('Transaction did not transition to COMPLETED', {
transactionId,
status: refreshed.status,
})
}
return refreshed
}
// ─────────────────────────────────────────────────────────────────────────────
// Public helpers
// ─────────────────────────────────────────────────────────────────────────────
/** Format the standard "you received X" message — exposed for the wrapper. */
export function receiveSuccessMessage(
receivedAmount: number,
unit: MintUnit,
swapFeePaid: number,
): string {
const code = getCurrency(unit).code
const feePart =
swapFeePaid > 0
? ` Swap fee paid was ${formatCurrency(swapFeePaid, code)} ${code}.`
: ''
return `You've received ${formatCurrency(receivedAmount, code)} ${code} to your Minibits wallet.${feePart}`
}
/** Loader for the offline-complete-from-id wrapper path. */
export function loadPreparedForOfflineComplete(
transactionId: number,
): PreparedReceiveData {
return _reloadPrepared(transactionId)
}
// ─────────────────────────────────────────────────────────────────────────────
// Public API export
// ─────────────────────────────────────────────────────────────────────────────
export const ReceiveOperationApi = {
prepare,
execute,
cancel,
reclaim,
finalize,
refresh,
}