Hotfix onchain minting, improve NWC compatibility

This commit is contained in:
minibits-cash
2026-09-18 19:04:44 +02:00
parent e1310ee553
commit 31b74985ca
13 changed files with 338 additions and 25 deletions
+22
View File
@@ -40,6 +40,7 @@ jest.mock('../src/services/db/walletCountersRepo', () => ({
import { import {
allocateQuoteKeypair, allocateQuoteKeypair,
deriveQuoteKeypair, deriveQuoteKeypair,
findQuoteKeyIndex,
quoteKeyDerivationPath, quoteKeyDerivationPath,
} from '../src/services/cashu/nut20' } 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', () => { it('is deterministic for a given seed and index', () => {
expect(deriveQuoteKeypair(SEED, 7)).toEqual(deriveQuoteKeypair(SEED, 7)) expect(deriveQuoteKeypair(SEED, 7)).toEqual(deriveQuoteKeypair(SEED, 7))
}) })
+21
View File
@@ -124,4 +124,25 @@ describe('capMintAmount', () => {
it('caps exactly at the boundary', () => { it('caps exactly at the boundary', () => {
expect(capMintAmount(500000, 500000)).toBe(500000) 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)
})
}) })
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "minibits_wallet", "name": "minibits_wallet",
"version": "0.4.3-beta.21", "version": "0.4.3-beta.23",
"private": true, "private": true,
"scripts": { "scripts": {
"android:clean": "cd android && ./gradlew clean", "android:clean": "cd android && ./gradlew clean",
+3 -1
View File
@@ -400,6 +400,7 @@
"payCommon_isPending": "Payment is pending", "payCommon_isPending": "Payment is pending",
"payCommon_isInProgress": "Payment in progress", "payCommon_isInProgress": "Payment in progress",
"payCommon_maximumPay": "Maximum amount to pay is %{amount} %{currency}.", "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_minimumPay": "Minimal payment amount is %{amount} %{currency}.",
"payCommon_minimumWithdraw": "Minimal withdraw amount is %{amount} %{currency}.", "payCommon_minimumWithdraw": "Minimal withdraw amount is %{amount} %{currency}.",
"payCommon_pay": "Pay", "payCommon_pay": "Pay",
@@ -577,7 +578,7 @@
"recoveryScreen_recoverMeltQuoteChange": "Recover melt quote change", "recoveryScreen_recoverMeltQuoteChange": "Recover melt quote change",
"recoveryScreen_recoverMeltQuoteChangeDesc": "Retry to receive back unspent ecash from a completed Lightning payment.", "recoveryScreen_recoverMeltQuoteChangeDesc": "Retry to receive back unspent ecash from a completed Lightning payment.",
"recoveryScreen_recoverMintQuote": "Recover mint quote", "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_remove": "Remove",
"recoveryScreen_selectMintToRecoverFrom": "Select mint to recover from", "recoveryScreen_selectMintToRecoverFrom": "Select mint to recover from",
"recoveryScreen_start": "Start", "recoveryScreen_start": "Start",
@@ -900,6 +901,7 @@
"tranDetail_checkStatus": "Check status", "tranDetail_checkStatus": "Check status",
"tranDetail_onchainStillUnconfirmed": "The payment is still waiting for confirmation on the blockchain.", "tranDetail_onchainStillUnconfirmed": "The payment is still waiting for confirmation on the blockchain.",
"tranDetail_onchainNoDeposits": "No new deposits found at this address yet.", "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", "tranDetail_onchainOutpoint": "Bitcoin transaction",
"transactionType_topupOnchain": "Topup onchain", "transactionType_topupOnchain": "Topup onchain",
"transactionType_transferOnchain": "Onchain payment", "transactionType_transferOnchain": "Onchain payment",
+3 -1
View File
@@ -413,6 +413,7 @@
"payCommon_isPending": "El pago está pendiente", "payCommon_isPending": "El pago está pendiente",
"payCommon_isInProgress": "Pago en curso", "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_maximumTopup": "El monto máximo de recarga 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} .",
"payCommon_pay": "Pagar", "payCommon_pay": "Pagar",
@@ -590,7 +591,7 @@
"recoveryScreen_recoverMeltQuoteChange": "Recuperar el cambio de cotización de fusión", "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_recoverMeltQuoteChangeDesc": "Intente nuevamente recibir el ecash no gastado de un pago Lightning completado.",
"recoveryScreen_recoverMintQuote": "Recuperar cotización de mint", "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_remove": "Eliminar",
"recoveryScreen_selectMintToRecoverFrom": "Seleccione menta para recuperarse", "recoveryScreen_selectMintToRecoverFrom": "Seleccione menta para recuperarse",
"recoveryScreen_start": "Comenzar", "recoveryScreen_start": "Comenzar",
@@ -899,6 +900,7 @@
"tranDetail_checkStatus": "Comprobar estado", "tranDetail_checkStatus": "Comprobar estado",
"tranDetail_onchainStillUnconfirmed": "El pago aún está esperando la confirmación en la blockchain.", "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_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", "tranDetail_onchainOutpoint": "Transacción Bitcoin",
"transactionType_topupOnchain": "Recarga onchain", "transactionType_topupOnchain": "Recarga onchain",
"transactionType_transferOnchain": "Pago onchain", "transactionType_transferOnchain": "Pago onchain",
+3 -1
View File
@@ -413,6 +413,7 @@
"payCommon_isPending": "Pagamento pendente", "payCommon_isPending": "Pagamento pendente",
"payCommon_isInProgress": "Pagamento em andamento", "payCommon_isInProgress": "Pagamento em andamento",
"payCommon_maximumPay": "Valor máximo a pagar é %{amount} %{currency}.", "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_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}.",
"payCommon_pay": "Pagar", "payCommon_pay": "Pagar",
@@ -590,7 +591,7 @@
"recoveryScreen_recoverMeltQuoteChange": "Recuperar troco de cotação melt", "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_recoverMeltQuoteChangeDesc": "Tente novamente receber de volta ecash não gasto de um pagamento Lightning concluído.",
"recoveryScreen_recoverMintQuote": "Recuperar cotação mint", "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_remove": "Remover",
"recoveryScreen_selectMintToRecoverFrom": "Selecione mint para recuperar", "recoveryScreen_selectMintToRecoverFrom": "Selecione mint para recuperar",
"recoveryScreen_start": "Iniciar", "recoveryScreen_start": "Iniciar",
@@ -899,6 +900,7 @@
"tranDetail_checkStatus": "Verificar estado", "tranDetail_checkStatus": "Verificar estado",
"tranDetail_onchainStillUnconfirmed": "O pagamento ainda aguarda confirmação na blockchain.", "tranDetail_onchainStillUnconfirmed": "O pagamento ainda aguarda confirmação na blockchain.",
"tranDetail_onchainNoDeposits": "Ainda não foram encontrados novos depósitos neste endereço.", "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", "tranDetail_onchainOutpoint": "Transação Bitcoin",
"transactionType_topupOnchain": "Carregamento onchain", "transactionType_topupOnchain": "Carregamento onchain",
"transactionType_transferOnchain": "Pagamento onchain", "transactionType_transferOnchain": "Pagamento onchain",
+3 -1
View File
@@ -413,6 +413,7 @@
"payCommon_isPending": "Platba je bežiaca", "payCommon_isPending": "Platba je bežiaca",
"payCommon_isInProgress": "Platba prebieha", "payCommon_isInProgress": "Platba prebieha",
"payCommon_maximumPay": "Najvyššia suma na zaplatenie je %{amount}.", "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_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}",
"payCommon_pay": "Zaplať", "payCommon_pay": "Zaplať",
@@ -590,7 +591,7 @@
"recoveryScreen_recoverMeltQuoteChange": "Obnoviť melt quote výdavok", "recoveryScreen_recoverMeltQuoteChange": "Obnoviť melt quote výdavok",
"recoveryScreen_recoverMeltQuoteChangeDesc": "Pokúsiť sa znovu prijať neminutý ecash z dokončenej Lightning platby.", "recoveryScreen_recoverMeltQuoteChangeDesc": "Pokúsiť sa znovu prijať neminutý ecash z dokončenej Lightning platby.",
"recoveryScreen_recoverMintQuote": "Obnoviť mint quote", "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_remove": "Odstrániť",
"recoveryScreen_selectMintToRecoverFrom": "Vyber mint na obnovu", "recoveryScreen_selectMintToRecoverFrom": "Vyber mint na obnovu",
"recoveryScreen_start": "Začať", "recoveryScreen_start": "Začať",
@@ -899,6 +900,7 @@
"tranDetail_checkStatus": "Skontrolovať stav", "tranDetail_checkStatus": "Skontrolovať stav",
"tranDetail_onchainStillUnconfirmed": "Platba stále čaká na potvrdenie v blockchaine.", "tranDetail_onchainStillUnconfirmed": "Platba stále čaká na potvrdenie v blockchaine.",
"tranDetail_onchainNoDeposits": "Na tejto adrese zatiaľ neboli nájdené žiadne nové vklady.", "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", "tranDetail_onchainOutpoint": "Bitcoin transakcia",
"transactionType_topupOnchain": "Dobitie onchain", "transactionType_topupOnchain": "Dobitie onchain",
"transactionType_transferOnchain": "Onchain platba", "transactionType_transferOnchain": "Onchain platba",
+9 -4
View File
@@ -230,13 +230,18 @@ export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen(_:
throw new AppError(Err.VALIDATION_ERROR, 'Mint is not selected.') throw new AppError(Err.VALIDATION_ERROR, 'Mint is not selected.')
} }
if(mintQuote.length !== 40) { // Length is not checked beyond "looks like an id": a quote id is opaque and
throw new AppError(Err.VALIDATION_ERROR, 'Mint quote must have 40 characters.') // 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({ const result = await WalletTask.recoverMintQuote({
mintUrl: mintBalanceToRecoverFrom.mintUrl, mintUrl: mintBalanceToRecoverFrom.mintUrl,
mintQuote mintQuote: quote
}) })
setIsLoading(false) setIsLoading(false)
@@ -490,7 +495,7 @@ export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen(_:
value={mintQuote} value={mintQuote}
autoCapitalize='none' autoCapitalize='none'
keyboardType='default' keyboardType='default'
maxLength={40} maxLength={80}
selectTextOnFocus={true} selectTextOnFocus={true}
style={[$quoteInput, {backgroundColor: inputBg, color: inputText}]} style={[$quoteInput, {backgroundColor: inputBg, color: inputText}]}
/> />
+23 -1
View File
@@ -57,7 +57,8 @@ import {
CurrencyCode, CurrencyCode,
MintUnit, MintUnit,
getCurrency, getCurrency,
convertToFromSats convertToFromSats,
formatCurrency
} from '../services/wallet/currency' } from '../services/wallet/currency'
import {MintHeader} from './Mints/MintHeader' import {MintHeader} from './Mints/MintHeader'
import useIsInternetReachable from '../utils/useIsInternetReachable' import useIsInternetReachable from '../utils/useIsInternetReachable'
@@ -690,6 +691,27 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
if (!mintBalanceToTopup) return if (!mintBalanceToTopup) return
try { 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' }) dispatch({ type: 'TOPUP_START' })
const created = await OnchainTopupOperationApi.createQuote({ const created = await OnchainTopupOperationApi.createQuote({
+15
View File
@@ -1559,6 +1559,21 @@ const OnchainTopupInfoBlock = function (props: {
setCheckResult(result.error) setCheckResult(result.error)
} else if (!result?.minted) { } else if (!result?.minted) {
setCheckResult(translate('tranDetail_onchainNoDeposits')) 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. // A successful mint settles the transaction; the observer re-renders it.
} catch (e: any) { } catch (e: any) {
+39
View File
@@ -102,3 +102,42 @@ export const allocateQuoteKeypair = function (
const index = allocateNextCounter(NUT20_COUNTER) const index = allocateNextCounter(NUT20_COUNTER)
return {index, ...deriveQuoteKeypair(seed, index)} 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
}
@@ -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 ( const recoverMintQuote = async (
params: {mintUrl: string; mintQuote: string}, params: {mintUrl: string; mintQuote: string},
@@ -172,7 +184,29 @@ const recoverMintQuote = async (
log.trace('[recoverMintQuote] start', {mintUrl, mintQuote}) 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) { if (returnedQuote !== mintQuote) {
throw new ValidationError('Mint returned mismatched quote', {mintQuote, returnedQuote}) throw new ValidationError('Mint returned mismatched quote', {mintQuote, returnedQuote})
@@ -61,8 +61,14 @@ import {
} from '../../../models/Transaction' } from '../../../models/Transaction'
import {Database} from '../../../services' import {Database} from '../../../services'
import {OnchainMintQuoteRecord} from '../../db/onchainQuotesRepo' import {OnchainMintQuoteRecord} from '../../db/onchainQuotesRepo'
import {NUT20_COUNTER} from '../../db'
import type {Mint} from '../../../models/Mint' 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 {capMintAmount, mintableAmount} from './onchainAmounts'
import {CashuProof} from '../../cashu/cashuUtils' import {CashuProof} from '../../cashu/cashuUtils'
import {MintUnit} from '../currency' import {MintUnit} from '../currency'
@@ -260,7 +266,12 @@ export type RefreshOnchainQuoteResult = {
quote: string quote: string
amountPaid: number amountPaid: number
amountIssued: 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 minted: number
transactionId?: number transactionId?: number
} }
@@ -300,17 +311,144 @@ async function refreshQuote(quoteId: string): Promise<RefreshOnchainQuoteResult>
return {quote: quoteId, amountPaid, amountIssued, minted: 0} 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 { return {
quote: quoteId, quote: quoteId,
amountPaid, amountPaid,
amountIssued: amountIssued + mintable, amountIssued: amountIssued + minted,
minted: mintable, minted,
transactionId, 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 // Private
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -355,7 +493,7 @@ async function _mintAvailable(
mintInstance: Mint, mintInstance: Mint,
quoteResponse: any, quoteResponse: any,
mintable: number, mintable: number,
): Promise<number> { ): Promise<{transactionId: number; minted: number}> {
const {quote, counterIndex} = row const {quote, counterIndex} = row
const unit = row.unit as MintUnit const unit = row.unit as MintUnit
@@ -458,11 +596,11 @@ async function _mintAvailable(
mintedAmount, 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 * 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 * new receipt and gets its own transaction — an amount that mutates after
@@ -477,15 +615,23 @@ async function _findOrCreateTransaction(
): Promise<Transaction> { ): Promise<Transaction> {
const last = transactionsStore.findLastBy({quote: row.quote}) const last = transactionsStore.findLastBy({quote: row.quote})
// PREPARED counts as reusable, not just PENDING. createQuote passes through it on // PREPARED and ERROR count as reusable, not just PENDING.
// 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 // PREPARED: createQuote passes through it on the way to PENDING, so a crash in
// rows for one deposit. // 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 ( if (
last && last &&
last.type === TransactionType.TOPUP_ONCHAIN && last.type === TransactionType.TOPUP_ONCHAIN &&
(last.status === TransactionStatus.PENDING || (last.status === TransactionStatus.PENDING ||
last.status === TransactionStatus.PREPARED) last.status === TransactionStatus.PREPARED ||
last.status === TransactionStatus.ERROR)
) { ) {
return last return last
} }
@@ -538,4 +684,5 @@ function _parseData(tx: Transaction): TransactionData[] {
export const OnchainTopupOperationApi = { export const OnchainTopupOperationApi = {
createQuote, createQuote,
refreshQuote, refreshQuote,
recoverQuote,
} }