fix(cashu): scratchpad Bdhke.blind + hashToCurve — covers restore path

Latest crash log showed no CashuTrace markers between the mint HTTP
response and the SIGSEGV — the tracing was only around swapToLocked
and unblindAll. The crash is in the NUT-09 restore loop, which calls
Bdhke.blind hundreds of times per batch (one per counter slot) and
each blind call still allocated ~5 short-lived Fe4 / MutablePoint
holders inside hashToCurve + the blind step itself.

Same JIT-bug shape as unblind had. Same fix: pre-allocate the holders
inside BdhkeScratchpad, pass it to blind / hashToCurveInto so the
methods write through caller-owned holders instead of allocating
fresh ones. Adds 6 fields to the scratchpad (kept distinct from the
unblind ones so future nesting can't silently overwrite live state).

Wired:
- CashuMintOperations.restore: one batchScratch per HTTP batch,
  reused across all per-counter Bdhke.blind calls in that batch.
- secretOutputsFor: one scratch per call, reused across the
  mapIndexed loop.
- Pre-existing scratchpads in unblindAll, melt-change loop, NUT-09
  restore unblind loop unchanged.

Added a `restore: batch counter=N size=M denoms=K` trace line so
the next failure (if any) points at restore unambiguously.

Tests: 17/17 in BdhkeTest still pass (spec vector + round-trips).
This commit is contained in:
Claude
2026-05-28 00:41:57 +00:00
parent 35c260f572
commit b20bb4e1d0
3 changed files with 59 additions and 19 deletions
@@ -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)
}
/**
@@ -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()
}
@@ -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<String, CounterMaterials>(effectiveBatchSize)
val recoveredCounters = HashSet<Long>(effectiveBatchSize)
val outputDtos = ArrayList<BlindedMessageDto>(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)
}
}