mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
Hotfix onchain minting, improve NWC compatibility
This commit is contained in:
@@ -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))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user