refactor(cashu): delete wrong-theory dodge scaffolding

Removes ~700 lines of code added across ~10 prior commits trying to dodge
the ART JIT crash from the wrong angle. With the real root cause fixed
upstream (uLtInline inline-expansion), none of this is needed.

Deleted:
- BdhkeScratchpad.kt + 3 platform actuals (apple/jvmAndroid/linux). The
  thread-local Fe4/MutablePoint pool was added under the belief that
  per-call allocation density was triggering an ART escape-analysis
  bug. It wasn't. The original Bdhke functions allocated ~5-10 small
  objects per call — well under any TLAB pressure threshold.
- Bdhke.warmup() and the 2048-cycle blind+unblind loop it ran. The
  warmup was justified by "force the JIT compile to happen during init
  where a crash isn't user-facing" — except the warmup itself was what
  triggered the crash. ~4 seconds of wasted startup CPU.
- MintApiSerializerWarmup.kt (kotlinx.serialization decoder warmup).
  Same wrong theory, same wasted startup work.
- The `scope.launch(Dispatchers.Default) { Bdhke.warmup(); ... }` block
  in CashuWalletState.start() that called both warmups.

Reverted:
- Bdhke.kt to its pre-scratchpad shape. Drops `hashToCurveInto`,
  `parseAffinePointInto`, `computeNegRkInto`, `negateInto`,
  `toUncompressedOrNullScratch`, `compressedToUncompressedScratch`,
  `toCompressedScratch`, `@Volatile warmupDone`, `fun warmup()`, and
  the `JIT_WARMUP_ITERATIONS` constant. Restores the simple
  fresh-allocations-per-call form of `hashToCurve`, `blind`,
  `unblind`, `verifyDleq`, `addRTimesA`.

Stripped from CashuMintOperations.kt:
- Four `Log.i("CashuTrace") { ... }` diagnostic lines in the restore
  loop, added to chase the wrong hypothesis.
- The `import com.vitorpamplona.quartz.utils.Log` they were the only
  user of.
- Five "Bdhke uses a thread-local scratchpad internally" comments that
  referenced the now-deleted scratchpad.
- The "easier on the ART JIT" rationale in the per-counter dedup
  comment, replaced with the actual NUT-09 §2 echo-semantics
  explanation. The dedup itself is a real algorithmic win (~378 → ~6
  unblinds per batch), kept.

Cleaned in CashuPreferences.kt:
- "ART JIT crash on Android 15+" example in the durability rationale,
  replaced with generic "OOM, signer dialog dismiss, unexpected
  process death." The durability point stands regardless of crash
  source.

