fix(cashu): scratchpad toCompressed + deep Bdhke tracing

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.
This commit is contained in:
Claude
2026-05-28 01:23:48 +00:00
parent 509cbcb297
commit 56e5aea79f
3 changed files with 57 additions and 4 deletions
@@ -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
@@ -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()
}
@@ -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
}