diff --git a/__tests__/nut20.test.ts b/__tests__/nut20.test.ts index 4c251cd5..4145f2b6 100644 --- a/__tests__/nut20.test.ts +++ b/__tests__/nut20.test.ts @@ -40,6 +40,7 @@ jest.mock('../src/services/db/walletCountersRepo', () => ({ import { allocateQuoteKeypair, deriveQuoteKeypair, + findQuoteKeyIndex, quoteKeyDerivationPath, } from '../src/services/cashu/nut20' @@ -111,6 +112,27 @@ describe('NUT-20 quote key derivation', () => { } }) + // Recovering a quote whose row is gone (made on another install of the same + // seed, or before a wipe) turns on finding its index again from the pubkey the + // mint returns. Without the index there is no private key, and no signature — + // the money would be unmintable. + it('finds the index that produced a pubkey', () => { + expect(findQuoteKeyIndex(SEED, VECTOR[1].pubkey, 50)).toBe(1) + }) + + it('finds an index far above the local counter', () => { + // The case that matters: a wiped wallet's counter is back at 0 while the + // mint still holds a quote locked to a much higher index. + const {pubkey} = deriveQuoteKeypair(SEED, 137) + expect(findQuoteKeyIndex(SEED, pubkey, 200)).toBe(137) + }) + + it('does not claim a key from another seed', () => { + const otherSeed = new Uint8Array(SEED).fill(7) + const foreign = deriveQuoteKeypair(otherSeed, 3).pubkey + expect(findQuoteKeyIndex(SEED, foreign, 200)).toBeUndefined() + }) + it('is deterministic for a given seed and index', () => { expect(deriveQuoteKeypair(SEED, 7)).toEqual(deriveQuoteKeypair(SEED, 7)) }) diff --git a/__tests__/onchainTopupOperation.test.ts b/__tests__/onchainTopupOperation.test.ts index 6300cc00..e2f3affa 100644 --- a/__tests__/onchainTopupOperation.test.ts +++ b/__tests__/onchainTopupOperation.test.ts @@ -124,4 +124,25 @@ describe('capMintAmount', () => { it('caps exactly at the boundary', () => { expect(capMintAmount(500000, 500000)).toBe(500000) }) + + // The over-limit recovery path. A deposit bigger than the mint will issue at + // once must not fail the mint request — it is taken in capped instalments, one + // per sweep or per "check for deposits" tap, until the quote is drained. Nothing + // is lost on the way, which is why an overpayment is recoverable rather than + // stuck. + it('drains an over-limit deposit across repeated tries', () => { + const paid = 1200000 + const max = 500000 + let issued = 0 + const takes: number[] = [] + + while (mintableAmount(paid, issued) > 0) { + const take = capMintAmount(mintableAmount(paid, issued), max) + takes.push(take) + issued += take + } + + expect(takes).toEqual([500000, 500000, 200000]) + expect(issued).toBe(paid) + }) }) diff --git a/package.json b/package.json index 6d886902..95a4b86e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minibits_wallet", - "version": "0.4.3-beta.21", + "version": "0.4.3-beta.23", "private": true, "scripts": { "android:clean": "cd android && ./gradlew clean", diff --git a/src/i18n_messages/en.json b/src/i18n_messages/en.json index f8722248..792cb937 100644 --- a/src/i18n_messages/en.json +++ b/src/i18n_messages/en.json @@ -400,6 +400,7 @@ "payCommon_isPending": "Payment is pending", "payCommon_isInProgress": "Payment in progress", "payCommon_maximumPay": "Maximum amount to pay is %{amount} %{currency}.", + "payCommon_maximumTopup": "Maximum topup amount is %{amount} %{currency}.", "payCommon_minimumPay": "Minimal payment amount is %{amount} %{currency}.", "payCommon_minimumWithdraw": "Minimal withdraw amount is %{amount} %{currency}.", "payCommon_pay": "Pay", @@ -577,7 +578,7 @@ "recoveryScreen_recoverMeltQuoteChange": "Recover melt quote change", "recoveryScreen_recoverMeltQuoteChangeDesc": "Retry to receive back unspent ecash from a completed Lightning payment.", "recoveryScreen_recoverMintQuote": "Recover mint quote", - "recoveryScreen_recoverMintQuoteDesc": "Retry to mint ecash from an already paid mint quote.", + "recoveryScreen_recoverMintQuoteDesc": "Retry to mint ecash from an already paid Lightning or onchain mint quote.", "recoveryScreen_remove": "Remove", "recoveryScreen_selectMintToRecoverFrom": "Select mint to recover from", "recoveryScreen_start": "Start", @@ -900,6 +901,7 @@ "tranDetail_checkStatus": "Check status", "tranDetail_onchainStillUnconfirmed": "The payment is still waiting for confirmation on the blockchain.", "tranDetail_onchainNoDeposits": "No new deposits found at this address yet.", + "tranDetail_onchainPartiallyMinted": "The mint limits how much can be minted at once, so %{amount} %{currency} of your deposit is still waiting. Check again to mint the rest.", "tranDetail_onchainOutpoint": "Bitcoin transaction", "transactionType_topupOnchain": "Topup onchain", "transactionType_transferOnchain": "Onchain payment", diff --git a/src/i18n_messages/es.json b/src/i18n_messages/es.json index 0e795c04..6b903537 100644 --- a/src/i18n_messages/es.json +++ b/src/i18n_messages/es.json @@ -413,6 +413,7 @@ "payCommon_isPending": "El pago está pendiente", "payCommon_isInProgress": "Pago en curso", "payCommon_maximumPay": "El monto máximo a pagar es %{amount} %{currency} .", + "payCommon_maximumTopup": "El monto máximo de recarga 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_pay": "Pagar", @@ -590,7 +591,7 @@ "recoveryScreen_recoverMeltQuoteChange": "Recuperar el cambio de cotización de fusión", "recoveryScreen_recoverMeltQuoteChangeDesc": "Intente nuevamente recibir el ecash no gastado de un pago Lightning completado.", "recoveryScreen_recoverMintQuote": "Recuperar cotización de mint", - "recoveryScreen_recoverMintQuoteDesc": "Intente nuevamente generar ecash a partir de una cotización de mint ya pagada.", + "recoveryScreen_recoverMintQuoteDesc": "Intente nuevamente generar ecash a partir de una cotización de mint Lightning u onchain ya pagada.", "recoveryScreen_remove": "Eliminar", "recoveryScreen_selectMintToRecoverFrom": "Seleccione menta para recuperarse", "recoveryScreen_start": "Comenzar", @@ -899,6 +900,7 @@ "tranDetail_checkStatus": "Comprobar estado", "tranDetail_onchainStillUnconfirmed": "El pago aún está esperando la confirmación en la blockchain.", "tranDetail_onchainNoDeposits": "Aún no se encontraron nuevos depósitos en esta dirección.", + "tranDetail_onchainPartiallyMinted": "La casa de cambio limita cuánto se puede emitir de una vez, por lo que %{amount} %{currency} de tu depósito siguen pendientes. Vuelve a comprobar para emitir el resto.", "tranDetail_onchainOutpoint": "Transacción Bitcoin", "transactionType_topupOnchain": "Recarga onchain", "transactionType_transferOnchain": "Pago onchain", diff --git a/src/i18n_messages/pt.json b/src/i18n_messages/pt.json index 8ae0ffed..52525544 100644 --- a/src/i18n_messages/pt.json +++ b/src/i18n_messages/pt.json @@ -413,6 +413,7 @@ "payCommon_isPending": "Pagamento pendente", "payCommon_isInProgress": "Pagamento em andamento", "payCommon_maximumPay": "Valor máximo a pagar é %{amount} %{currency}.", + "payCommon_maximumTopup": "Valor máximo de recarga é %{amount} %{currency}.", "payCommon_minimumPay": "Valor mínimo de pagamento é %{amount} %{currency}.", "payCommon_minimumWithdraw": "Valor mínimo de saque é %{amount} %{currency}.", "payCommon_pay": "Pagar", @@ -590,7 +591,7 @@ "recoveryScreen_recoverMeltQuoteChange": "Recuperar troco de cotação melt", "recoveryScreen_recoverMeltQuoteChangeDesc": "Tente novamente receber de volta ecash não gasto de um pagamento Lightning concluído.", "recoveryScreen_recoverMintQuote": "Recuperar cotação mint", - "recoveryScreen_recoverMintQuoteDesc": "Tente novamente cunhar ecash de uma cotação mint já paga.", + "recoveryScreen_recoverMintQuoteDesc": "Tente novamente cunhar ecash de uma cotação mint Lightning ou onchain já paga.", "recoveryScreen_remove": "Remover", "recoveryScreen_selectMintToRecoverFrom": "Selecione mint para recuperar", "recoveryScreen_start": "Iniciar", @@ -899,6 +900,7 @@ "tranDetail_checkStatus": "Verificar estado", "tranDetail_onchainStillUnconfirmed": "O pagamento ainda aguarda confirmação na blockchain.", "tranDetail_onchainNoDeposits": "Ainda não foram encontrados novos depósitos neste endereço.", + "tranDetail_onchainPartiallyMinted": "A casa da moeda limita quanto pode ser emitido de uma vez, por isso %{amount} %{currency} do seu depósito ainda estão pendentes. Verifique novamente para emitir o restante.", "tranDetail_onchainOutpoint": "Transação Bitcoin", "transactionType_topupOnchain": "Carregamento onchain", "transactionType_transferOnchain": "Pagamento onchain", diff --git a/src/i18n_messages/sk.json b/src/i18n_messages/sk.json index eb3ac2d8..12adbcd0 100644 --- a/src/i18n_messages/sk.json +++ b/src/i18n_messages/sk.json @@ -413,6 +413,7 @@ "payCommon_isPending": "Platba je bežiaca", "payCommon_isInProgress": "Platba prebieha", "payCommon_maximumPay": "Najvyššia suma na zaplatenie je %{amount}.", + "payCommon_maximumTopup": "Najvyššia suma na dobitie je %{amount} %{currency}.", "payCommon_minimumPay": "Minimálna suma platby je %{amount} %{currency} .", "payCommon_minimumWithdraw": "Najmenšia suma na zaplatenie je %{amount} %{currnecy}", "payCommon_pay": "Zaplať", @@ -590,7 +591,7 @@ "recoveryScreen_recoverMeltQuoteChange": "Obnoviť melt quote výdavok", "recoveryScreen_recoverMeltQuoteChangeDesc": "Pokúsiť sa znovu prijať neminutý ecash z dokončenej Lightning platby.", "recoveryScreen_recoverMintQuote": "Obnoviť mint quote", - "recoveryScreen_recoverMintQuoteDesc": "Pokúsiť sa znovu vytvoriť ecash z už zaplatenej mint quote.", + "recoveryScreen_recoverMintQuoteDesc": "Pokúsiť sa znovu vytvoriť ecash z už zaplatenej Lightning alebo onchain mint quote.", "recoveryScreen_remove": "Odstrániť", "recoveryScreen_selectMintToRecoverFrom": "Vyber mint na obnovu", "recoveryScreen_start": "Začať", @@ -899,6 +900,7 @@ "tranDetail_checkStatus": "Skontrolovať stav", "tranDetail_onchainStillUnconfirmed": "Platba stále čaká na potvrdenie v blockchaine.", "tranDetail_onchainNoDeposits": "Na tejto adrese zatiaľ neboli nájdené žiadne nové vklady.", + "tranDetail_onchainPartiallyMinted": "Mint obmedzuje, koľko sa dá vydať naraz, takže %{amount} %{currency} z vášho vkladu stále čaká. Skontrolujte znova a vydajte zvyšok.", "tranDetail_onchainOutpoint": "Bitcoin transakcia", "transactionType_topupOnchain": "Dobitie onchain", "transactionType_transferOnchain": "Onchain platba", diff --git a/src/screens/RecoveryOptionsScreen.tsx b/src/screens/RecoveryOptionsScreen.tsx index f393b332..2124b9ec 100644 --- a/src/screens/RecoveryOptionsScreen.tsx +++ b/src/screens/RecoveryOptionsScreen.tsx @@ -230,13 +230,18 @@ export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen(_: throw new AppError(Err.VALIDATION_ERROR, 'Mint is not selected.') } - if(mintQuote.length !== 40) { - throw new AppError(Err.VALIDATION_ERROR, 'Mint quote must have 40 characters.') + // Length is not checked beyond "looks like an id": a quote id is opaque and + // its shape is the mint's business — bolt11 ids here are 40 hex characters, + // an onchain (NUT-30) one may be a uuid or anything else the mint issues. + const quote = mintQuote.trim() + + if(quote.length < 8) { + throw new AppError(Err.VALIDATION_ERROR, 'Enter the mint quote id as the mint issued it.') } const result = await WalletTask.recoverMintQuote({ mintUrl: mintBalanceToRecoverFrom.mintUrl, - mintQuote + mintQuote: quote }) setIsLoading(false) @@ -490,7 +495,7 @@ export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen(_: value={mintQuote} autoCapitalize='none' keyboardType='default' - maxLength={40} + maxLength={80} selectTextOnFocus={true} style={[$quoteInput, {backgroundColor: inputBg, color: inputText}]} /> diff --git a/src/screens/TopupScreen.tsx b/src/screens/TopupScreen.tsx index 8f58f885..b24d1cf6 100644 --- a/src/screens/TopupScreen.tsx +++ b/src/screens/TopupScreen.tsx @@ -57,7 +57,8 @@ import { CurrencyCode, MintUnit, getCurrency, - convertToFromSats + convertToFromSats, + formatCurrency } from '../services/wallet/currency' import {MintHeader} from './Mints/MintHeader' import useIsInternetReachable from '../utils/useIsInternetReachable' @@ -690,6 +691,27 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) { if (!mintBalanceToTopup) return try { + // The mint caps a single mint operation. Unlike bolt11 nothing on the mint + // side refuses an over-limit onchain topup — the quote is just an address and + // carries no amount — so a bigger deposit is credited but then has to be + // minted in instalments (capMintAmount), one per sweep. Say so up front + // instead of handing out an address with a hint the mint cannot honour. + // `AmountLike` in the cashu-ts types, but mint info is normalized straight off + // the JSON, so this is a plain number. + const maxAmount = selectedMint?.mintMethodSetting!('onchain', unitRef.current) + ?.max_amount as number | null | undefined + + if (maxAmount && amountToTopupInt() > maxAmount) { + const currencyCode = getCurrency(unitRef.current).code + infoMessage( + translate('payCommon_maximumTopup', { + amount: formatCurrency(maxAmount, currencyCode), + currency: currencyCode, + }), + ) + return + } + dispatch({ type: 'TOPUP_START' }) const created = await OnchainTopupOperationApi.createQuote({ diff --git a/src/screens/TranDetailScreen.tsx b/src/screens/TranDetailScreen.tsx index a8edd6a2..dadb8d5a 100644 --- a/src/screens/TranDetailScreen.tsx +++ b/src/screens/TranDetailScreen.tsx @@ -1559,6 +1559,21 @@ const OnchainTopupInfoBlock = function (props: { setCheckResult(result.error) } else if (!result?.minted) { setCheckResult(translate('tranDetail_onchainNoDeposits')) + } else if (result.amountPaid > result.amountIssued) { + // The mint caps a single mint operation, so a deposit above that cap + // is minted in instalments — this run took one. The remainder stays + // credited on the quote and the watcher will collect it, but say so + // here: the user is standing in front of the button that does it, and + // silence would read as "that is all there was". + setCheckResult( + translate('tranDetail_onchainPartiallyMinted', { + amount: formatCurrency( + result.amountPaid - result.amountIssued, + getCurrency(transaction.unit).code, + ), + currency: getCurrency(transaction.unit).code, + }), + ) } // A successful mint settles the transaction; the observer re-renders it. } catch (e: any) { diff --git a/src/services/cashu/nut20.ts b/src/services/cashu/nut20.ts index 5688615f..5e911ff9 100644 --- a/src/services/cashu/nut20.ts +++ b/src/services/cashu/nut20.ts @@ -102,3 +102,42 @@ export const allocateQuoteKeypair = function ( const index = allocateNextCounter(NUT20_COUNTER) return {index, ...deriveQuoteKeypair(seed, index)} } + +/** + * How far past the wallet's own counter a recovery scan keeps looking. + * + * Allocation is burn-forward, so indices are skipped whenever a quote request + * fails — the used indices are a sparse prefix, not a dense one. And the counter + * itself is local state: a wiped wallet (or a wallet restored from the seed + * elsewhere) starts back at 0 while the mint still holds quotes locked to much + * higher indices. So the scan cannot stop at the local counter. + */ +export const NUT20_RECOVERY_GAP_LIMIT = 200 + +/** + * Which derivation index produced `pubkey`, or undefined within `scanTo`. + * + * A quote is locked to a NUT-20 pubkey and only its private key can sign the mint + * request. When the quote row is gone — recovering a quote made on another + * install of the same seed, or after the wallet state was wiped — the index is + * the one thing missing, and the mint hands back the pubkey, so it can be found + * by re-deriving. Undefined means the quote belongs to a different seed and this + * wallet can never mint it. + * + * ponytail: re-derives from the master seed per index (~scanTo HMAC chains). At a + * few hundred indices behind a manual recovery button that is imperceptible; if + * the scan ever needs to be wide, derive the parent once and walk its children. + */ +export const findQuoteKeyIndex = function ( + seed: Uint8Array, + pubkey: string, + scanTo: number, +): number | undefined { + const target = pubkey.toLowerCase() + + for (let index = 0; index <= scanTo; index++) { + if (deriveQuoteKeypair(seed, index).pubkey === target) return index + } + + return undefined +} diff --git a/src/services/wallet/operations/mintOperations.ts b/src/services/wallet/operations/mintOperations.ts index 6ac54e6b..3f8ba375 100644 --- a/src/services/wallet/operations/mintOperations.ts +++ b/src/services/wallet/operations/mintOperations.ts @@ -157,7 +157,19 @@ function _lastDataEntry(tx: Transaction): TransactionData | undefined { } /** - * Manually recover minted ecash from a paid mint quote (e.g. lost topup) + * Manually recover minted ecash from a paid mint quote (e.g. lost topup). + * + * Takes a bare quote id, so it works where nothing else does: a quote made on + * another install of the same seed, or one whose transaction was lost with the + * wallet state. There is no transaction to start from — only what the mint still + * knows about the quote. + * + * Both rails are served through this one entry, because a quote id does not say + * which rail it belongs to. bolt11 is tried first (every mint has it, and it is + * what this has always recovered); an onchain (NUT-30) quote lives behind a + * different endpoint, so it is tried when the bolt11 lookup fails at a mint that + * advertises onchain minting. See OnchainTopupOperationApi.recoverQuote for what + * recovering an onchain quote additionally has to reconstruct. */ const recoverMintQuote = async ( params: {mintUrl: string; mintQuote: string}, @@ -172,7 +184,29 @@ const recoverMintQuote = async ( log.trace('[recoverMintQuote] start', {mintUrl, mintQuote}) - const {state, mintQuote: returnedQuote, encodedInvoice} = await walletStore.checkLightningMintQuote(mintUrl, mintQuote) + let lightningQuote + + try { + lightningQuote = await walletStore.checkLightningMintQuote(mintUrl, mintQuote) + } catch (e: any) { + // Not a bolt11 quote at this mint — or the mint is unreachable, in which case + // the onchain lookup below fails too and its error is the one the user sees. + if (!mint.supportsMint!('onchain', unit)) { + throw e + } + + log.debug('[recoverMintQuote] Not a bolt11 quote, trying onchain', { + mintUrl, + mintQuote, + error: e.message, + }) + + const {OnchainTopupOperationApi} = await import('./onchainTopupOperationApi') + + return await OnchainTopupOperationApi.recoverQuote({mintUrl, quote: mintQuote}) + } + + const {state, mintQuote: returnedQuote, encodedInvoice} = lightningQuote if (returnedQuote !== mintQuote) { throw new ValidationError('Mint returned mismatched quote', {mintQuote, returnedQuote}) diff --git a/src/services/wallet/operations/onchainTopupOperationApi.ts b/src/services/wallet/operations/onchainTopupOperationApi.ts index eddee3d8..351a3e34 100644 --- a/src/services/wallet/operations/onchainTopupOperationApi.ts +++ b/src/services/wallet/operations/onchainTopupOperationApi.ts @@ -61,8 +61,14 @@ import { } from '../../../models/Transaction' import {Database} from '../../../services' import {OnchainMintQuoteRecord} from '../../db/onchainQuotesRepo' +import {NUT20_COUNTER} from '../../db' import type {Mint} from '../../../models/Mint' -import {allocateQuoteKeypair, deriveQuoteKeypair} from '../../cashu/nut20' +import { + allocateQuoteKeypair, + deriveQuoteKeypair, + findQuoteKeyIndex, + NUT20_RECOVERY_GAP_LIMIT, +} from '../../cashu/nut20' import {capMintAmount, mintableAmount} from './onchainAmounts' import {CashuProof} from '../../cashu/cashuUtils' import {MintUnit} from '../currency' @@ -260,7 +266,12 @@ export type RefreshOnchainQuoteResult = { quote: string amountPaid: number amountIssued: number - /** Ecash actually minted during THIS refresh. 0 when nothing new arrived. */ + /** + * Ecash actually minted during THIS refresh. 0 when nothing new arrived, and + * less than `amountPaid - amountIssued` when the mint's per-operation maximum + * capped it — `amountPaid > amountIssued` afterwards means another run will + * take the rest. + */ minted: number transactionId?: number } @@ -300,17 +311,144 @@ async function refreshQuote(quoteId: string): Promise return {quote: quoteId, amountPaid, amountIssued, minted: 0} } - const transactionId = await _mintAvailable(row, mint, quoteResponse, mintable) + const {transactionId, minted} = await _mintAvailable(row, mint, quoteResponse, mintable) + // `minted`, NOT `mintable`: a deposit over the mint's per-operation maximum is + // minted in instalments, so this run may have taken only part of it. The rest + // stays credited on the quote and the watch rule (amountPaid > amountIssued) + // keeps it in the sweep — but the caller has to be told the truth about what + // landed, or the UI reports a partial recovery as a complete one. return { quote: quoteId, amountPaid, - amountIssued: amountIssued + mintable, - minted: mintable, + amountIssued: amountIssued + minted, + minted, transactionId, } } +// ───────────────────────────────────────────────────────────────────────────── +// recoverQuote() +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Mint an onchain quote this wallet has no record of. + * + * The manual recovery path (Recovery options → Recover mint quote), for a quote + * the wallet cannot reach through a transaction: one created on another install + * of the same seed, or before the wallet state was wiped. There is no quote row + * and no transaction — only the id the user pastes. + * + * Everything else the row holds can be read back off the mint: the address, the + * unit, the expiry, and how much is paid and issued. Everything but the NUT-20 + * derivation index, which is local and is the one thing that can make the money + * unmintable. The mint does return the PUBKEY the quote is locked to, so the + * index is recovered by re-deriving from the seed until the pubkey matches + * (findQuoteKeyIndex). No match means the quote belongs to another seed — no + * signature we can produce will ever satisfy it, so this refuses rather than + * leaving the user tapping a button that cannot work. + * + * Once the row is rebuilt this is an ordinary quote again: the mint is done by + * refreshQuote through the sync queue, so the cap on a single mint operation, the + * instalments for an over-limit deposit, the settled transaction and the watch on + * any remainder all behave exactly as they do for a quote created here. + */ +async function recoverQuote(input: { + mintUrl: string + quote: string +}): Promise<{recoveredAmount: number}> { + const {mintUrl, quote: quoteId} = input + + const mintInstance = mintsStore.findByUrl(mintUrl) + if (!mintInstance) { + throw new ValidationError('Could not find mint', {mintUrl}) + } + + const quoteResponse = await walletStore.checkOnchainMintQuote(mintUrl, quoteId) + + const amountPaid = Number(quoteResponse.amount_paid ?? 0) + const amountIssued = Number(quoteResponse.amount_issued ?? 0) + + if (mintableAmount(amountPaid, amountIssued) <= 0) { + throw new ValidationError( + 'This onchain quote has nothing left to mint.', + {quote: quoteId, amountPaid, amountIssued}, + ) + } + + if (!Database.getOnchainMintQuote(quoteId)) { + const pubkey = quoteResponse.pubkey + if (!pubkey) { + throw new ValidationError( + 'This onchain quote is not locked to a key, so this wallet cannot mint it.', + {quote: quoteId}, + ) + } + + const seed: Uint8Array = await walletStore.getCachedSeed() + const scanTo = Database.getWalletCounter(NUT20_COUNTER) + NUT20_RECOVERY_GAP_LIMIT + const counterIndex = findQuoteKeyIndex(seed, pubkey, scanTo) + + if (counterIndex === undefined) { + throw new ValidationError( + 'This onchain quote is locked to a key that does not belong to this wallet seed.', + {quote: quoteId, pubkey, scanTo}, + ) + } + + Database.addOnchainMintQuote({ + mintId: mintInstance.id, + quote: quoteId, + mintUrl, + unit: quoteResponse.unit, + address: quoteResponse.request, + counterIndex, + pubkey, + // Unknowable now — it was only ever a BIP21 hint on the device that + // created the quote. What was actually paid comes from the mint. + amountRequested: null, + amountPaid, + amountIssued, + expiry: quoteResponse.expiry ?? null, + }) + + // A recovered index can sit far above the local counter (a wiped wallet + // starts back at 0), and handing it out again would lock two quotes to one + // key. setWalletCounter only ever raises the value, so this cannot walk the + // wallet back onto an index it has already used. + Database.setWalletCounter(NUT20_COUNTER, counterIndex + 1) + + log.info('[OnchainTopupOperationApi.recoverQuote] Rebuilt a lost quote', { + quote: quoteId, + mintUrl, + counterIndex, + amountPaid, + amountIssued, + }) + } else { + // Known quote, but the user is here because nothing settled it — the watch + // has very likely lapsed, and a check alone would not put it back in the + // watcher's set for the next deposit. + Database.extendOnchainMintQuoteWatch(quoteId) + } + + // Through the queue, NOT straight to refreshQuote: minting derives blinded + // secrets from the keyset counter and the watcher sweep can be doing the same + // thing at this moment. SyncQueue (concurrency 1) is what keeps the two from + // advancing to the same counter and reusing secrets. + const {OnchainOperationService} = await import('./onchainOperations') + const result: any = await OnchainOperationService.enqueueOnchainQuoteCheck(quoteId) + + // The watcher swallows errors by design (one bad quote must not abort a sweep), + // so a failure arrives as a value here. The user asked for this one explicitly + // and needs to see why it did not work. + if (result?.error) { + throw new MintError(result.error, {quote: quoteId, mintUrl}) + } + + return {recoveredAmount: result?.minted ?? 0} +} + // ───────────────────────────────────────────────────────────────────────────── // Private // ───────────────────────────────────────────────────────────────────────────── @@ -355,7 +493,7 @@ async function _mintAvailable( mintInstance: Mint, quoteResponse: any, mintable: number, -): Promise { +): Promise<{transactionId: number; minted: number}> { const {quote, counterIndex} = row const unit = row.unit as MintUnit @@ -458,11 +596,11 @@ async function _mintAvailable( mintedAmount, }) - return transactionId + return {transactionId, minted: mintedAmount} } /** - * The PENDING transaction waiting on this quote, or a new one. + * The unsettled transaction waiting on this quote, or a new one. * * A second deposit to an address whose transaction already COMPLETED is a genuinely * new receipt and gets its own transaction — an amount that mutates after @@ -477,15 +615,23 @@ async function _findOrCreateTransaction( ): Promise { const last = transactionsStore.findLastBy({quote: row.quote}) - // PREPARED counts as reusable, not just PENDING. createQuote passes through it on - // the way to PENDING, so a crash in that window leaves a PREPARED row for this - // quote — and settling onto a NEW transaction instead would leave the user with two - // rows for one deposit. + // PREPARED and ERROR count as reusable, not just PENDING. + // + // PREPARED: createQuote passes through it on the way to PENDING, so a crash in + // that window leaves a PREPARED row for this quote — and settling onto a NEW + // transaction instead would leave the user with two rows for one deposit. + // + // ERROR: the row of an attempt that failed (the mint refused the mint request, + // the app died mid-flight). It never received proofs — nothing is ERROR after a + // successful commit — so settling onto it is a recovery, not a rewrite: the user + // taps "check for deposits" and the failed topup they are looking at becomes the + // completed one, instead of staying broken beside a new row they did not expect. if ( last && last.type === TransactionType.TOPUP_ONCHAIN && (last.status === TransactionStatus.PENDING || - last.status === TransactionStatus.PREPARED) + last.status === TransactionStatus.PREPARED || + last.status === TransactionStatus.ERROR) ) { return last } @@ -538,4 +684,5 @@ function _parseData(tx: Transaction): TransactionData[] { export const OnchainTopupOperationApi = { createQuote, refreshQuote, + recoverQuote, }