Net: -704 / +117 lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-28 15:15:18 -04:00
co-authored by Claude Opus 4.7
parent 6b9573906b
commit ebf8f195e4
9 changed files with 111 additions and 698 deletions
@@ -44,10 +44,10 @@ import com.vitorpamplona.amethyst.Amethyst
*
* The default settings save path debounces writes by 1000 ms, which is
* exactly the race window between "we asked the mint to sign" and "the
* mint replied". A crash inside that window (ART JIT crash on Android
* 15+, signer dialog dismiss, OOM, etc.) loses the counter advance and
* makes the wallet unusable. This store writes via `commit = true` so
* each reservation is durable before the function returns.
* mint replied". A crash inside that window (OOM, signer dialog dismiss,
* unexpected process death) loses the counter advance and makes the
* wallet unusable. This store writes via `commit = true` so each
* reservation is durable before the function returns.
*
* # Layout
*
@@ -32,11 +32,9 @@ import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip60Cashu.bdhke.Bdhke
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
import com.vitorpamplona.quartz.nip60Cashu.mintApi.DeterministicSecretFactory
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MeltQuoteBolt11ResponseDto
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintApiSerializerWarmup
import com.vitorpamplona.quartz.nip60Cashu.mintApi.ProofState
import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent
import com.vitorpamplona.quartz.nip60Cashu.seed.CashuDeterministic
@@ -387,21 +385,6 @@ class CashuWalletState(
triggerAutoRedeem()
}
// JIT-warmup the BDHKE primitives + kotlinx.serialization
// decoders that the cashu flows hit hardest. On Android 15+
// these hot paths trigger an ART JIT optimizer crash (SIGSEGV
// in Jit thread pool) when compiled mid-restore with hundreds
// of allocations queued. Running them once during init forces
// the synchronous compile to happen here, where the user isn't
// waiting and a crash blast-radius is just a warmup failure
// logged once instead of a mid-recovery process death.
scope.launch(Dispatchers.Default) {
runCatching { Bdhke.warmup() }
.onFailure { Log.w("CashuWallet", "Bdhke warmup failed", it) }
runCatching { MintApiSerializerWarmup.warmup() }
.onFailure { Log.w("CashuWallet", "MintApi serializer warmup failed", it) }
}
// Keep the relay subscription in sync with the outbox set.
jobs +=
scope.launch(Dispatchers.IO) {
@@ -1,28 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip60Cashu.bdhke
/**
* Apple actual: fresh allocation per call. Cashu hot paths don't
* run in production on iOS — this implementation exists only to
* satisfy the expect declaration for the multiplatform compile.
*/
internal actual fun bdhkeScratchpad(): BdhkeScratchpad = BdhkeScratchpad()
@@ -21,7 +21,6 @@
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
@@ -32,7 +31,6 @@ import com.vitorpamplona.quartz.utils.secp256k1.ScalarN
import com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
import com.vitorpamplona.quartz.utils.secp256k1.U256
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlin.concurrent.Volatile
/**
* Blind Diffie-Hellman Key Exchange (BDHKE) — the cryptographic primitive
@@ -68,20 +66,7 @@ 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 = 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 {
internal fun hashToCurve(x: ByteArray): MutablePoint {
val msgToHash = sha256(DOMAIN_SEPARATOR + x)
val buf = ByteArray(36)
msgToHash.copyInto(buf, 0)
@@ -94,10 +79,13 @@ object Bdhke {
buf[35] = ((counter ushr 24) and 0xFF).toByte()
val candidate = sha256(buf)
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
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
}
counter++
}
@@ -106,24 +94,12 @@ object Bdhke {
/**
* Public form of [hashToCurve] that returns a 33-byte compressed point.
* Uses the per-thread scratchpad from [bdhkeScratchpad] so the call
* is allocation-free regardless of how the caller is structured.
*/
fun hashToCurveCompressed(x: ByteArray): ByteArray {
val scratch = bdhkeScratchpad()
return toCompressedScratch(hashToCurveInto(x, scratch), scratch)
}
fun hashToCurveCompressed(x: ByteArray): ByteArray = toCompressed(hashToCurve(x))
/**
* Step 1 of BDHKE — Alice creates a blinded message.
*
* Allocation-free hot path: pulls a per-thread scratchpad from
* [bdhkeScratchpad] and reuses it across every call on that
* thread. Important for NUT-09 restore which runs [blind]
* hundreds of times in sequence — without scratchpad reuse the
* ~10 short-lived Fe4 / MutablePoint allocations per call would
* trigger the Android 15+ ART JIT escape-analysis crash.
*
* @param secret 32-byte (or arbitrary length) message to be later unblinded.
* @param r 32-byte blinding factor (must be a valid scalar < n).
* @return 33-byte compressed point `B_ = hash_to_curve(secret) + r·G`.
@@ -132,15 +108,19 @@ object Bdhke {
secret: ByteArray,
r: ByteArray,
): ByteArray {
val scratch = bdhkeScratchpad()
require(r.size == 32) { "Blinding factor must be 32 bytes" }
require(Secp256k1.secKeyVerify(r)) { "Invalid blinding factor" }
val y = hashToCurveInto(secret, scratch)
U256.fromBytesInto(scratch.blindFe4Scalar, r, 0)
ECPoint.mulG(scratch.blindPointRg, scratch.blindFe4Scalar)
ECPoint.addPoints(scratch.blindPointOut, y, scratch.blindPointRg)
return toCompressedScratch(scratch.blindPointOut, scratch)
val y = hashToCurve(secret)
val rg = MutablePoint()
val rScalar = Fe4()
U256.fromBytesInto(rScalar, r, 0)
ECPoint.mulG(rg, rScalar)
val out = MutablePoint()
ECPoint.addPoints(out, y, rg)
return toCompressed(out)
}
/**
@@ -151,21 +131,6 @@ object Bdhke {
* Implemented as `C_ + (-r·K)` since we don't expose point subtraction
* directly. `-r·K` is computed by negating the scalar: `(n - r)·K`.
*
* Allocation-free hot path: pulls a per-thread scratchpad from
* [bdhkeScratchpad] and reuses its pre-allocated [Fe4] /
* [MutablePoint] holders across every call on that thread. The
* holders are fully overwritten on each call so re-use is safe.
*
* Why thread-local pooling instead of fresh allocations: Android
* 15+ ART crashes the JIT compiler (SIGSEGV at offset 0x48 in
* "Jit thread pool") when it tries to escape-analyze the original
* inlined unblind body — about 10 short-lived holders in one
* ~30-line method body. Pre-allocating them and storing the
* reference on a thread-local lets the JIT see the objects clearly
* escape (came from outside, returned from a function it can't
* inline through), so the scalarization pass that's actually
* buggy never runs.
*
* @param blindSignature 33-byte compressed `C_` returned by the mint.
* @param r 32-byte blinding factor used in [blind].
* @param mintPubKey 33-byte compressed `K = a·G` (the mint's public key
@@ -177,45 +142,34 @@ object Bdhke {
r: ByteArray,
mintPubKey: ByteArray,
): ByteArray {
val scratch = bdhkeScratchpad()
require(r.size == 32) { "Blinding factor must be 32 bytes" }
val cTick = parseAffinePointInto(blindSignature, "blind signature", scratch.fe4A, scratch.fe4B, scratch.pointA)
val k = parseAffinePointInto(mintPubKey, "mint public key", scratch.fe4C, scratch.fe4D, scratch.pointB)
val negRk = computeNegRkInto(k, r, scratch.fe4E, scratch.pointC, scratch.fe4F, scratch.fe4G, scratch.pointD)
ECPoint.addPoints(scratch.outPoint, cTick, negRk)
return toCompressedScratch(scratch.outPoint, scratch)
}
val cTickX = Fe4()
val cTickY = Fe4()
require(KeyCodec.parsePublicKey(blindSignature, cTickX, cTickY)) { "Invalid blind signature" }
val cTick = MutablePoint().also { it.setAffine(cTickX, cTickY) }
/** Parse a 33-byte compressed pubkey into [outPoint], using [xHolder]/[yHolder] as scratch. */
private fun parseAffinePointInto(
compressed: ByteArray,
label: String,
xHolder: Fe4,
yHolder: Fe4,
outPoint: MutablePoint,
): MutablePoint {
require(KeyCodec.parsePublicKey(compressed, xHolder, yHolder)) { "Invalid $label" }
outPoint.setAffine(xHolder, yHolder)
return outPoint
}
val kx = Fe4()
val ky = Fe4()
require(KeyCodec.parsePublicKey(mintPubKey, kx, ky)) { "Invalid mint public key" }
val k = MutablePoint().also { it.setAffine(kx, ky) }
/** Compute `-r·K` into [outPoint], using the remaining holders as scratch. */
private fun computeNegRkInto(
k: MutablePoint,
r: ByteArray,
rScalar: Fe4,
rkPoint: MutablePoint,
rkX: Fe4,
rkY: Fe4,
outPoint: MutablePoint,
): MutablePoint {
// r·K, then negate Y to get -r·K
val rScalar = Fe4()
U256.fromBytesInto(rScalar, r, 0)
ECPoint.mul(rkPoint, k, rScalar)
require(ECPoint.toAffine(rkPoint, rkX, rkY)) { "rK is point at infinity" }
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)
outPoint.setAffine(rkX, rkY)
return outPoint
val negRk = MutablePoint().also { it.setAffine(rkX, rkY) }
val out = MutablePoint()
ECPoint.addPoints(out, cTick, negRk)
return toCompressed(out)
}
/**
@@ -275,12 +229,6 @@ object Bdhke {
* outside [1, n), points not on curve) or DLEQ mismatch. Callers
* should treat false as a protocol violation and abort the swap/mint.
*
* Allocation-free hot path: pulls a per-thread scratchpad from
* [bdhkeScratchpad]. Carol-side verification runs once per
* inbound nutzap proof during auto-redeem, so this is on the hot
* path of every nutzap receive — same JIT-crash mitigation as
* [blind] and [unblind].
*
* @param e 32-byte challenge scalar from the mint.
* @param s 32-byte response scalar from the mint.
* @param blindedMessage 33-byte compressed `B_` we sent.
@@ -294,53 +242,72 @@ object Bdhke {
blindSignature: ByteArray,
mintPubKey: ByteArray,
): Boolean {
val scratch = bdhkeScratchpad()
if (e.size != 32 || s.size != 32) return false
if (blindedMessage.size != 33 || blindSignature.size != 33 || mintPubKey.size != 33) return false
U256.fromBytesInto(scratch.verifyFe4E, e, 0)
U256.fromBytesInto(scratch.verifyFe4S, s, 0)
val eScalar = Fe4()
U256.fromBytesInto(eScalar, e, 0)
val sScalar = Fe4()
U256.fromBytesInto(sScalar, s, 0)
// `s` is `r' + e*k mod n` by construction (NUT-12 §2), so it must
// be a canonical scalar < n; reject if not.
if (!ScalarN.isValid(scratch.verifyFe4S)) return false
if (!ScalarN.isValid(sScalar)) return false
// `e` is a SHA-256 hash output that NUT-12 treats as raw bytes,
// NOT a scalar mod n — there's no requirement that `e < n`. With
// probability ~2^-128 a valid proof has `e >= n`; rejecting on
// `isValid` here would spuriously fail those. We only check that
// `e` isn't zero (a zero `e` would let any junk signature pass
// because R1_check / R2_check would collapse to `sG` / `sB'`,
// independent of the mint's keyset key).
if (scratch.verifyFe4E.isZero()) return false
// independent of the mint's keyset key). Downstream point
// multiplications (`ECPoint.mul(_, A, eScalar)`) handle e >= n
// correctly via internal GLV reduction.
if (eScalar.isZero()) return false
if (!KeyCodec.parsePublicKey(mintPubKey, scratch.verifyFe4Ax, scratch.verifyFe4Ay)) return false
scratch.verifyPointA.setAffine(scratch.verifyFe4Ax, scratch.verifyFe4Ay)
val aX = Fe4()
val aY = Fe4()
if (!KeyCodec.parsePublicKey(mintPubKey, aX, aY)) return false
val aPoint = MutablePoint().also { it.setAffine(aX, aY) }
if (!KeyCodec.parsePublicKey(blindedMessage, scratch.verifyFe4Bx, scratch.verifyFe4By)) return false
scratch.verifyPointB.setAffine(scratch.verifyFe4Bx, scratch.verifyFe4By)
val bX = Fe4()
val bY = Fe4()
if (!KeyCodec.parsePublicKey(blindedMessage, bX, bY)) return false
val bPoint = MutablePoint().also { it.setAffine(bX, bY) }
if (!KeyCodec.parsePublicKey(blindSignature, scratch.verifyFe4Cx, scratch.verifyFe4Cy)) return false
scratch.verifyPointC.setAffine(scratch.verifyFe4Cx, scratch.verifyFe4Cy)
val cX = Fe4()
val cY = Fe4()
if (!KeyCodec.parsePublicKey(blindSignature, cX, cY)) return false
val cPoint = MutablePoint().also { it.setAffine(cX, cY) }
// R1 = sG - eA, R2 = sB' - eC'. Same affine-Y-flip negation
// pattern as [unblind]. Uses scratchpad holders throughout.
ECPoint.mulG(scratch.verifyPointSg, scratch.verifyFe4S)
ECPoint.mul(scratch.verifyPointEa, scratch.verifyPointA, scratch.verifyFe4E)
if (!negateInto(scratch.verifyPointEa, scratch.verifyPointNegEa, scratch.verifyFe4Ser, scratch.verifyFe4Ser2)) return false
ECPoint.addPoints(scratch.verifyPointR1, scratch.verifyPointSg, scratch.verifyPointNegEa)
// R1 = sG - eA, R2 = sB' - eC'. We use the unblind() pattern for
// point negation: compute the positive multiple, take affine, flip
// the Y coordinate. Cheaper than computing (n-e) and re-multiplying.
val sG = MutablePoint().also { ECPoint.mulG(it, sScalar) }
val eA = MutablePoint().also { ECPoint.mul(it, aPoint, eScalar) }
val negEa = negate(eA) ?: return false
val r1 = MutablePoint().also { ECPoint.addPoints(it, sG, negEa) }
ECPoint.mul(scratch.verifyPointSb, scratch.verifyPointB, scratch.verifyFe4S)
ECPoint.mul(scratch.verifyPointEc, scratch.verifyPointC, scratch.verifyFe4E)
if (!negateInto(scratch.verifyPointEc, scratch.verifyPointNegEc, scratch.verifyFe4Ser, scratch.verifyFe4Ser2)) return false
ECPoint.addPoints(scratch.verifyPointR2, scratch.verifyPointSb, scratch.verifyPointNegEc)
val sB = MutablePoint().also { ECPoint.mul(it, bPoint, sScalar) }
val eC = MutablePoint().also { ECPoint.mul(it, cPoint, eScalar) }
val negEc = negate(eC) ?: return false
val r2 = MutablePoint().also { ECPoint.addPoints(it, sB, negEc) }
// NUT-12 §2 hash input: sha256(utf8(hex(R1) || hex(R2) || hex(A) || hex(C')))
// where each hex is the 65-byte uncompressed form (04 || X || Y).
// See the original verifyDleq comment for the spec / reference-impl details.
val r1Uncompressed = toUncompressedOrNullScratch(scratch.verifyPointR1, scratch.verifyFe4Ser, scratch.verifyFe4Ser2) ?: return false
val r2Uncompressed = toUncompressedOrNullScratch(scratch.verifyPointR2, scratch.verifyFe4Ser, scratch.verifyFe4Ser2) ?: return false
val aUncompressed = compressedToUncompressedScratch(mintPubKey, scratch.verifyFe4Ser, scratch.verifyFe4Ser2) ?: return false
val cUncompressed = compressedToUncompressedScratch(blindSignature, scratch.verifyFe4Ser, scratch.verifyFe4Ser2) ?: return false
val r1Uncompressed = toUncompressedOrNull(r1) ?: return false
val r2Uncompressed = toUncompressedOrNull(r2) ?: return false
val aUncompressed = compressedToUncompressed(mintPubKey) ?: return false
val cUncompressed = compressedToUncompressed(blindSignature) ?: return false
// NUT-12 §2 hash input. The spec text says `sha256(R1 || R2 || A || C')`
// but the normative behaviour established by every reference impl
// (cashu-ts, nutshell, CDK) is:
// 1. Serialize each point as 65-byte UNCOMPRESSED form (04 || X || Y)
// 2. Hex-encode each (130 chars per point)
// 3. Concatenate the four hex strings (520 chars)
// 4. Hash the UTF-8 bytes of that concatenation
// Real mints emit `e` computed this way. Earlier versions of this
// code used raw 33-byte compressed bytes — round-tripping our own
// [signFull] hid the mismatch but every real mint rejected the
// proof. CDK's `hash_e` in crates/cashu/src/dhke.rs is the
// authoritative reference.
val hashInput =
(r1Uncompressed.toHexKey() + r2Uncompressed.toHexKey() + aUncompressed.toHexKey() + cUncompressed.toHexKey())
.encodeToByteArray()
@@ -349,36 +316,6 @@ object Bdhke {
return computed.contentEquals(e)
}
/** Allocation-free [negate] — writes into [out], using xHolder / yHolder as scratch. */
private fun negateInto(
p: MutablePoint,
out: MutablePoint,
xHolder: Fe4,
yHolder: Fe4,
): Boolean {
if (!ECPoint.toAffine(p, xHolder, yHolder)) return false
FieldP.neg(yHolder, yHolder)
out.setAffine(xHolder, yHolder)
return true
}
/** Allocation-free [toUncompressedOrNull] — returns the fresh ByteArray, scratch holders reused. */
private fun toUncompressedOrNullScratch(
p: MutablePoint,
xHolder: Fe4,
yHolder: Fe4,
): ByteArray? = if (ECPoint.toAffine(p, xHolder, yHolder)) KeyCodec.serializeUncompressed(xHolder, yHolder) else null
/** Allocation-free [compressedToUncompressed] — returns the fresh ByteArray, scratch holders reused. */
private fun compressedToUncompressedScratch(
compressed: ByteArray,
xHolder: Fe4,
yHolder: Fe4,
): ByteArray? {
if (!KeyCodec.parsePublicKey(compressed, xHolder, yHolder)) return null
return KeyCodec.serializeUncompressed(xHolder, yHolder)
}
/**
* NUT-12 §3 Carol-side DLEQ verification.
*
@@ -416,16 +353,6 @@ object Bdhke {
* @param unblindedC 33-byte `C` from the proof — the unblinded
* signature the wallet stores after Alice's
* [unblind].
* Allocation-free hot path: pulls a per-thread scratchpad from
* [bdhkeScratchpad]. The Carol pipeline is the heaviest in BDHKE
* (one blind + one addRTimesA + one verifyDleq per call) so a
* hot loop here without sharing would burn ~40 short-lived
* holders per proof — exactly the shape that triggers the
* Android 15+ ART JIT crash. Each nested call grabs the same
* thread-local scratchpad; the holder sets used by [blind],
* [addRTimesA], and [verifyDleq] are disjoint so nested-on-same-
* scratchpad is safe.
*
* @param mintPubKey 33-byte compressed `A = k·G` for this amount,
* looked up from the mint's keyset.
*/
@@ -466,21 +393,24 @@ object Bdhke {
r: ByteArray,
mintPubKey: ByteArray,
): ByteArray {
val scratch = bdhkeScratchpad()
// Reuses unblind's holder set — addRTimesA is only called from
// verifyDleqCarol, which calls verifyDleq AFTER this one. The
// unblind holders are free for the duration of this call.
require(KeyCodec.parsePublicKey(c, scratch.fe4A, scratch.fe4B)) { "Invalid C" }
scratch.pointA.setAffine(scratch.fe4A, scratch.fe4B)
val cX = Fe4()
val cY = Fe4()
require(KeyCodec.parsePublicKey(c, cX, cY)) { "Invalid C" }
val cPoint = MutablePoint().also { it.setAffine(cX, cY) }
require(KeyCodec.parsePublicKey(mintPubKey, scratch.fe4C, scratch.fe4D)) { "Invalid mint public key" }
scratch.pointB.setAffine(scratch.fe4C, scratch.fe4D)
val kx = Fe4()
val ky = Fe4()
require(KeyCodec.parsePublicKey(mintPubKey, kx, ky)) { "Invalid mint public key" }
val k = MutablePoint().also { it.setAffine(kx, ky) }
U256.fromBytesInto(scratch.fe4E, r, 0)
ECPoint.mul(scratch.pointC, scratch.pointB, scratch.fe4E)
val rScalar = Fe4()
U256.fromBytesInto(rScalar, r, 0)
val rk = MutablePoint()
ECPoint.mul(rk, k, rScalar)
ECPoint.addPoints(scratch.outPoint, scratch.pointA, scratch.pointC)
return toCompressedScratch(scratch.outPoint, scratch)
val out = MutablePoint()
ECPoint.addPoints(out, cPoint, rk)
return toCompressed(out)
}
/**
@@ -558,100 +488,6 @@ object Bdhke {
*/
fun randomSecret(): ByteArray = randomScalar()
/**
* Tracks whether [warmup] has already been invoked this process.
* At-most-once: the FIRST caller does the JIT-warming work; every
* subsequent caller (including parallel ones from different
* accounts' [com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState.start])
* sees the flag and returns immediately.
*
* Multiple warmups running concurrently was itself a crash trigger
* — two accounts each doing 32 blind+unblind cycles on
* Dispatchers.Default at startup put ~128 concurrent BDHKE calls
* in flight, contending for the JIT compiler and reproducing the
* Android 15+ ART optimizer bug we were trying to dodge.
*
* Plain @Volatile + check is sufficient — the harm from a tiny
* race window (two callers both seeing `false` before either
* flips the flag) is one extra 32-cycle warmup, not a correctness
* issue. We avoid `synchronized` to stay commonMain-portable.
*/
@Volatile
private var warmupDone: Boolean = false
/**
* Pre-warm the JIT for [blind] / [unblind] by running them N times
* with synthetic data. Forces ART's optimizing compiler to do its
* work during app init (low contention, no user waiting) instead
* of mid-restore where the synchronous compile pause is visible
* (~13 ms gap inside the loop) and a crash in the JIT thread tears
* the whole process down.
*
* Called from `CashuWalletState.start()` on a background coroutine.
* At-most-once per process — see [warmupDone].
*
* The synthetic data uses a fixed mint pubkey / random blinding
* factors. It does NOT touch any wallet state or network.
*/
fun warmup() {
if (warmupDone) return
warmupDone = true
Log.i("CashuTrace") { "Bdhke.warmup: begin" }
// Fixed public key for warmup — generator point G's compressed form.
// G is always on the curve and parses cleanly; nothing we do
// here leaks into wallet state.
val mintPubKey =
byteArrayOf(
0x02.toByte(),
0x79.toByte(),
0xBE.toByte(),
0x66.toByte(),
0x7E.toByte(),
0xF9.toByte(),
0xDC.toByte(),
0xBB.toByte(),
0xAC.toByte(),
0x55.toByte(),
0xA0.toByte(),
0x62.toByte(),
0x95.toByte(),
0xCE.toByte(),
0x87.toByte(),
0x0B.toByte(),
0x07.toByte(),
0x02.toByte(),
0x9B.toByte(),
0xFC.toByte(),
0xDB.toByte(),
0x2D.toByte(),
0xCE.toByte(),
0x28.toByte(),
0xD9.toByte(),
0x59.toByte(),
0xF2.toByte(),
0x81.toByte(),
0x5B.toByte(),
0x16.toByte(),
0xF8.toByte(),
0x17.toByte(),
0x98.toByte(),
)
// ART JIT typically tier-1 compiles after ~10 invocations on
// Android 15+. 32 iterations is enough headroom that both blind
// and unblind reach the optimized tier before user-facing calls.
repeat(JIT_WARMUP_ITERATIONS) {
val secret = randomScalar()
val r = randomScalar()
val bTick = blind(secret, r)
// We don't have a real mint signature to unblind, but `blind`'s
// own output is a valid curve point that unblind will process
// identically from the JIT's perspective (same code paths, same
// allocations) — the math result is meaningless and discarded.
unblind(bTick, r, mintPubKey)
}
Log.i("CashuTrace") { "Bdhke.warmup: end" }
}
private fun toCompressed(p: MutablePoint): ByteArray {
val x = Fe4()
val y = Fe4()
@@ -659,24 +495,6 @@ 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
@@ -720,21 +538,4 @@ object Bdhke {
// Spec doesn't bound this; in practice the first iteration succeeds with
// probability ~1/2. We cap at 2^16 — astronomically unlikely to hit.
private const val MAX_HASH_TO_CURVE_ITERATIONS = 65536
/**
* How many times [warmup] exercises [blind] / [unblind].
*
* 32 iterations was enough for ART tier-0 (baseline) but NOT
* tier-1 (optimizing). The diagnostic logs showed batches 1-3
* completing fine, then batch 4 crashing — exactly when tier-1
* compile triggers on [Bdhke.unblind] (~21 production unblinds
* crossed the optimizer threshold). Bump to 2048 so tier-1
* fires during the background warmup coroutine where a crash
* would be visible but not user-facing.
*
* ~1ms per blind+unblind cycle on a mid-range Android 15 device
* → ~4 seconds of background warmup at app start. Acceptable for
* the protection it provides.
*/
private const val JIT_WARMUP_ITERATIONS = 2048
}
@@ -1,138 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip60Cashu.bdhke
import com.vitorpamplona.quartz.utils.secp256k1.Fe4
import com.vitorpamplona.quartz.utils.secp256k1.MutablePoint
/**
* Pre-allocated holders the BDHKE crypto primitives re-use across calls.
*
* # Why
*
* Android 15+ ART JIT compiler crashes (SIGSEGV at offset 0x48 in
* "Jit thread pool") when it escape-analyzes a method body that
* allocates many short-lived [Fe4] / [MutablePoint] holders in tight
* sequence. [Bdhke.unblind] used to do that — about 10 such
* allocations per call. Splitting into smaller methods didn't help
* (ART's inliner re-merges them at compile time).
*
* Passing the holders in as parameters dodges the bug. The JIT sees
* the objects come from outside, so its (buggy) scalarization pass
* doesn't run on them. Side benefit: a hot loop like NUT-09 restore
* (hundreds of unblinds per sweep) now does one [BdhkeScratchpad]
* allocation total instead of ~10 per iteration.
*
* # Safety
*
* All holders are fully overwritten on each crypto call —
* `KeyCodec.parsePublicKey`, `U256.fromBytesInto`, `ECPoint.mul`,
* `ECPoint.toAffine`, `FieldP.neg`, `MutablePoint.setAffine` all
* write through every field they use. So reusing one scratchpad
* across many sequential calls in the same thread is safe even if
* earlier content was different.
*
* # Thread safety
*
* [BdhkeScratchpad] is NOT thread-safe. One scratchpad per caller
* is the contract. Two coroutines on different threads must each
* 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()
internal val fe4D: Fe4 = Fe4()
internal val fe4E: Fe4 = Fe4()
internal val fe4F: Fe4 = Fe4()
internal val fe4G: Fe4 = Fe4()
internal val pointA: MutablePoint = MutablePoint()
internal val pointB: MutablePoint = MutablePoint()
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()
// Holders used by [Bdhke.verifyDleq] + [Bdhke.verifyDleqCarol] +
// [Bdhke.addRTimesA]. The Carol path runs once per inbound nutzap
// or cashu-token proof during redeem, so it lives on the hot
// path of receive flows.
internal val verifyFe4E: Fe4 = Fe4()
internal val verifyFe4S: Fe4 = Fe4()
internal val verifyFe4Ax: Fe4 = Fe4()
internal val verifyFe4Ay: Fe4 = Fe4()
internal val verifyFe4Bx: Fe4 = Fe4()
internal val verifyFe4By: Fe4 = Fe4()
internal val verifyFe4Cx: Fe4 = Fe4()
internal val verifyFe4Cy: Fe4 = Fe4()
internal val verifyFe4Ser: Fe4 = Fe4()
internal val verifyFe4Ser2: Fe4 = Fe4()
internal val verifyPointA: MutablePoint = MutablePoint()
internal val verifyPointB: MutablePoint = MutablePoint()
internal val verifyPointC: MutablePoint = MutablePoint()
internal val verifyPointSg: MutablePoint = MutablePoint()
internal val verifyPointEa: MutablePoint = MutablePoint()
internal val verifyPointNegEa: MutablePoint = MutablePoint()
internal val verifyPointR1: MutablePoint = MutablePoint()
internal val verifyPointSb: MutablePoint = MutablePoint()
internal val verifyPointEc: MutablePoint = MutablePoint()
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()
}
/**
* Returns the scratchpad this thread should use for Bdhke crypto ops.
*
* On JVM/Android, backed by `ThreadLocal` — each thread that ever
* touches Bdhke gets its own scratchpad allocated lazily on first
* use, then reused across every subsequent call on that thread.
* Means every `Bdhke.blind`/`unblind`/`verifyDleq` etc. is
* allocation-free regardless of how the caller is structured.
*
* On other targets (iOS, native), allocates a fresh scratchpad per
* call. Those targets don't run the Cashu hot paths in production,
* but the function must exist for `quartz` to compile across the
* KMP target set.
*/
internal expect fun bdhkeScratchpad(): BdhkeScratchpad
@@ -1,126 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip60Cashu.mintApi
import com.vitorpamplona.quartz.utils.Log
import kotlinx.serialization.json.Json
import kotlin.concurrent.Volatile
/**
* Pre-warms kotlinx.serialization for the hot mint-API DTOs.
*
* # Why
*
* NUT-09 restore returns 300400 [BlindSignatureDto]s + [BlindedMessageDto]s
* per HTTP round-trip, each with nested [DleqProofDto]s. Decoding that
* response allocates ~1.5k data-class instances and ~5k Strings in
* tight succession through kotlinx.serialization's generated
* `serializer()` deserialize methods.
*
* On Android 15+ the ART JIT optimizer crashes (SIGSEGV at 0x48 in
* Jit thread pool) when it tries to escape-analyze those generated
* decode bodies. We've already addressed the in-Kotlin BDHKE
* allocation density via [com.vitorpamplona.quartz.nip60Cashu.bdhke.BdhkeScratchpad],
* but the serializers are also-ran hot methods we don't own.
*
* Warming them at init forces the JIT compile to happen under low
* pressure (background coroutine, no user waiting) instead of mid-
* restore where the synchronous compile pause is visible AND the
* crash blast-radius is "wallet unusable".
*
* # How
*
* Decode a synthetic [RestoreResponseDto] with [WARMUP_ELEMENT_COUNT]
* entries. This exercises the same code paths the real response will
* — the same generated `BlindSignatureDto.serializer().deserialize(...)`,
* the same nested `DleqProofDto` decode, the same List<> accumulator.
* If ART's optimizer is going to crash on this shape, it crashes now
* (visible at app start) instead of mid-recovery.
*/
object MintApiSerializerWarmup {
/**
* Per-payload element count. The serializer warmup decodes one
* synthetic [RestoreResponseDto] with [WARMUP_ELEMENT_COUNT] entries
* and one synthetic [SwapResponseDto] with [WARMUP_ELEMENT_COUNT] entries.
* Beyond the restore endpoint (which now uses a hand-rolled
* tree-API decode and doesn't reach kotlinx.serialization at all),
* the swap / mint / melt decoders still go through generated code.
* 128 elements gives them tier-0 baseline coverage without the
* tier-1 escape-analysis crash we hit on /v1/restore.
*/
private const val WARMUP_ELEMENT_COUNT = 128
private val json =
Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = true
explicitNulls = false
}
/**
* At-most-once flag, mirrors [com.vitorpamplona.quartz.nip60Cashu.bdhke.Bdhke.warmup]'s
* gate. Multiple accounts' `CashuWalletState.start()` racing to
* warm the serializer simultaneously defeats the purpose — it
* stacks N parallel decodes into the JIT's queue at exactly the
* moment we wanted things calm.
*/
@Volatile
private var warmupDone: Boolean = false
fun warmup() {
if (warmupDone) return
warmupDone = true
Log.i("CashuTrace") { "MintApiSerializerWarmup: begin" }
val payload = buildSyntheticRestorePayload(WARMUP_ELEMENT_COUNT)
// Decode + re-encode. The encode path is also hot (every mint
// request body serializes a List<BlindedMessageDto>), so warm
// both directions.
val decoded = json.decodeFromString(RestoreResponseDto.serializer(), payload)
json.encodeToString(RestoreResponseDto.serializer(), decoded)
// Also warm the SwapResponseDto / MintBolt11ResponseDto decoders
// since they go through the same BlindSignatureDto inner code.
val swap = buildSyntheticSwapPayload(WARMUP_ELEMENT_COUNT)
json.decodeFromString(SwapResponseDto.serializer(), swap)
Log.i("CashuTrace") { "MintApiSerializerWarmup: end" }
}
private fun buildSyntheticRestorePayload(count: Int): String {
val outputs =
(0 until count).joinToString(",") {
"""{"amount":1,"id":"00aabbccdd001122","B_":"02${"a".repeat(64)}"}"""
}
val sigs =
(0 until count).joinToString(",") {
"""{"amount":1,"id":"00aabbccdd001122","C_":"02${"b".repeat(64)}","dleq":{"e":"${"c".repeat(64)}","s":"${"d".repeat(64)}"}}"""
}
return """{"outputs":[$outputs],"signatures":[$sigs]}"""
}
private fun buildSyntheticSwapPayload(count: Int): String {
val sigs =
(0 until count).joinToString(",") {
"""{"amount":1,"id":"00aabbccdd001122","C_":"02${"b".repeat(64)}","dleq":{"e":"${"c".repeat(64)}","s":"${"d".repeat(64)}"}}"""
}
return """{"signatures":[$sigs]}"""
}
}
@@ -1,35 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip60Cashu.bdhke
/**
* JVM/Android actual: one [BdhkeScratchpad] per thread, allocated
* lazily on first access and reused for the lifetime of the thread.
*
* The hot Cashu paths (NUT-09 restore, swap output unblinding,
* Carol verification) all run on a single Dispatchers.IO or
* Dispatchers.Default thread per coroutine, so a thread-local pool
* matches their access pattern perfectly — every call after the
* first reuses the same holders, zero per-call allocation.
*/
private val threadLocal = ThreadLocal.withInitial { BdhkeScratchpad() }
internal actual fun bdhkeScratchpad(): BdhkeScratchpad = threadLocal.get()
@@ -27,7 +27,6 @@ import com.vitorpamplona.quartz.nip60Cashu.p2pk.P2PK
import com.vitorpamplona.quartz.nip60Cashu.seed.CashuDeterministic
import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof
import com.vitorpamplona.quartz.nip60Cashu.token.TokenContent
import com.vitorpamplona.quartz.utils.Log
/**
* High-level mint operations: mint-from-LN, swap, melt-to-LN, send-as-token,
@@ -431,8 +430,7 @@ class CashuMintOperations(
// wallet would have minted at this counter slot. We
// try each amount denomination — the mint will only
// return the signature(s) it actually issued at this
// slot, if any. Bdhke uses a thread-local scratchpad
// internally so no per-call allocation pressure.
// slot, if any.
val secretBytes = CashuDeterministic.secretBytes(seed, keysetId, c)
val r = CashuDeterministic.blindingFactor(seed, keysetId, c)
val secretHex = secretBytes.toHexKey()
@@ -444,9 +442,7 @@ class CashuMintOperations(
}
}
Log.i("CashuTrace") { "restore: POST /v1/restore (req=${outputDtos.size} outputs)" }
val response = client.restore(RestoreRequestDto(outputs = outputDtos))
Log.i("CashuTrace") { "restore: decoded sigs=${response.signatures.size} echoes=${response.outputs.size}" }
if (response.signatures.isEmpty()) {
emptyStreak++
} else {
@@ -458,10 +454,8 @@ class CashuMintOperations(
// 63× per counter (once per denomination we probed).
//
// Dedupe to one (counter → signature) pair BEFORE the
// unblind loop. This trims a ~378-iteration mostly-no-op
// loop down to ~6 real unblinds per batch — both faster
// and easier on the ART JIT, which kept trying to compile
// the wide hot loop and crashing on Android 15+.
// unblind loop so a typical restore batch shrinks from
// ~378 iterations to ~6 real unblinds.
val uniqueByCounter = LinkedHashMap<Long, Pair<CounterMaterials, BlindSignatureDto>>()
for (i in response.signatures.indices) {
val echo = response.outputs.getOrNull(i) ?: continue
@@ -469,7 +463,6 @@ class CashuMintOperations(
if (mat.counter in uniqueByCounter) continue
uniqueByCounter[mat.counter] = mat to response.signatures[i]
}
Log.i("CashuTrace") { "restore: deduped to ${uniqueByCounter.size} unique counter(s)" }
for ((c, pair) in uniqueByCounter) {
val (mat, sig) = pair
val output = BlindOutput(sig.amount, keysetId, mat.r, mat.secretHex, mat.bTick)
@@ -477,7 +470,6 @@ class CashuMintOperations(
recovered += RecoveredProof(proof, c)
if (c > highestSeenCounter) highestSeenCounter = c
}
Log.i("CashuTrace") { "restore: batch unblinded" }
}
counter += effectiveBatchSize
@@ -533,8 +525,6 @@ class CashuMintOperations(
if (proofs.isEmpty()) return true
// Fetch all keysets the mint exposes so cross-keyset tokens
// verify against the right amount key. Cheap — one round-trip.
// Bdhke.verifyDleqCarol uses a thread-local scratchpad
// internally so the loop is allocation-free.
val allKeysets = client.activeKeysets().keysets.associateBy { it.id }
for (proof in proofs) {
val dleq = proof.dleq ?: continue
@@ -570,8 +560,6 @@ class CashuMintOperations(
suspend fun checkStates(proofs: List<CashuProof>): Map<String, ProofState> {
if (proofs.isEmpty()) return emptyMap()
// NUT-07 keys check requests by `Y` (hash-to-curve of the secret).
// Bdhke.hashToCurveCompressed uses a thread-local scratchpad
// internally so the per-proof loop is allocation-free.
val ys = proofs.map { Bdhke.hashToCurveCompressed(it.secret.encodeToByteArray()).toHexKey() }
val response = client.checkState(CheckStateRequestDto(ys = ys))
val secretByY =
@@ -614,7 +602,6 @@ class CashuMintOperations(
// [secretFactory] decides whether those bytes are pure-random or
// 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.
// Bdhke.blind uses a thread-local scratchpad internally.
val derived = secretFactory.nextSecrets(keyset.id, amounts.size)
return amounts.mapIndexed { i, amount ->
val pair = derived[i]
@@ -649,9 +636,6 @@ class CashuMintOperations(
"Got ${signatures.size} signatures for ${outputs.size} outputs",
)
}
// Bdhke.unblind uses a thread-local scratchpad internally so
// each call on this thread reuses the same Fe4 / MutablePoint
// holders — no per-iteration allocation pressure on the JIT.
val out = ArrayList<CashuProof>(outputs.size)
for (i in outputs.indices) {
out += unblindOne(outputs[i], signatures[i], keyset)
@@ -1,28 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip60Cashu.bdhke
/**
* Linux actual: fresh allocation per call. Cashu hot paths don't
* run in production on linuxNative — this implementation exists only
* to satisfy the expect declaration for the multiplatform compile.
*/
internal actual fun bdhkeScratchpad(): BdhkeScratchpad = BdhkeScratchpad()