diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt index a75b8491bd..07087d008e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt @@ -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 = diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt index a504e2f018..4c21f22a5d 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt @@ -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() 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>(perBatchSize) val outputDtos = ArrayList(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 = splitAmountIntoDenominations(amount)