mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
prepfer-async mode for melting
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "minibits_wallet",
|
"name": "minibits_wallet",
|
||||||
"version": "0.4.2-beta.3",
|
"version": "0.4.2-beta.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android:clean": "cd android && ./gradlew clean",
|
"android:clean": "cd android && ./gradlew clean",
|
||||||
|
|||||||
@@ -293,6 +293,7 @@
|
|||||||
"payCommon_failed": "Payment failed",
|
"payCommon_failed": "Payment failed",
|
||||||
"payCommon_insufficientFunds": "There are not enough funds to send this amount.",
|
"payCommon_insufficientFunds": "There are not enough funds to send this amount.",
|
||||||
"payCommon_isPending": "Payment is pending",
|
"payCommon_isPending": "Payment is pending",
|
||||||
|
"payCommon_isInProgress": "Payment in progress",
|
||||||
"payCommon_maximumPay": "Maximum amount to pay is %{amount} %{currency}.",
|
"payCommon_maximumPay": "Maximum amount to pay is %{amount} %{currency}.",
|
||||||
"payCommon_minimumPay": "Minimal payment amount is %{amount} %{currency}.",
|
"payCommon_minimumPay": "Minimal payment amount is %{amount} %{currency}.",
|
||||||
"payCommon_minimumWithdraw": "Minimal withdraw amount is %{amount} %{currency}.",
|
"payCommon_minimumWithdraw": "Minimal withdraw amount is %{amount} %{currency}.",
|
||||||
|
|||||||
@@ -292,6 +292,7 @@
|
|||||||
"payCommon_failed": "Pago fallido",
|
"payCommon_failed": "Pago fallido",
|
||||||
"payCommon_insufficientFunds": "No hay fondos suficientes para enviar esta cantidad.",
|
"payCommon_insufficientFunds": "No hay fondos suficientes para enviar esta cantidad.",
|
||||||
"payCommon_isPending": "El pago está pendiente",
|
"payCommon_isPending": "El pago está pendiente",
|
||||||
|
"payCommon_isInProgress": "Pago en curso",
|
||||||
"payCommon_maximumPay": "El monto máximo a pagar es %{amount} %{currency} .",
|
"payCommon_maximumPay": "El monto máximo a pagar es %{amount} %{currency} .",
|
||||||
"payCommon_minimumPay": "El monto mínimo de pago 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} .",
|
"payCommon_minimumWithdraw": "El monto mínimo de retiro es %{amount} %{currency} .",
|
||||||
|
|||||||
@@ -293,6 +293,7 @@
|
|||||||
"payCommon_failed": "Pagamento falhou",
|
"payCommon_failed": "Pagamento falhou",
|
||||||
"payCommon_insufficientFunds": "Fundos insuficientes para enviar este valor.",
|
"payCommon_insufficientFunds": "Fundos insuficientes para enviar este valor.",
|
||||||
"payCommon_isPending": "Pagamento pendente",
|
"payCommon_isPending": "Pagamento pendente",
|
||||||
|
"payCommon_isInProgress": "Pagamento em andamento",
|
||||||
"payCommon_maximumPay": "Valor máximo a pagar é %{amount} %{currency}.",
|
"payCommon_maximumPay": "Valor máximo a pagar é %{amount} %{currency}.",
|
||||||
"payCommon_minimumPay": "Valor mínimo de pagamento é %{amount} %{currency}.",
|
"payCommon_minimumPay": "Valor mínimo de pagamento é %{amount} %{currency}.",
|
||||||
"payCommon_minimumWithdraw": "Valor mínimo de saque é %{amount} %{currency}.",
|
"payCommon_minimumWithdraw": "Valor mínimo de saque é %{amount} %{currency}.",
|
||||||
|
|||||||
@@ -293,6 +293,7 @@
|
|||||||
"payCommon_failed": "Platba zlyhala.",
|
"payCommon_failed": "Platba zlyhala.",
|
||||||
"payCommon_insufficientFunds": "Nemáš dostatočné prostriedky na poslanie tejto sumy.",
|
"payCommon_insufficientFunds": "Nemáš dostatočné prostriedky na poslanie tejto sumy.",
|
||||||
"payCommon_isPending": "Platba je bežiaca",
|
"payCommon_isPending": "Platba je bežiaca",
|
||||||
|
"payCommon_isInProgress": "Platba prebieha",
|
||||||
"payCommon_maximumPay": "Najvyššia suma na zaplatenie je %{amount}.",
|
"payCommon_maximumPay": "Najvyššia suma na zaplatenie je %{amount}.",
|
||||||
"payCommon_minimumPay": "Minimálna suma platby je %{amount} %{currency} .",
|
"payCommon_minimumPay": "Minimálna suma platby je %{amount} %{currency} .",
|
||||||
"payCommon_minimumWithdraw": "Najmenšia suma na zaplatenie je %{amount} %{currnecy}",
|
"payCommon_minimumWithdraw": "Najmenšia suma na zaplatenie je %{amount} %{currnecy}",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { InFlightRequest, Mint } from './Mint'
|
|||||||
import { getRootStore } from './helpers/getRootStore'
|
import { getRootStore } from './helpers/getRootStore'
|
||||||
import { Transaction } from './Transaction'
|
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.
|
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,
|
transactionId: number,
|
||||||
options?: {
|
options?: {
|
||||||
increaseCounterBy?: number,
|
increaseCounterBy?: number,
|
||||||
inFlightRequest?: InFlightRequest<MeltParams>
|
inFlightRequest?: InFlightRequest<MeltParams>,
|
||||||
|
preferAsync?: boolean,
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const mintInstance = self.getMintModelInstance(mintUrl)
|
const mintInstance = self.getMintModelInstance(mintUrl)
|
||||||
@@ -971,7 +972,7 @@ export const WalletStoreModel = types
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 2: Complete the melt (sends to mint and constructs change proofs)
|
// 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
|
// Remove the stored preview on success
|
||||||
currentCounter.removeMeltCounterValue(transactionId)
|
currentCounter.removeMeltCounterValue(transactionId)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { LnurlClient, LNURLPayParams } from '../services/lnurlService'
|
|||||||
import { CurrencyCode, MintUnit, convertToFromSats, getCurrency } from "../services/wallet/currency"
|
import { CurrencyCode, MintUnit, convertToFromSats, getCurrency } from "../services/wallet/currency"
|
||||||
import { FeeBadge } from './Wallet/FeeBadge'
|
import { FeeBadge } from './Wallet/FeeBadge'
|
||||||
import { MeltQuoteBolt11Response } from '@cashu/cashu-ts'
|
import { MeltQuoteBolt11Response } from '@cashu/cashu-ts'
|
||||||
|
import EventEmitter from '../utils/eventEmitter'
|
||||||
import { MintHeader } from './Mints/MintHeader'
|
import { MintHeader } from './Mints/MintHeader'
|
||||||
import { MintBalanceSelector } from './Mints/MintBalanceSelector'
|
import { MintBalanceSelector } from './Mints/MintBalanceSelector'
|
||||||
import numbro from 'numbro'
|
import numbro from 'numbro'
|
||||||
@@ -103,6 +104,7 @@ type TransferAction =
|
|||||||
| { type: 'QUOTE_FINISHED' }
|
| { type: 'QUOTE_FINISHED' }
|
||||||
| { type: 'TRANSFER_START' }
|
| { type: 'TRANSFER_START' }
|
||||||
| { type: 'TRANSFER_COMPLETE'; transaction?: Transaction; transactionStatus: TransactionStatus; finalFee: number; resultModalInfo: { status: TransactionStatus; title?: string; message: string } }
|
| { 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: 'TOGGLE_RESULT_MODAL' }
|
||||||
| { type: 'SET_INFO'; message: string }
|
| { type: 'SET_INFO'; message: string }
|
||||||
| { type: 'CLEAR_INFO' }
|
| { type: 'CLEAR_INFO' }
|
||||||
@@ -196,6 +198,13 @@ function transferReducer(state: TransferState, action: TransferAction): Transfer
|
|||||||
isResultModalVisible: true,
|
isResultModalVisible: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'UPDATE_RESULT_MODAL':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
transactionStatus: action.transactionStatus,
|
||||||
|
resultModalInfo: action.resultModalInfo,
|
||||||
|
}
|
||||||
|
|
||||||
case 'TOGGLE_RESULT_MODAL':
|
case 'TOGGLE_RESULT_MODAL':
|
||||||
return { ...state, isResultModalVisible: !state.isResultModalVisible }
|
return { ...state, isResultModalVisible: !state.isResultModalVisible }
|
||||||
|
|
||||||
@@ -434,6 +443,23 @@ export const TransferScreen = observer(function TransferScreen({ route }: Props)
|
|||||||
}, [route.params?.paymentOption]),
|
}, [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 {
|
const handleError = function(e: AppError): void {
|
||||||
dispatch({ type: 'SET_ERROR', error: e })
|
dispatch({ type: 'SET_ERROR', error: e })
|
||||||
}
|
}
|
||||||
@@ -750,6 +776,7 @@ export const TransferScreen = observer(function TransferScreen({ route }: Props)
|
|||||||
draftTransactionIdRef.current || undefined
|
draftTransactionIdRef.current || undefined
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TODO: handle async melt case - show in progress (not pending) state in modal and update when completed
|
||||||
await handleTransferTaskResult(result)
|
await handleTransferTaskResult(result)
|
||||||
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -805,6 +832,7 @@ export const TransferScreen = observer(function TransferScreen({ route }: Props)
|
|||||||
let resultModalInfo: { status: TransactionStatus; title?: string; message: string }
|
let resultModalInfo: { status: TransactionStatus; title?: string; message: string }
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
// mint returned error with proofs pending - show pending message in modal instead of error
|
||||||
if (status === TransactionStatus.PENDING) {
|
if (status === TransactionStatus.PENDING) {
|
||||||
resultModalInfo = { status, message }
|
resultModalInfo = { status, message }
|
||||||
} else {
|
} else {
|
||||||
@@ -815,6 +843,11 @@ export const TransferScreen = observer(function TransferScreen({ route }: Props)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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
|
// Success path
|
||||||
if (isInvoiceDonation && donationForName &&
|
if (isInvoiceDonation && donationForName &&
|
||||||
(status === TransactionStatus.COMPLETED || status === TransactionStatus.RECOVERED)) {
|
(status === TransactionStatus.COMPLETED || status === TransactionStatus.RECOVERED)) {
|
||||||
@@ -1129,7 +1162,8 @@ export const TransferScreen = observer(function TransferScreen({ route }: Props)
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{resultModalInfo &&
|
{resultModalInfo &&
|
||||||
transactionStatus === TransactionStatus.PENDING && (
|
transactionStatus === TransactionStatus.PENDING &&
|
||||||
|
!pendingAsyncMeltId && (
|
||||||
<>
|
<>
|
||||||
<ResultModalInfo
|
<ResultModalInfo
|
||||||
icon="faTriangleExclamation"
|
icon="faTriangleExclamation"
|
||||||
@@ -1146,6 +1180,25 @@ export const TransferScreen = observer(function TransferScreen({ route }: Props)
|
|||||||
</View>
|
</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}
|
onBackButtonPress={toggleResultModal}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ const getRandomPictures = async function () {
|
|||||||
return avatars
|
return avatars
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO remove pubkey params
|
|
||||||
const createWalletProfile = async function (walletId: string, seedHash: string) {
|
const createWalletProfile = async function (walletId: string, seedHash: string) {
|
||||||
const url = MINIBITS_SERVER_API_HOST + '/profile'
|
const url = MINIBITS_SERVER_API_HOST + '/profile'
|
||||||
const method = 'POST'
|
const method = 'POST'
|
||||||
@@ -126,7 +126,6 @@ const createWalletProfile = async function (walletId: string, seedHash: string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// what is passed in {update} gets updated
|
// 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 updateWalletProfile = async function (update: {name?: string, lud16?: string, avatar?: string}) {
|
||||||
const url = MINIBITS_SERVER_API_HOST + '/profile'
|
const url = MINIBITS_SERVER_API_HOST + '/profile'
|
||||||
const method = 'PUT'
|
const method = 'PUT'
|
||||||
@@ -149,7 +148,7 @@ const updateWalletProfile = async function (update: {name?: string, lud16?: stri
|
|||||||
return walletProfile
|
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 updateWalletProfileNip05 = async function (update: {newPubkey: string, name: string, nip05: string, lud16: string, avatar: string}) {
|
||||||
const url = MINIBITS_SERVER_API_HOST + '/profile'
|
const url = MINIBITS_SERVER_API_HOST + '/profile'
|
||||||
const method = 'PUT'
|
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 updateDeviceToken = async function (deviceToken: string) {
|
||||||
const url = MINIBITS_SERVER_API_HOST + '/profile'
|
const url = MINIBITS_SERVER_API_HOST + '/profile'
|
||||||
const method = 'PUT'
|
const method = 'PUT'
|
||||||
|
|||||||
@@ -32,6 +32,45 @@ const {
|
|||||||
|
|
||||||
export const SEND_TASK = 'sendTask'
|
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 (
|
export const sendTask = async function (
|
||||||
mintBalanceToSendFrom: MintBalance,
|
mintBalanceToSendFrom: MintBalance,
|
||||||
amountToSend: number,
|
amountToSend: number,
|
||||||
@@ -135,48 +174,9 @@ export const sendTask = async function (
|
|||||||
|
|
||||||
log.trace('[send] totalBalance after', balanceAfter)
|
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) {
|
if(selectedProofs.length === 0) {
|
||||||
|
_monitorSentProofs({mintUrl, proofsToSend})
|
||||||
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}))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {CashuUtils} from '../cashu/cashuUtils'
|
import {CashuUtils} from '../cashu/cashuUtils'
|
||||||
import AppError, {Err} from '../../utils/AppError'
|
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 {rootStoreInstance} from '../../models'
|
||||||
import { TransactionTaskResult, WalletTask } from '../walletService'
|
import { TransactionTaskResult, WalletTask } from '../walletService'
|
||||||
import { MintBalance } from '../../models/Mint'
|
import { MintBalance } from '../../models/Mint'
|
||||||
@@ -8,6 +8,7 @@ import { Proof } from '../../models/Proof'
|
|||||||
import { Transaction, TransactionData, TransactionStatus, TransactionType } from '../../models/Transaction'
|
import { Transaction, TransactionData, TransactionStatus, TransactionType } from '../../models/Transaction'
|
||||||
import { log } from '../logService'
|
import { log } from '../logService'
|
||||||
import { WalletUtils } from './utils'
|
import { WalletUtils } from './utils'
|
||||||
|
import { poller } from '../../utils/poller'
|
||||||
import {isBefore} from 'date-fns'
|
import {isBefore} from 'date-fns'
|
||||||
import { MintUnit, formatCurrency, getCurrency } from './currency'
|
import { MintUnit, formatCurrency, getCurrency } from './currency'
|
||||||
import { NostrEvent } from '../nostrService'
|
import { NostrEvent } from '../nostrService'
|
||||||
@@ -24,6 +25,49 @@ const {
|
|||||||
|
|
||||||
export const TRANSFER_TASK = 'transferTask'
|
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 (
|
export const transferTask = async function (
|
||||||
mintBalanceToTransferFrom: MintBalance,
|
mintBalanceToTransferFrom: MintBalance,
|
||||||
amountToTransfer: number,
|
amountToTransfer: number,
|
||||||
@@ -149,6 +193,9 @@ export const transferTask = async function (
|
|||||||
proofsToMeltFromAmount = CashuUtils.getProofsAmount(proofsToMeltFrom)
|
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
|
// move proofs to PENDING state and link them to current tx
|
||||||
proofsStore.addOrUpdate(proofsToMeltFrom,
|
proofsStore.addOrUpdate(proofsToMeltFrom,
|
||||||
{
|
{
|
||||||
@@ -194,10 +241,11 @@ export const transferTask = async function (
|
|||||||
unit,
|
unit,
|
||||||
meltQuote,
|
meltQuote,
|
||||||
proofsToMeltFrom,
|
proofsToMeltFrom,
|
||||||
transactionId,
|
transactionId,
|
||||||
|
{ preferAsync: true },
|
||||||
)
|
)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (WalletUtils.shouldHealOutputsError(e)) {
|
if (WalletUtils.shouldHealOutputsError(e)) {
|
||||||
log.error('[transferTask] Increasing proofsCounter outdated values and repeating payLightningMelt.')
|
log.error('[transferTask] Increasing proofsCounter outdated values and repeating payLightningMelt.')
|
||||||
meltResponse = await walletStore.payLightningMelt(
|
meltResponse = await walletStore.payLightningMelt(
|
||||||
mintUrl,
|
mintUrl,
|
||||||
@@ -205,7 +253,7 @@ export const transferTask = async function (
|
|||||||
meltQuote,
|
meltQuote,
|
||||||
proofsToMeltFrom,
|
proofsToMeltFrom,
|
||||||
transactionId,
|
transactionId,
|
||||||
{increaseCounterBy: 10}
|
{ increaseCounterBy: 10, preferAsync: true },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
throw e
|
throw e
|
||||||
@@ -301,20 +349,43 @@ export const transferTask = async function (
|
|||||||
nwcEvent
|
nwcEvent
|
||||||
} as TransactionTaskResult
|
} as TransactionTaskResult
|
||||||
|
|
||||||
} else if(meltResponse.quote.state === MeltQuoteState.PENDING) {
|
} else if(meltResponse.quote.state === MeltQuoteState.PENDING) {
|
||||||
|
|
||||||
log.debug('[transfer] Invoice PENDING', {
|
log.debug('[transfer] Invoice PENDING, async melt in progress', {
|
||||||
metResponseQuote: meltResponse.quote,
|
quoteId: meltResponse.quote.quote,
|
||||||
transactionId
|
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 {
|
return {
|
||||||
taskFunction: TRANSFER_TASK,
|
taskFunction: TRANSFER_TASK,
|
||||||
mintUrl,
|
mintUrl,
|
||||||
transaction,
|
transaction,
|
||||||
message: `Lightning payment did not complete in time. Your ecash will remain pending until the payment completes or fails.`,
|
message: 'Lightning payment is in progress. Check your transaction history for the result.',
|
||||||
meltQuote,
|
meltQuote: meltResponse.quote,
|
||||||
nwcEvent
|
nwcEvent,
|
||||||
} as TransactionTaskResult
|
} as TransactionTaskResult
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -148,9 +148,19 @@ type WalletTaskService = {
|
|||||||
mintQuote: string
|
mintQuote: string
|
||||||
}) => Promise<{recoveredAmount: number}>
|
}) => Promise<{recoveredAmount: number}>
|
||||||
recoverMeltQuoteChange: (params: {
|
recoverMeltQuoteChange: (params: {
|
||||||
mintUrl: string,
|
mintUrl: string,
|
||||||
meltQuote: string | MeltQuoteResponse
|
meltQuote: string | MeltQuoteResponse
|
||||||
}) => Promise<{recoveredAmount: number}>
|
}) => 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 {
|
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> {
|
const handleClaimQueue = async function (): Promise<void> {
|
||||||
|
|
||||||
log.info('[handleClaimQueue] start')
|
log.info('[handleClaimQueue] start')
|
||||||
@@ -3145,4 +3262,5 @@ export const WalletTask: WalletTaskService = {
|
|||||||
testQueue,
|
testQueue,
|
||||||
recoverMintQuote,
|
recoverMintQuote,
|
||||||
recoverMeltQuoteChange,
|
recoverMeltQuoteChange,
|
||||||
|
handlePendingMeltTask,
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user