fix(cashu): split Bdhke.unblind into smaller helpers — dodge ART JIT bug

CashuTrace showed the Android 15+ JIT compiler crash (SIGSEGV 0x48
in "Jit thread pool") firing while ART was compiling Bdhke.unblind:

  unblindOne[0/2] begin amount=2
    unblindOne: Bdhke.unblind begin
  >>> Fatal signal 11 (SIGSEGV) <<<  in Jit thread pool
    unblindOne: Bdhke.unblind end       ← app thread keeps running
  unblindOne[0/2] end
  unblindOne[1/2] begin amount=8
    unblindOne: Bdhke.unblind begin
    unblindOne: Bdhke.unblind end
  unblindOne[1/2] end
  […300ms of other work, then process death tombstone…]

The unblind itself runs fine to completion (twice). The crash is the
JIT compiler thread choking on Bdhke.unblind's bytecode — specifically
the escape-analysis pass on the 10 short-lived Fe4 / MutablePoint
allocations packed into one ~30-line method body. ART decides the
process is unstable a few hundred ms later and tears it down.

Split the body into three: an orchestrator + parseAffinePoint() +
computeNegRk(). Same semantics, three smaller bytecode bodies for
the JIT to compile, each well under the threshold that triggers the
bad optimizer path.

If this still crashes, the trace will tell us which of the three
methods the JIT was compiling and we move to the next mitigation
(reusable per-thread buffer pools to drop allocations further).
This commit is contained in:
Claude
2026-05-27 23:45:22 +00:00
parent 5d64d3829e
commit 8d997a9973
@@ -144,32 +144,46 @@ object Bdhke {
): ByteArray {
require(r.size == 32) { "Blinding factor must be 32 bytes" }
val cTickX = Fe4()
val cTickY = Fe4()
require(KeyCodec.parsePublicKey(blindSignature, cTickX, cTickY)) { "Invalid blind signature" }
val cTick = MutablePoint().also { it.setAffine(cTickX, cTickY) }
// The body was previously inlined here as one ~30-line method
// with ~10 short-lived Fe4 / MutablePoint allocations. Android
// 15+ ART crashes (SIGSEGV at 0x48 in "Jit thread pool") when
// it tries to escape-analyze that exact allocation density.
// Splitting into three smaller methods gives the JIT three
// small bodies it can compile cleanly instead of one big one
// that triggers the optimizer bug.
val cTick = parseAffinePoint(blindSignature, "blind signature")
val k = parseAffinePoint(mintPubKey, "mint public key")
val negRk = computeNegRk(k, r)
val out = MutablePoint()
ECPoint.addPoints(out, cTick, negRk)
return toCompressed(out)
}
val kx = Fe4()
val ky = Fe4()
require(KeyCodec.parsePublicKey(mintPubKey, kx, ky)) { "Invalid mint public key" }
val k = MutablePoint().also { it.setAffine(kx, ky) }
/** Parse a 33-byte compressed pubkey into an affine [MutablePoint]. */
private fun parseAffinePoint(
compressed: ByteArray,
label: String,
): MutablePoint {
val px = Fe4()
val py = Fe4()
require(KeyCodec.parsePublicKey(compressed, px, py)) { "Invalid $label" }
return MutablePoint().also { it.setAffine(px, py) }
}
// r·K, then negate Y to get -r·K
/** Compute `-r·K` as an affine [MutablePoint]. */
private fun computeNegRk(
k: MutablePoint,
r: ByteArray,
): MutablePoint {
val rScalar = Fe4()
U256.fromBytesInto(rScalar, r, 0)
val rk = MutablePoint()
ECPoint.mul(rk, k, rScalar)
// Convert to affine first, then negate Y.
val rkX = Fe4()
val rkY = Fe4()
require(ECPoint.toAffine(rk, rkX, rkY)) { "rK is point at infinity" }
FieldP.neg(rkY, rkY)
val negRk = MutablePoint().also { it.setAffine(rkX, rkY) }
val out = MutablePoint()
ECPoint.addPoints(out, cTick, negRk)
return toCompressed(out)
return MutablePoint().also { it.setAffine(rkX, rkY) }
}
/**