From 56e5aea79fb4d9add9384f9147dff92b604e0a63 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 01:23:48 +0000 Subject: [PATCH] fix(cashu): scratchpad toCompressed + deep Bdhke tracing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous round of scratchpadding still left two Fe4 allocations per call inside toCompressed — the final point→bytes step at the tail of every blind / unblind / addRTimesA. Across an NUT-09 restore sweep that's ~250+ Fe4 allocations the ART JIT optimizer still gets to chew on. After ART inlines toCompressed back into the outer crypto bodies, those allocations end up in the same hot method bodies the scratchpad was supposed to clear out. Adds a [toCompressedScratch] variant that reuses two new holders on [BdhkeScratchpad], and routes every hot-path caller through it (unblind, blind, addRTimesA, hashToCurveCompressed). The plain [toCompressed] stays for the cold paths (sign, signFull, others only used by tests). Also adds verbose BdhkeTrace / CashuTrace markers in: - Bdhke.blind: secKeyVerify → hashToCurveInto → mulG → addPoints → toCompressedScratch - Bdhke.unblind: parseAffinePoint cTick → parseAffinePoint k → computeNegRk → addPoints → toCompressedScratch - restore inner loop: per-counter (CashuDeterministic → Bdhke.blind → build dtos), HTTP, per-signature unblind begin/end So when the next JIT crash happens (if it does), the last log line points at the exact sub-step ART was compiling. Logs are at INFO level; cheap enough to leave on while diagnosing, easy to strip afterward. --- .../quartz/nip60Cashu/bdhke/Bdhke.kt | 44 +++++++++++++++++-- .../nip60Cashu/bdhke/BdhkeScratchpad.kt | 10 +++++ .../nip60Cashu/mintApi/CashuMintOperations.kt | 7 +++ 3 files changed, 57 insertions(+), 4 deletions(-) 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 e4dab770d9..93630fd391 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 @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip60Cashu.bdhke import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.secp256k1.ECPoint import com.vitorpamplona.quartz.utils.secp256k1.Fe4 @@ -116,7 +117,7 @@ object Bdhke { fun hashToCurveCompressed( x: ByteArray, scratch: BdhkeScratchpad, - ): ByteArray = toCompressed(hashToCurveInto(x, scratch)) + ): ByteArray = toCompressedScratch(hashToCurveInto(x, scratch), scratch) /** * Step 1 of BDHKE — Alice creates a blinded message. @@ -142,16 +143,25 @@ object Bdhke { r: ByteArray, scratch: BdhkeScratchpad, ): ByteArray { + Log.i("BdhkeTrace") { " blind enter (secret=${secret.size}b r=${r.size}b)" } require(r.size == 32) { "Blinding factor must be 32 bytes" } + Log.i("BdhkeTrace") { " blind: Secp256k1.secKeyVerify" } require(Secp256k1.secKeyVerify(r)) { "Invalid blinding factor" } + Log.i("BdhkeTrace") { " blind: hashToCurveInto" } val y = hashToCurveInto(secret, scratch) + Log.i("BdhkeTrace") { " blind: U256.fromBytesInto" } U256.fromBytesInto(scratch.blindFe4Scalar, r, 0) + Log.i("BdhkeTrace") { " blind: ECPoint.mulG" } ECPoint.mulG(scratch.blindPointRg, scratch.blindFe4Scalar) + Log.i("BdhkeTrace") { " blind: ECPoint.addPoints" } ECPoint.addPoints(scratch.blindPointOut, y, scratch.blindPointRg) - return toCompressed(scratch.blindPointOut) + Log.i("BdhkeTrace") { " blind: toCompressedScratch" } + val out = toCompressedScratch(scratch.blindPointOut, scratch) + Log.i("BdhkeTrace") { " blind exit" } + return out } /** @@ -207,13 +217,21 @@ object Bdhke { mintPubKey: ByteArray, scratch: BdhkeScratchpad, ): ByteArray { + Log.i("BdhkeTrace") { " unblind enter" } require(r.size == 32) { "Blinding factor must be 32 bytes" } + Log.i("BdhkeTrace") { " unblind: parseAffinePointInto cTick" } val cTick = parseAffinePointInto(blindSignature, "blind signature", scratch.fe4A, scratch.fe4B, scratch.pointA) + Log.i("BdhkeTrace") { " unblind: parseAffinePointInto k" } val k = parseAffinePointInto(mintPubKey, "mint public key", scratch.fe4C, scratch.fe4D, scratch.pointB) + Log.i("BdhkeTrace") { " unblind: computeNegRkInto" } val negRk = computeNegRkInto(k, r, scratch.fe4E, scratch.pointC, scratch.fe4F, scratch.fe4G, scratch.pointD) + Log.i("BdhkeTrace") { " unblind: ECPoint.addPoints" } ECPoint.addPoints(scratch.outPoint, cTick, negRk) - return toCompressed(scratch.outPoint) + Log.i("BdhkeTrace") { " unblind: toCompressedScratch" } + val out = toCompressedScratch(scratch.outPoint, scratch) + Log.i("BdhkeTrace") { " unblind exit" } + return out } /** Parse a 33-byte compressed pubkey into [outPoint], using [xHolder]/[yHolder] as scratch. */ @@ -525,7 +543,7 @@ object Bdhke { ECPoint.mul(scratch.pointC, scratch.pointB, scratch.fe4E) ECPoint.addPoints(scratch.outPoint, scratch.pointA, scratch.pointC) - return toCompressed(scratch.outPoint) + return toCompressedScratch(scratch.outPoint, scratch) } /** @@ -610,6 +628,24 @@ object Bdhke { return KeyCodec.serializeCompressed(x, y) } + /** + * Allocation-free [toCompressed] — reuses [scratch.toCompressedFe4X] / + * [scratch.toCompressedFe4Y] as scratch holders. The returned + * ByteArray comes from [KeyCodec.serializeCompressed] which always + * allocates fresh, so the result is independent of the scratchpad. + * + * Used at the tail of every hot crypto op (blind / unblind / + * addRTimesA) so the final point-serialization step doesn't + * sneak 2 Fe4 allocations back into the inlined body. + */ + private fun toCompressedScratch( + p: MutablePoint, + scratch: BdhkeScratchpad, + ): ByteArray { + require(ECPoint.toAffine(p, scratch.toCompressedFe4X, scratch.toCompressedFe4Y)) { "Point is at infinity" } + return KeyCodec.serializeCompressed(scratch.toCompressedFe4X, scratch.toCompressedFe4Y) + } + /** * 65-byte uncompressed form `04 || X || Y`. Used only by the * NUT-12 hash input — the on-wire form for everything else is 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 60af87ce86..3e7e11f3fa 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 @@ -109,4 +109,14 @@ class BdhkeScratchpad { internal val verifyPointNegEc: MutablePoint = MutablePoint() internal val verifyPointR2: MutablePoint = MutablePoint() internal val verifyPointTmp: MutablePoint = MutablePoint() + + // Holders used by [Bdhke.toCompressed] (and its OrNull variant). + // These are reused at the END of every blind / unblind / addRTimesA + // call to convert the result MutablePoint back into a 33-byte + // compressed ByteArray. Hot loops (NUT-09 restore, NUT-07 scrub) + // call this hundreds of times, so the per-call 2-Fe4 allocation + // adds up to the JIT-bug threshold once ART inlines it into the + // outer crypto bodies. + internal val toCompressedFe4X: Fe4 = Fe4() + internal val toCompressedFe4Y: Fe4 = Fe4() } 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 5562b654dd..bec87c1aea 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 @@ -446,6 +446,7 @@ class CashuMintOperations( for (offset in 0 until effectiveBatchSize) { val c = counter + offset + Log.i("CashuTrace") { "restore.derive c=$c: CashuDeterministic" } // Per-counter derivation: same (secret, r) pair the // wallet would have minted at this counter slot. We // try each amount denomination — the mint will only @@ -454,7 +455,9 @@ class CashuMintOperations( val secretBytes = CashuDeterministic.secretBytes(seed, keysetId, c) val r = CashuDeterministic.blindingFactor(seed, keysetId, c) val secretHex = secretBytes.toHexKey() + Log.i("CashuTrace") { "restore.derive c=$c: Bdhke.blind" } val bTick = Bdhke.blind(secretHex.encodeToByteArray(), r, batchScratch) + Log.i("CashuTrace") { "restore.derive c=$c: build dtos for ${denominations.size} denoms" } val bTickHex = bTick.toHexKey() matsByBTick[bTickHex] = CounterMaterials(c, secretHex, r, bTick) for (amount in denominations) { @@ -462,7 +465,9 @@ class CashuMintOperations( } } + Log.i("CashuTrace") { "restore: HTTP /v1/restore (request ${outputDtos.size} outputs)" } val response = client.restore(RestoreRequestDto(outputs = outputDtos)) + Log.i("CashuTrace") { "restore: HTTP response sigs=${response.signatures.size} echoes=${response.outputs.size}" } if (response.signatures.isEmpty()) { emptyStreak++ } else { @@ -476,6 +481,7 @@ class CashuMintOperations( // is the source of truth, the echoed amount is not. val restoreScratch = BdhkeScratchpad() for (i in response.signatures.indices) { + Log.i("CashuTrace") { "restore.unblind[$i/${response.signatures.size}] begin" } val echo = response.outputs.getOrNull(i) ?: continue val sig = response.signatures[i] val mat = matsByBTick[echo.bTick] ?: continue @@ -485,6 +491,7 @@ class CashuMintOperations( if (!recoveredCounters.add(mat.counter)) continue val output = BlindOutput(sig.amount, keysetId, mat.r, mat.secretHex, mat.bTick) val proof = unblindOne(output, sig, keyset, restoreScratch) + Log.i("CashuTrace") { "restore.unblind[$i/${response.signatures.size}] end (counter=${mat.counter} amt=${sig.amount})" } recovered += RecoveredProof(proof, mat.counter) if (mat.counter > highestSeenCounter) highestSeenCounter = mat.counter }