diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/Bdhke.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/Bdhke.kt index f022b958e3..99ff9c20be 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/Bdhke.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/Bdhke.kt @@ -621,6 +621,27 @@ 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 @@ -630,13 +651,14 @@ object Bdhke { * the whole process down. * * Called from `CashuWalletState.start()` on a background coroutine. - * Safe to call multiple times — the JIT will deduplicate. Cheap if - * the methods are already compiled. + * 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 val scratch = BdhkeScratchpad() // Fixed public key for warmup — generator point G's compressed form. // G is always on the curve and parses cleanly; nothing we do diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintApiSerializerWarmup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintApiSerializerWarmup.kt index 464986f14e..e27e8d12e8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintApiSerializerWarmup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintApiSerializerWarmup.kt @@ -64,7 +64,19 @@ object MintApiSerializerWarmup { 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 val payload = buildSyntheticRestorePayload(WARMUP_ELEMENT_COUNT) // Decode + re-encode. The encode path is also hot (every mint // request body serializes a List), so warm