diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/Bdhke.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/Bdhke.kt index 08723df652..16413e057c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/Bdhke.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/Bdhke.kt @@ -66,7 +66,20 @@ object Bdhke { * Internal because [MutablePoint] is internal to the Quartz crypto package. * External callers should use [hashToCurveCompressed] to get a 33-byte point. */ - internal fun hashToCurve(x: ByteArray): MutablePoint { + internal fun hashToCurve(x: ByteArray): MutablePoint = hashToCurveInto(x, BdhkeScratchpad()) + + /** + * Allocation-free [hashToCurve] variant — writes the resulting + * affine point into [scratch.blindPointY] using + * [scratch.blindFe4X] / [scratch.blindFe4Y] / [scratch.blindFe4Scalar] + * as inner scratch, then returns that same shared point. The caller + * must consume the result before the next BDHKE operation on the + * same scratchpad — see [BdhkeScratchpad] for the contract. + */ + internal fun hashToCurveInto( + x: ByteArray, + scratch: BdhkeScratchpad, + ): MutablePoint { val msgToHash = sha256(DOMAIN_SEPARATOR + x) val buf = ByteArray(36) msgToHash.copyInto(buf, 0) @@ -79,13 +92,10 @@ object Bdhke { buf[35] = ((counter ushr 24) and 0xFF).toByte() val candidate = sha256(buf) - val x4 = U256.fromBytes(candidate) - val outX = Fe4() - val outY = Fe4() - if (KeyCodec.liftX(outX, outY, x4)) { - val point = MutablePoint() - point.setAffine(outX, outY) - return point + U256.fromBytesInto(scratch.blindFe4Scalar, candidate, 0) + if (KeyCodec.liftX(scratch.blindFe4X, scratch.blindFe4Y, scratch.blindFe4Scalar)) { + scratch.blindPointY.setAffine(scratch.blindFe4X, scratch.blindFe4Y) + return scratch.blindPointY } counter++ } @@ -107,20 +117,30 @@ object Bdhke { fun blind( secret: ByteArray, r: ByteArray, + ): ByteArray = blind(secret, r, BdhkeScratchpad()) + + /** + * Allocation-free [blind] variant — same ART JIT escape-analysis + * mitigation as [unblind]. A hot loop like NUT-09 restore runs + * [blind] hundreds of times in sequence; the scratchpad shaves + * ~5 allocations off each iteration AND keeps the JIT from + * crashing on the (Android 15+ ART) bug. + */ + fun blind( + secret: ByteArray, + r: ByteArray, + scratch: BdhkeScratchpad, ): ByteArray { require(r.size == 32) { "Blinding factor must be 32 bytes" } require(Secp256k1.secKeyVerify(r)) { "Invalid blinding factor" } - val y = hashToCurve(secret) - val rg = MutablePoint() - val rScalar = Fe4() - U256.fromBytesInto(rScalar, r, 0) - ECPoint.mulG(rg, rScalar) + val y = hashToCurveInto(secret, scratch) + U256.fromBytesInto(scratch.blindFe4Scalar, r, 0) + ECPoint.mulG(scratch.blindPointRg, scratch.blindFe4Scalar) - val out = MutablePoint() - ECPoint.addPoints(out, y, rg) + ECPoint.addPoints(scratch.blindPointOut, y, scratch.blindPointRg) - return toCompressed(out) + return toCompressed(scratch.blindPointOut) } /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.kt index 456979a09b..1ee3ec00ab 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.kt @@ -57,6 +57,7 @@ import com.vitorpamplona.quartz.utils.secp256k1.MutablePoint * allocate their own. The cost is negligible (~10 small objects). */ class BdhkeScratchpad { + // Holders used by [Bdhke.unblind]. internal val fe4A: Fe4 = Fe4() internal val fe4B: Fe4 = Fe4() internal val fe4C: Fe4 = Fe4() @@ -69,4 +70,16 @@ class BdhkeScratchpad { internal val pointC: MutablePoint = MutablePoint() internal val pointD: MutablePoint = MutablePoint() internal val outPoint: MutablePoint = MutablePoint() + + // Holders used by [Bdhke.blind] and [Bdhke.hashToCurve]. + // Kept distinct from the unblind holders so a future refactor that + // calls blind from inside unblind (or vice versa) doesn't silently + // overwrite live state. Currently the operations don't nest, but + // the separation makes the safety invariant local. + internal val blindFe4X: Fe4 = Fe4() + internal val blindFe4Y: Fe4 = Fe4() + internal val blindFe4Scalar: Fe4 = Fe4() + internal val blindPointY: MutablePoint = MutablePoint() + internal val blindPointRg: MutablePoint = MutablePoint() + internal val blindPointOut: MutablePoint = MutablePoint() } 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 bf73e0e128..b5dc93a14a 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 @@ -433,9 +433,16 @@ class CashuMintOperations( // deterministic (secret, r) pair derived from the counter), // not per (counter, amount) — the wallet only ever minted // one denomination per counter under NUT-13. + Log.i("CashuTrace") { "restore: batch counter=$counter size=$effectiveBatchSize denoms=${denominations.size}" } val matsByBTick = HashMap(effectiveBatchSize) val recoveredCounters = HashSet(effectiveBatchSize) val outputDtos = ArrayList(perBatchSize) + // One scratchpad reused across all per-counter Bdhke.blind + // calls in this batch — see [BdhkeScratchpad]. Critical for + // restore: without it we'd allocate ~6 short-lived holders + // per counter × batchSize counters per batch and trip the + // Android 15+ ART JIT escape-analysis crash. + val batchScratch = BdhkeScratchpad() for (offset in 0 until effectiveBatchSize) { val c = counter + offset @@ -447,7 +454,7 @@ class CashuMintOperations( val secretBytes = CashuDeterministic.secretBytes(seed, keysetId, c) val r = CashuDeterministic.blindingFactor(seed, keysetId, c) val secretHex = secretBytes.toHexKey() - val bTick = Bdhke.blind(secretHex.encodeToByteArray(), r) + val bTick = Bdhke.blind(secretHex.encodeToByteArray(), r, batchScratch) val bTickHex = bTick.toHexKey() matsByBTick[bTickHex] = CounterMaterials(c, secretHex, r, bTick) for (amount in denominations) { @@ -615,10 +622,10 @@ class CashuMintOperations( // NUT-13-derived from a wallet seed; either way the on-wire shape // is identical so the mint can't tell which scheme we're using. val derived = secretFactory.nextSecrets(keyset.id, amounts.size) + val scratch = BdhkeScratchpad() return amounts.mapIndexed { i, amount -> - Log.i("CashuTrace") { " Bdhke.blind[$i/${amounts.size}] amount=$amount" } val pair = derived[i] - val bTick = Bdhke.blind(pair.secretHex.encodeToByteArray(), pair.blindingFactor) + val bTick = Bdhke.blind(pair.secretHex.encodeToByteArray(), pair.blindingFactor, scratch) BlindOutput(amount, keyset.id, pair.blindingFactor, pair.secretHex, bTick) } }