mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+13
@@ -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()
|
||||
}
|
||||
|
||||
+10
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user