prepfer-async mode for melting

This commit is contained in:
minibits-cash
2026-05-09 23:40:27 +02:00
parent aa682ae3ad
commit 27734a1032
11 changed files with 307 additions and 62 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "minibits_wallet",
"version": "0.4.2-beta.3",
"version": "0.4.2-beta.4",
"private": true,
"scripts": {
"android:clean": "cd android && ./gradlew clean",
+1
View File
@@ -293,6 +293,7 @@
"payCommon_failed": "Payment failed",
"payCommon_insufficientFunds": "There are not enough funds to send this amount.",
"payCommon_isPending": "Payment is pending",
"payCommon_isInProgress": "Payment in progress",
"payCommon_maximumPay": "Maximum amount to pay is %{amount} %{currency}.",
"payCommon_minimumPay": "Minimal payment amount is %{amount} %{currency}.",
"payCommon_minimumWithdraw": "Minimal withdraw amount is %{amount} %{currency}.",
+1
View File
@@ -292,6 +292,7 @@
"payCommon_failed": "Pago fallido",
"payCommon_insufficientFunds": "No hay fondos suficientes para enviar esta cantidad.",
"payCommon_isPending": "El pago está pendiente",
"payCommon_isInProgress": "Pago en curso",
"payCommon_maximumPay": "El monto máximo a pagar es %{amount} %{currency} .",
"payCommon_minimumPay": "El monto mínimo de pago es %{amount} %{currency} .",
"payCommon_minimumWithdraw": "El monto mínimo de retiro es %{amount} %{currency} .",
+1
View File
@@ -293,6 +293,7 @@
"payCommon_failed": "Pagamento falhou",
"payCommon_insufficientFunds": "Fundos insuficientes para enviar este valor.",
"payCommon_isPending": "Pagamento pendente",
"payCommon_isInProgress": "Pagamento em andamento",
"payCommon_maximumPay": "Valor máximo a pagar é %{amount} %{currency}.",
"payCommon_minimumPay": "Valor mínimo de pagamento é %{amount} %{currency}.",
"payCommon_minimumWithdraw": "Valor mínimo de saque é %{amount} %{currency}.",
+1
View File
@@ -293,6 +293,7 @@
"payCommon_failed": "Platba zlyhala.",
"payCommon_insufficientFunds": "Nemáš dostatočné prostriedky na poslanie tejto sumy.",
"payCommon_isPending": "Platba je bežiaca",
"payCommon_isInProgress": "Platba prebieha",
"payCommon_maximumPay": "Najvyššia suma na zaplatenie je %{amount}.",
"payCommon_minimumPay": "Minimálna suma platby je %{amount} %{currency} .",
"payCommon_minimumWithdraw": "Najmenšia suma na zaplatenie je %{amount} %{currnecy}",
+4 -3
View File
@@ -29,7 +29,7 @@ import { InFlightRequest, Mint } from './Mint'
import { getRootStore } from './helpers/getRootStore'
import { Transaction } from './Transaction'
//
// r
/*
Not persisted, in-memory only model of the cashu-ts wallet instances and wallet keys persisted in the device secure store.
@@ -907,7 +907,8 @@ export const WalletStoreModel = types
transactionId: number,
options?: {
increaseCounterBy?: number,
inFlightRequest?: InFlightRequest<MeltParams>
inFlightRequest?: InFlightRequest<MeltParams>,
preferAsync?: boolean,
}
) {
const mintInstance = self.getMintModelInstance(mintUrl)
@@ -971,7 +972,7 @@ export const WalletStoreModel = types
try {
// Step 2: Complete the melt (sends to mint and constructs change proofs)
const meltResponse: MeltProofsResponse = yield cashuWallet.completeMelt(meltPreview)
const meltResponse: MeltProofsResponse = yield cashuWallet.completeMelt(meltPreview, undefined, options?.preferAsync)
// Remove the stored preview on success
currentCounter.removeMeltCounterValue(transactionId)
+54 -1
View File
@@ -39,6 +39,7 @@ import { LnurlClient, LNURLPayParams } from '../services/lnurlService'
import { CurrencyCode, MintUnit, convertToFromSats, getCurrency } from "../services/wallet/currency"
import { FeeBadge } from './Wallet/FeeBadge'
import { MeltQuoteBolt11Response } from '@cashu/cashu-ts'
import EventEmitter from '../utils/eventEmitter'
import { MintHeader } from './Mints/MintHeader'
import { MintBalanceSelector } from './Mints/MintBalanceSelector'
import numbro from 'numbro'
@@ -103,6 +104,7 @@ type TransferAction =
| { type: 'QUOTE_FINISHED' }
| { type: 'TRANSFER_START' }
| { type: 'TRANSFER_COMPLETE'; transaction?: Transaction; transactionStatus: TransactionStatus; finalFee: number; resultModalInfo: { status: TransactionStatus; title?: string; message: string } }
| { type: 'UPDATE_RESULT_MODAL'; transactionStatus: TransactionStatus; resultModalInfo: { status: TransactionStatus; message: string } }
| { type: 'TOGGLE_RESULT_MODAL' }
| { type: 'SET_INFO'; message: string }
| { type: 'CLEAR_INFO' }
@@ -196,6 +198,13 @@ function transferReducer(state: TransferState, action: TransferAction): Transfer
isResultModalVisible: true,
}
case 'UPDATE_RESULT_MODAL':
return {
...state,
transactionStatus: action.transactionStatus,
resultModalInfo: action.resultModalInfo,
}
case 'TOGGLE_RESULT_MODAL':
return { ...state, isResultModalVisible: !state.isResultModalVisible }
@@ -434,6 +443,23 @@ export const TransferScreen = observer(function TransferScreen({ route }: Props)
}, [route.params?.paymentOption]),
)
const [pendingAsyncMeltId, setPendingAsyncMeltId] = useState<number | undefined>(undefined)
useEffect(() => {
if (!pendingAsyncMeltId) return
const handler = (result: { transactionId: number; status: TransactionStatus; message: string }) => {
if (result.transactionId !== pendingAsyncMeltId) return
setPendingAsyncMeltId(undefined)
dispatch({
type: 'UPDATE_RESULT_MODAL',
transactionStatus: result.status,
resultModalInfo: { status: result.status, message: result.message },
})
}
EventEmitter.on('ev_asyncMeltResult', handler)
return () => EventEmitter.off('ev_asyncMeltResult', handler)
}, [pendingAsyncMeltId])
const handleError = function(e: AppError): void {
dispatch({ type: 'SET_ERROR', error: e })
}
@@ -750,6 +776,7 @@ export const TransferScreen = observer(function TransferScreen({ route }: Props)
draftTransactionIdRef.current || undefined
)
// TODO: handle async melt case - show in progress (not pending) state in modal and update when completed
await handleTransferTaskResult(result)
} catch (e: any) {
@@ -805,6 +832,7 @@ export const TransferScreen = observer(function TransferScreen({ route }: Props)
let resultModalInfo: { status: TransactionStatus; title?: string; message: string }
if (error) {
// mint returned error with proofs pending - show pending message in modal instead of error
if (status === TransactionStatus.PENDING) {
resultModalInfo = { status, message }
} else {
@@ -815,6 +843,11 @@ export const TransferScreen = observer(function TransferScreen({ route }: Props)
}
}
} else {
if (status === TransactionStatus.PENDING) {
// Async melt: monitor is running in background and will emit ev_asyncMeltResult
// when the quote resolves. The listener above will update the modal at that point.
setPendingAsyncMeltId(transaction.id)
}
// Success path
if (isInvoiceDonation && donationForName &&
(status === TransactionStatus.COMPLETED || status === TransactionStatus.RECOVERED)) {
@@ -1129,7 +1162,8 @@ export const TransferScreen = observer(function TransferScreen({ route }: Props)
</>
)}
{resultModalInfo &&
transactionStatus === TransactionStatus.PENDING && (
transactionStatus === TransactionStatus.PENDING &&
!pendingAsyncMeltId && (
<>
<ResultModalInfo
icon="faTriangleExclamation"
@@ -1146,6 +1180,25 @@ export const TransferScreen = observer(function TransferScreen({ route }: Props)
</View>
</>
)}
{resultModalInfo &&
transactionStatus === TransactionStatus.PENDING &&
!!pendingAsyncMeltId && (
<>
<ResultModalInfo
icon="faClock"
iconColor={colors.palette.iconBlue200}
title={translate('payCommon_isInProgress')}
message={resultModalInfo?.message}
/>
<View style={$buttonContainer}>
<Button
preset="secondary"
tx={'commonClose'}
onPress={gotoWallet}
/>
</View>
</>
)}
</>
}
onBackButtonPress={toggleResultModal}
+2 -4
View File
@@ -102,7 +102,7 @@ const getRandomPictures = async function () {
return avatars
}
// TODO remove pubkey params
const createWalletProfile = async function (walletId: string, seedHash: string) {
const url = MINIBITS_SERVER_API_HOST + '/profile'
const method = 'POST'
@@ -126,7 +126,6 @@ const createWalletProfile = async function (walletId: string, seedHash: string)
}
// what is passed in {update} gets updated
// TODO remove pubkey from url and params
const updateWalletProfile = async function (update: {name?: string, lud16?: string, avatar?: string}) {
const url = MINIBITS_SERVER_API_HOST + '/profile'
const method = 'PUT'
@@ -149,7 +148,7 @@ const updateWalletProfile = async function (update: {name?: string, lud16?: stri
return walletProfile
}
// TODO remove pubkey from url and params
const updateWalletProfileNip05 = async function (update: {newPubkey: string, name: string, nip05: string, lud16: string, avatar: string}) {
const url = MINIBITS_SERVER_API_HOST + '/profile'
const method = 'PUT'
@@ -175,7 +174,6 @@ const updateWalletProfileNip05 = async function (update: {newPubkey: string, nam
}
// TODO remove pubkey from url and params
const updateDeviceToken = async function (deviceToken: string) {
const url = MINIBITS_SERVER_API_HOST + '/profile'
const method = 'PUT'
+41 -41
View File
@@ -32,6 +32,45 @@ const {
export const SEND_TASK = 'sendTask'
const _monitorSentProofs = async (params: {
mintUrl: string
proofsToSend: CashuProof[]
}) => {
const { mintUrl, proofsToSend } = params
const proofsToSync = proofsStore.getByMint(mintUrl, {isPending: true})
const wsMint = new CashuMint(mintUrl)
const wsWallet = new CashuWallet(wsMint)
try {
log.trace('[send] Subscribing to proofStateUpdates for sent proof', {secret: proofsToSend[0]})
const unsub = await wsWallet.on.proofStateUpdates(
[proofsToSend[0]],
async (proofState: ProofState) => {
log.trace(`Websocket: proof state updated: ${proofState.state} with secret: ${proofsToSend[0].secret}`)
if (proofState.state == CheckStateEnum.SPENT) {
WalletTask.syncStateWithMintQueueAwaitable({proofsToSync, mintUrl, isPending: true})
unsub()
}
},
async (error: any) => {
throw error
},
)
} catch (error: any) {
log.error(Err.NETWORK_ERROR,
"Error in websocket subscription. Starting poller.",
error.message,
)
poller(
`syncStateWithMintPoller-${mintUrl}`,
WalletTask.syncStateWithMintQueueAwaitable,
{interval: 10 * 1000, maxPolls: 3, maxErrors: 1},
{proofsToSend, mintUrl, isPending: true},
)
.then(() => log.trace('[syncStateWithMintPoller]', 'polling completed', {mintUrl}))
}
}
export const sendTask = async function (
mintBalanceToSendFrom: MintBalance,
amountToSend: number,
@@ -135,48 +174,9 @@ export const sendTask = async function (
log.trace('[send] totalBalance after', balanceAfter)
// Start polling for accepted payment it is not an offline send
// Start monitoring for accepted payment if it is not an offline send
if(selectedProofs.length === 0) {
const proofsToSync = proofsStore.getByMint(mintUrl, {isPending: true})
const wsMint = new CashuMint(mintUrl)
const wsWallet = new CashuWallet(wsMint)
try {
log.trace('[send] Subscribing to onProofStateUpdates for proof', {secret: proofsToSend[0]})
const unsub = await wsWallet.on.proofStateUpdates(
[proofsToSend[0]],
async (proofState: ProofState) => {
log.trace(`Websocket: proof state updated: ${proofState.state} with secret: ${proofsToSend[0].secret}`)
if (proofState.state == CheckStateEnum.SPENT) {
WalletTask.syncStateWithMintQueueAwaitable({proofsToSync, mintUrl, isPending: true})
unsub()
}
},
async (error: any) => {
throw error
}
)
} catch (error: any) {
log.error(Err.NETWORK_ERROR,
"Error in websocket subscription. Starting poller.",
error.message
)
poller(
`syncStateWithMintPoller-${mintUrl}`,
WalletTask.syncStateWithMintQueueAwaitable,
{
interval: 10 * 1000,
maxPolls: 3,
maxErrors: 1
},
{proofsToSend, mintUrl, isPending: true}
)
.then(() => log.trace('[syncStateWithMintPoller]', 'polling completed', {mintUrl}))
}
_monitorSentProofs({mintUrl, proofsToSend})
}
return {
+79 -8
View File
@@ -1,6 +1,6 @@
import {CashuUtils} from '../cashu/cashuUtils'
import AppError, {Err} from '../../utils/AppError'
import {MeltProofsResponse, MeltQuoteBolt11Response, MeltQuoteResponse, MeltQuoteState, getEncodedToken} from '@cashu/cashu-ts'
import {Mint as CashuMint, Wallet as CashuWallet, MeltProofsResponse, MeltQuoteBolt11Response, MeltQuoteResponse, MeltQuoteState, getEncodedToken} from '@cashu/cashu-ts'
import {rootStoreInstance} from '../../models'
import { TransactionTaskResult, WalletTask } from '../walletService'
import { MintBalance } from '../../models/Mint'
@@ -8,6 +8,7 @@ import { Proof } from '../../models/Proof'
import { Transaction, TransactionData, TransactionStatus, TransactionType } from '../../models/Transaction'
import { log } from '../logService'
import { WalletUtils } from './utils'
import { poller } from '../../utils/poller'
import {isBefore} from 'date-fns'
import { MintUnit, formatCurrency, getCurrency } from './currency'
import { NostrEvent } from '../nostrService'
@@ -24,6 +25,49 @@ const {
export const TRANSFER_TASK = 'transferTask'
const _monitorAsyncMeltQuote = async (params: {
mintUrl: string
unit: MintUnit
quoteId: string
proofsToMeltFrom: Proof[]
proofsToMeltFromAmount: number
amountToTransfer: number
meltFeeReserve: number
transactionId: number
}) => {
const { mintUrl, quoteId } = params
const wsMint = new CashuMint(mintUrl)
const wsWallet = new CashuWallet(wsMint)
try {
log.trace('[transfer] Subscribing to meltQuoteUpdates for async melt', { quoteId })
const unsub = await wsWallet.on.meltQuoteUpdates(
[quoteId],
async (updatedQuote: MeltQuoteBolt11Response) => {
if (updatedQuote.state === MeltQuoteState.PAID ||
updatedQuote.state === MeltQuoteState.UNPAID) {
WalletTask.handlePendingMeltTask(params)
unsub()
}
},
async (error: any) => {
throw error
},
)
} catch (error: any) {
log.error(Err.NETWORK_ERROR,
'[transfer] WebSocket error for async melt, starting poller.',
error.message,
)
poller(
`meltQuotePoller-${quoteId}`,
WalletTask.handlePendingMeltTask,
{ interval: 15 * 1000, maxPolls: 8, maxErrors: 2 },
params,
).then(() => log.trace('[meltQuotePoller] polling completed', { quoteId }))
}
}
export const transferTask = async function (
mintBalanceToTransferFrom: MintBalance,
amountToTransfer: number,
@@ -149,6 +193,9 @@ export const transferTask = async function (
proofsToMeltFromAmount = CashuUtils.getProofsAmount(proofsToMeltFrom)
}
// TODO: if proofsToMelFromAmount is significantly higher than amountWithFees,
// add swap with the mint to get denominations that fit the needed amount
// move proofs to PENDING state and link them to current tx
proofsStore.addOrUpdate(proofsToMeltFrom,
{
@@ -195,6 +242,7 @@ export const transferTask = async function (
meltQuote,
proofsToMeltFrom,
transactionId,
{ preferAsync: true },
)
} catch (e: any) {
if (WalletUtils.shouldHealOutputsError(e)) {
@@ -205,7 +253,7 @@ export const transferTask = async function (
meltQuote,
proofsToMeltFrom,
transactionId,
{increaseCounterBy: 10}
{ increaseCounterBy: 10, preferAsync: true },
)
} else {
throw e
@@ -303,18 +351,41 @@ export const transferTask = async function (
} else if(meltResponse.quote.state === MeltQuoteState.PENDING) {
log.debug('[transfer] Invoice PENDING', {
metResponseQuote: meltResponse.quote,
transactionId
log.debug('[transfer] Invoice PENDING, async melt in progress', {
quoteId: meltResponse.quote.quote,
transactionId,
})
transactionData.push({
status: TransactionStatus.PENDING,
createdAt: new Date(),
})
transaction.update({
status: TransactionStatus.PENDING,
data: JSON.stringify(transactionData),
})
// Fire-and-forget: monitors quote via websocket (poller fallback) and
// updates the transaction + emits ev_asyncMeltResult when resolved.
_monitorAsyncMeltQuote({
mintUrl,
unit,
quoteId: meltResponse.quote.quote,
proofsToMeltFrom,
proofsToMeltFromAmount,
amountToTransfer,
meltFeeReserve,
transactionId,
})
return {
taskFunction: TRANSFER_TASK,
mintUrl,
transaction,
message: `Lightning payment did not complete in time. Your ecash will remain pending until the payment completes or fails.`,
meltQuote,
nwcEvent
message: 'Lightning payment is in progress. Check your transaction history for the result.',
meltQuote: meltResponse.quote,
nwcEvent,
} as TransactionTaskResult
} else {
+118
View File
@@ -151,6 +151,16 @@ type WalletTaskService = {
mintUrl: string,
meltQuote: string | MeltQuoteResponse
}) => Promise<{recoveredAmount: number}>
handlePendingMeltTask: (params: {
mintUrl: string
unit: MintUnit
quoteId: string
proofsToMeltFrom: Proof[]
proofsToMeltFromAmount: number
amountToTransfer: number
meltFeeReserve: number
transactionId: number
}) => Promise<void>
}
export interface WalletTaskResult {
@@ -2321,6 +2331,113 @@ const recoverMintQuote = async (
}
const handlePendingMeltTask = async (params: {
mintUrl: string
unit: MintUnit
quoteId: string
proofsToMeltFrom: Proof[]
proofsToMeltFromAmount: number
amountToTransfer: number
meltFeeReserve: number
transactionId: number
}): Promise<void> => {
const { mintUrl, unit, quoteId, proofsToMeltFrom, proofsToMeltFromAmount,
amountToTransfer, meltFeeReserve, transactionId } = params
const transaction = transactionsStore.findById(transactionId)
if (!transaction || transaction.status !== TransactionStatus.PENDING) return
const quote = await walletStore.checkLightningMeltQuote(mintUrl, quoteId)
if (quote.state === MeltQuoteState.PAID) {
proofsStore.moveToSpent(proofsToMeltFrom)
let totalFeePaid = proofsToMeltFromAmount - amountToTransfer
let lightningFeePaid = totalFeePaid - meltFeeReserve
const meltFeePaid = meltFeeReserve
let returnedAmount = 0
let outputToken: string | undefined
// Call completeMelt with the stored MeltPreview to unbind the change blind signatures.
// This is a POST to the mint; different from the GET quote check above.
try {
const mintInstance = mintsStore.findByUrl(mintUrl)
if (mintInstance && transaction.keysetId) {
const currentCounter = mintInstance.getProofsCounterByKeysetId!(transaction.keysetId)
const meltCounterValue = currentCounter?.getMeltCounterValue(transactionId)
if (meltCounterValue?.meltPreview) {
const cashuWallet = await walletStore.getWallet(mintUrl, unit,
{ withSeed: true, keysetId: transaction.keysetId })
const { change } = await cashuWallet.completeMelt(meltCounterValue.meltPreview)
currentCounter.removeMeltCounterValue(transactionId)
if (change.length > 0) {
proofsStore.addOrUpdate(change, {
mintUrl, tId: transactionId, unit, isPending: false, isSpent: false,
})
returnedAmount = CashuUtils.getProofsAmount(change)
outputToken = getEncodedToken({ mint: mintUrl, proofs: change, unit })
totalFeePaid = totalFeePaid - returnedAmount
lightningFeePaid = totalFeePaid - meltFeeReserve
}
}
}
} catch (e: any) {
log.error('[handlePendingMelt] change recovery failed, completing without change', e.message)
}
const txData: TransactionData[] = transaction.data ? JSON.parse(transaction.data) : []
const balanceAfter = proofsStore.getUnitBalance(unit)?.unitBalance
txData.push({
status: TransactionStatus.COMPLETED,
lightningFeePaid,
meltFeePaid,
returnedAmount,
//@ts-ignore
preimage: quote.payment_preimage,
createdAt: new Date(),
})
const updatePayload: any = {
status: TransactionStatus.COMPLETED,
data: JSON.stringify(txData),
fee: totalFeePaid,
balanceAfter,
}
if (outputToken) updatePayload.outputToken = outputToken
//@ts-ignore
if (quote.payment_preimage) updatePayload.proof = quote.payment_preimage
transaction.update(updatePayload)
log.debug('[handlePendingMelt] Transaction completed', { transactionId, totalFeePaid })
EventEmitter.emit('ev_asyncMeltResult', {
transactionId,
status: TransactionStatus.COMPLETED,
message: `Lightning invoice paid. Fee: ${formatCurrency(transaction.fee, getCurrency(unit).code)} ${getCurrency(unit).code}.`,
})
} else if (quote.state === MeltQuoteState.UNPAID) {
proofsStore.revertToSpendable(proofsToMeltFrom)
const txData: TransactionData[] = transaction.data ? JSON.parse(transaction.data) : []
txData.push({
status: TransactionStatus.ERROR,
error: WalletUtils.formatError(new AppError(Err.MINT_ERROR, 'Async lightning payment failed.')),
createdAt: new Date(),
})
transaction.update({ status: TransactionStatus.ERROR, data: JSON.stringify(txData) })
log.debug('[handlePendingMelt] Transaction failed (UNPAID)', { transactionId })
EventEmitter.emit('ev_asyncMeltResult', {
transactionId,
status: TransactionStatus.ERROR,
message: 'Lightning payment failed. Ecash returned to spendable balance.',
})
}
// MeltQuoteState.PENDING: no-op, ws/poller will call again
}
const handleClaimQueue = async function (): Promise<void> {
log.info('[handleClaimQueue] start')
@@ -3145,4 +3262,5 @@ export const WalletTask: WalletTaskService = {
testQueue,
recoverMintQuote,
recoverMeltQuoteChange,
handlePendingMeltTask,
}