mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
fix(cashu): cap NUT-09 restore request size to mint validation limit
The "Issuing proofs" dialog hung at 80% for 30 seconds and the mint returned "List should have at most 1000 items after validation, not 6300" on the resume-pending-invoice flow. Two compounding causes: 1. CashuMintOperations.restore packs `batchSize * denominations.size` blinded outputs into each /v1/restore body. The default batchSize=100 against a typical keyset with the full power-of-2 denomination set (~63 amounts) hits 6300 outputs per request — nutshell / CDK / minibits all enforce a 1000-item Pydantic cap and reject the body. Auto-clamp the effective batch size so total outputs stay under MAX_RESTORE_REQUEST_ITEMS (500, leaving headroom for the response doubling — the mint echoes outputs alongside signatures). Honours the caller's batchSize when it already fits. 2. recoverPreviouslyIssuedProofs (the "outputs already signed" fallback) was scanning every keyset denomination at every counter in the rewind window. But the prior /v1/mint/bolt11 reserved only `splitAmountIntoDenominations(amountSats).size` counters and the mint signed exactly one output per slot — so the relevant denoms are a handful, not 63. Pass `amounts = splitAmountIntoDenominations(amountSats)` plus a single-batch sweep (`batchSize = rewind`, `emptyBatchesToStop = 1`) so the recovery makes one HTTP call against ~3-6 denominations instead of 32 batches of 63.
This commit is contained in:
+31
-12
@@ -45,6 +45,7 @@ import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintQuoteBolt11ResponseDto
|
||||
import com.vitorpamplona.quartz.nip60Cashu.mintApi.ProofState
|
||||
import com.vitorpamplona.quartz.nip60Cashu.mintApi.RandomSecretFactory
|
||||
import com.vitorpamplona.quartz.nip60Cashu.mintApi.SecretFactory
|
||||
import com.vitorpamplona.quartz.nip60Cashu.mintApi.splitAmountIntoDenominations
|
||||
import com.vitorpamplona.quartz.nip60Cashu.p2pk.P2PK
|
||||
import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof
|
||||
@@ -269,7 +270,7 @@ class CashuWalletOps(
|
||||
if (isOutputsAlreadySignedError(e)) {
|
||||
// Mint already issued for this quote on a prior crashed
|
||||
// attempt. Recover via NUT-09 instead of bailing.
|
||||
recoverPreviouslyIssuedProofs(mintUrl, quoteEvent)
|
||||
recoverPreviouslyIssuedProofs(mintUrl, amountSats)
|
||||
?: throw MintProtocolException(
|
||||
"Mint already issued for this quote, but seed-based restore found no recoverable proofs",
|
||||
)
|
||||
@@ -313,12 +314,17 @@ class CashuWalletOps(
|
||||
|
||||
/**
|
||||
* NUT-09 fallback for the "outputs already signed" / "quote already
|
||||
* issued" mint response — re-derives our deterministic blinded
|
||||
* outputs from the seed and asks the mint which it has signed. The
|
||||
* scope is intentionally narrow: we only need the proofs the mint
|
||||
* issued for this quote, so start from the wallet's persisted
|
||||
* counter for the active keyset and let the gap-limit heuristic
|
||||
* inside [CashuMintOperations.restore] bound the work.
|
||||
* issued" mint response — re-derives the deterministic blinded
|
||||
* outputs from the seed and asks the mint which it has signed.
|
||||
*
|
||||
* Scope is intentionally narrow: the prior `/v1/mint/bolt11` call
|
||||
* reserved exactly `splitAmounts(amountSats).size` counters and
|
||||
* signed one output per slot, so we restrict the restore to
|
||||
* - those amount denominations (skips the ~63-denom fan-out that
|
||||
* would push request size past the mint's 1000-item cap), and
|
||||
* - one batch starting at `peekCashuCounter - DEFAULT_RESTORE_SCAN_BACK`
|
||||
* (the wallet reserved the counters before the failed call, so
|
||||
* the relevant slots sit just below the current high-water mark).
|
||||
*
|
||||
* Returns null when there's no seed yet (kind:17375 not decrypted)
|
||||
* or when the restore turns up no unspent proofs — caller surfaces
|
||||
@@ -326,17 +332,30 @@ class CashuWalletOps(
|
||||
*/
|
||||
private suspend fun recoverPreviouslyIssuedProofs(
|
||||
mintUrl: String,
|
||||
quoteEvent: CashuMintQuoteEvent,
|
||||
amountSats: Long,
|
||||
): TokenContent? {
|
||||
val seed = seedForRestore() ?: return null
|
||||
val mintOps = ops(mintUrl)
|
||||
val keysetId = mintOps.activeKeyset().id
|
||||
// Walk back the counter so the next derivation re-mints the same
|
||||
// B_ values the mint already has signatures for. Without rewinding,
|
||||
// we'd ask the mint about a fresh counter window it never saw.
|
||||
val counterBefore = peekCashuCounter(keysetId)
|
||||
val startCounter = (counterBefore - DEFAULT_RESTORE_SCAN_BACK).coerceAtLeast(0L)
|
||||
val restoreResult = mintOps.restore(seed = seed, keysetId = keysetId, startCounter = startCounter)
|
||||
// splitAmountIntoDenominations is what the mint flow itself used
|
||||
// to decide which amounts to ask /v1/mint/bolt11 for, so the
|
||||
// signatures the mint has correspond to exactly these denoms.
|
||||
val expectedDenoms = splitAmountIntoDenominations(amountSats).distinct()
|
||||
// One batch of DEFAULT_RESTORE_SCAN_BACK counters across just the
|
||||
// expected denoms is enough — the proofs we're looking for sit in
|
||||
// a contiguous window of `expectedDenoms.size` counters at the top
|
||||
// of [startCounter, counterBefore).
|
||||
val restoreResult =
|
||||
mintOps.restore(
|
||||
seed = seed,
|
||||
keysetId = keysetId,
|
||||
startCounter = startCounter,
|
||||
batchSize = DEFAULT_RESTORE_SCAN_BACK.toInt(),
|
||||
emptyBatchesToStop = 1,
|
||||
amounts = expectedDenoms,
|
||||
)
|
||||
if (restoreResult.proofs.isEmpty()) return null
|
||||
val states = mintOps.checkStates(restoreResult.proofs.map { it.proof })
|
||||
val unspent =
|
||||
|
||||
+25
-3
@@ -395,19 +395,32 @@ class CashuMintOperations(
|
||||
throw IllegalStateException("Keyset $keysetId exposes no amount denominations")
|
||||
}
|
||||
|
||||
// Cap each /v1/restore request at MAX_RESTORE_REQUEST_ITEMS outputs.
|
||||
// A keyset with the full power-of-2 denomination set (up to ~63 amounts)
|
||||
// multiplied by the default batchSize=100 produces 6300 outputs per
|
||||
// round-trip — far above the 1000-item validation cap that nutshell /
|
||||
// CDK enforce, which surfaces as "List should have at most 1000 items".
|
||||
// Honour the caller's batchSize when it already fits.
|
||||
val effectiveBatchSize =
|
||||
if (batchSize * denominations.size > MAX_RESTORE_REQUEST_ITEMS) {
|
||||
(MAX_RESTORE_REQUEST_ITEMS / denominations.size).coerceAtLeast(1)
|
||||
} else {
|
||||
batchSize
|
||||
}
|
||||
|
||||
val recovered = mutableListOf<RecoveredProof>()
|
||||
var counter = startCounter
|
||||
var emptyStreak = 0
|
||||
var highestSeenCounter = startCounter - 1
|
||||
|
||||
val perBatchSize = batchSize * denominations.size
|
||||
val perBatchSize = effectiveBatchSize * denominations.size
|
||||
while (emptyStreak < emptyBatchesToStop) {
|
||||
// Pre-sized collections — without these, the ArrayList /
|
||||
// HashMap resize ~10 times per 1000-output batch.
|
||||
val outputsByCounter = HashMap<String, Pair<Long, BlindOutput>>(perBatchSize)
|
||||
val outputDtos = ArrayList<BlindedMessageDto>(perBatchSize)
|
||||
|
||||
for (offset in 0 until batchSize) {
|
||||
for (offset in 0 until effectiveBatchSize) {
|
||||
val c = counter + offset
|
||||
// Per-counter derivation: same (secret, r) pair the
|
||||
// wallet would have minted at this counter slot. We try
|
||||
@@ -448,7 +461,7 @@ class CashuMintOperations(
|
||||
}
|
||||
}
|
||||
|
||||
counter += batchSize
|
||||
counter += effectiveBatchSize
|
||||
}
|
||||
|
||||
return RestoreResult(
|
||||
@@ -688,6 +701,15 @@ class CashuMintOperations(
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Upper bound on outputs per `/v1/restore` request body. The cashu
|
||||
* spec doesn't pin a value; nutshell + CDK + minibits all enforce
|
||||
* a 1000-item Pydantic cap. 500 leaves headroom for response
|
||||
* doubling (mint echoes outputs alongside signatures) and any
|
||||
* future tightening.
|
||||
*/
|
||||
const val MAX_RESTORE_REQUEST_ITEMS: Int = 500
|
||||
|
||||
/** Re-exported from [splitAmountIntoDenominations] for convenience. */
|
||||
fun splitAmounts(amount: Long): List<Long> = splitAmountIntoDenominations(amount)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user