From 63e9970340efd7633dd634fe98c3e126e7438b00 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 02:43:48 +0000 Subject: [PATCH] =?UTF-8?q?refactor(cashu):=20Bdhke=20scratchpad=20via=20T?= =?UTF-8?q?hreadLocal=20=E2=80=94=20drop=20param=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bdhke's allocation-free hot path used to expose `BdhkeScratchpad` as an explicit parameter on every public function (`blind(secret, r, scratch)`, `unblind(..., scratch)`, `verifyDleq(..., scratch)`, …). Every caller in CashuMintOperations had to remember to allocate a scratchpad per loop and thread it through. Switch to a thread-local pool. New expect/actual: internal expect fun bdhkeScratchpad(): BdhkeScratchpad - jvmAndroid: ThreadLocal.withInitial { BdhkeScratchpad() } - apple / linux: fresh allocation per call (no Cashu in prod) Each public Bdhke function pulls the scratchpad internally with one `val scratch = bdhkeScratchpad()` at the top. Every thread that ever touches Bdhke gets one scratchpad allocated lazily on first use and reuses it across every subsequent call on that thread — same JIT-bug mitigation, much cleaner API. Removes: - 0-arg and N+1-arg overloads on blind / unblind / verifyDleq / verifyDleqCarol / hashToCurveCompressed - `scratch` parameter on private addRTimesA / unblindOne / unblindAll - All `val scratch = BdhkeScratchpad()` boilerplate in CashuMintOperations.restore / meltToLightning / verifyTokenDleq / checkStates / secretOutputsFor Also strips the diagnostic Log.i("CashuTrace") / Log.i("BdhkeTrace") lines added during the JIT-bug investigation — the at-most-once warmup + ThreadLocal pooling should resolve the crash, and the traces were polluting logcat at info level. Nested calls (verifyDleqCarol → blind + addRTimesA + verifyDleq) all grab the same thread-local scratchpad; the holder field sets are disjoint by design so nested use is safe. BdhkeTest still 17/17 green. --- .../model/nip60Cashu/CashuWalletOps.kt | 16 -- .../nip60Cashu/bdhke/BdhkeScratchpad.apple.kt | 28 +++ .../quartz/nip60Cashu/bdhke/Bdhke.kt | 176 ++++++------------ .../nip60Cashu/bdhke/BdhkeScratchpad.kt | 16 ++ .../bdhke/BdhkeScratchpad.jvmAndroid.kt | 35 ++++ .../nip60Cashu/mintApi/CashuMintOperations.kt | 75 ++------ .../nip60Cashu/bdhke/BdhkeScratchpad.linux.kt | 28 +++ 7 files changed, 179 insertions(+), 195 deletions(-) create mode 100644 quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.apple.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.jvmAndroid.kt create mode 100644 quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.linux.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt index 4187a503ad..305bf80873 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt @@ -617,23 +617,17 @@ class CashuWalletOps( message: String, available: List, ): NutzapSent { - Log.i("CashuTrace") { "sendNutzap enter: amount=$amountSats mint=$mintUrl" } if (amountSats <= 0) throw IllegalArgumentException("Amount must be positive") - Log.i("CashuTrace") { "sendNutzap: seedWarmer begin" } seedWarmer() - Log.i("CashuTrace") { "sendNutzap: seedWarmer end" } val (selected, totalSelected) = selectProofsCovering(available, amountSats) if (totalSelected < amountSats) throw IllegalStateException("Insufficient balance for $mintUrl") - Log.i("CashuTrace") { "sendNutzap: selected=${selected.size} entries, total=$totalSelected sat" } - Log.i("CashuTrace") { "sendNutzap: swapToLocked begin (${selected.flatMap { it.content.proofs }.size} input proofs)" } val swap = ops(mintUrl).swapToLocked( proofs = selected.flatMap { it.content.proofs }, recipientP2pkPubkeyHex = recipientP2pkPubkeyHex, targetSplit = amountSats, ) - Log.i("CashuTrace") { "sendNutzap: swapToLocked end (send=${swap.send.size}, keep=${swap.keep.size})" } // Build the kind:9321 first so we have its id to reference from history. val proofJsons = swap.send.map { nutzapProofJson.encodeToString(NutzapProofJson.serializer(), it.toNutzapJson()) } @@ -646,20 +640,15 @@ class CashuWalletOps( zappedEvent = zappedEvent, recipientPubKey = recipientPubKey, ) - Log.i("CashuTrace") { "sendNutzap: signing nutzapEvent" } val nutzapEvent = signer.sign(nutzapTemplate) - Log.i("CashuTrace") { "sendNutzap: publishing nutzapEvent id=${nutzapEvent.id.take(8)}" } publish(nutzapEvent) - Log.i("CashuTrace") { "sendNutzap: nutzapEvent published" } // Roll over change locally if any. val keepEvent = if (swap.keep.isNotEmpty()) { val content = TokenContent(mint = mintUrl, proofs = swap.keep, del = selected.map { it.event.id }) - Log.i("CashuTrace") { "sendNutzap: building+signing keep kind:7375 (${swap.keep.size} proofs, NIP-44 encrypt)" } val template = CashuTokenEvent.build(content, signer) val signed = signer.sign(template) - Log.i("CashuTrace") { "sendNutzap: publishing keep event id=${signed.id.take(8)}" } publish(signed) signed } else { @@ -667,12 +656,10 @@ class CashuWalletOps( } // NIP-09 delete the source token events. - Log.i("CashuTrace") { "sendNutzap: signing delete event" } val deleteEvent = run { val template = DeletionEvent.build(selected.map { it.event }) signer.sign(template).also { - Log.i("CashuTrace") { "sendNutzap: publishing delete event id=${it.id.take(8)}" } publish(it) } } @@ -688,11 +675,8 @@ class CashuWalletOps( }, signer = signer, ) - Log.i("CashuTrace") { "sendNutzap: signing history (NIP-44 encrypt)" } val historyEvent = signer.sign(historyTemplate) - Log.i("CashuTrace") { "sendNutzap: publishing history id=${historyEvent.id.take(8)}" } publish(historyEvent) - Log.i("CashuTrace") { "sendNutzap: done — nutzap=${nutzapEvent.id.take(8)}" } return NutzapSent( nutzapEvent = nutzapEvent, diff --git a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.apple.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.apple.kt new file mode 100644 index 0000000000..22728c1390 --- /dev/null +++ b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.apple.kt @@ -0,0 +1,28 @@ +/* + * 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() 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 99ff9c20be..7ab6d84d61 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 @@ -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 @@ -67,7 +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()) + internal fun hashToCurve(x: ByteArray): MutablePoint = hashToCurveInto(x, bdhkeScratchpad()) /** * Allocation-free [hashToCurve] variant — writes the resulting @@ -105,23 +104,24 @@ 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 = hashToCurveCompressed(x, BdhkeScratchpad()) - - /** - * Allocation-free [hashToCurveCompressed] variant. NUT-07 - * checkstate hashes every proof's secret once per scrub sweep — - * a hot loop where the per-call allocations would otherwise - * stack up. - */ - fun hashToCurveCompressed( - x: ByteArray, - scratch: BdhkeScratchpad, - ): ByteArray = toCompressedScratch(hashToCurveInto(x, scratch), scratch) + fun hashToCurveCompressed(x: ByteArray): ByteArray { + val scratch = bdhkeScratchpad() + return toCompressedScratch(hashToCurveInto(x, scratch), scratch) + } /** * 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`. @@ -129,39 +129,16 @@ 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 { - Log.i("BdhkeTrace") { " blind enter (secret=${secret.size}b r=${r.size}b)" } + val scratch = bdhkeScratchpad() require(r.size == 32) { "Blinding factor must be 32 bytes" } - Log.i("BdhkeTrace") { " blind: Secp256k1.secKeyVerify" } require(Secp256k1.secKeyVerify(r)) { "Invalid blinding factor" } - Log.i("BdhkeTrace") { " blind: hashToCurveInto" } val y = hashToCurveInto(secret, scratch) - Log.i("BdhkeTrace") { " blind: U256.fromBytesInto" } U256.fromBytesInto(scratch.blindFe4Scalar, r, 0) - Log.i("BdhkeTrace") { " blind: ECPoint.mulG" } ECPoint.mulG(scratch.blindPointRg, scratch.blindFe4Scalar) - - Log.i("BdhkeTrace") { " blind: ECPoint.addPoints" } ECPoint.addPoints(scratch.blindPointOut, y, scratch.blindPointRg) - - Log.i("BdhkeTrace") { " blind: toCompressedScratch" } - val out = toCompressedScratch(scratch.blindPointOut, scratch) - Log.i("BdhkeTrace") { " blind exit" } - return out + return toCompressedScratch(scratch.blindPointOut, scratch) } /** @@ -172,6 +149,21 @@ 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 @@ -182,56 +174,15 @@ object Bdhke { blindSignature: ByteArray, r: ByteArray, mintPubKey: ByteArray, - ): ByteArray = unblind(blindSignature, r, mintPubKey, BdhkeScratchpad()) - - /** - * Allocation-free [unblind] variant that re-uses pre-allocated - * [Fe4] / [MutablePoint] holders from the caller-owned [scratch]. - * - * # Why the scratch parameter exists - * - * Android 15+ ART crashes the JIT compiler thread (SIGSEGV at - * field offset 0x48 in "Jit thread pool") when it tries to - * escape-analyze the original inlined unblind body — about 10 - * short-lived holder objects in one ~30-line method body. We - * tried splitting into three smaller helpers; the JIT's - * inlining pass put them back together at compile time and the - * crash persisted. - * - * Passing the holders as parameters fixes it: the JIT sees the - * objects clearly escape (came from outside, stored across the - * call), so the scalarization pass that's actually buggy in ART - * 15+ never runs. Side benefit: a hot loop like NUT-09 restore - * (hundreds of unblinds per pass) now does one [BdhkeScratchpad] - * allocation total instead of ~10 per iteration. - * - * The holders are fully overwritten on each call — - * [KeyCodec.parsePublicKey], [U256.fromBytesInto], [ECPoint.mul], - * [ECPoint.toAffine], [FieldP.neg], [MutablePoint.setAffine] all - * write through every field they use — so re-use across calls is - * safe even if previous content was different. - */ - fun unblind( - blindSignature: ByteArray, - r: ByteArray, - mintPubKey: ByteArray, - scratch: BdhkeScratchpad, ): ByteArray { - Log.i("BdhkeTrace") { " unblind enter" } + val scratch = bdhkeScratchpad() require(r.size == 32) { "Blinding factor must be 32 bytes" } - Log.i("BdhkeTrace") { " unblind: parseAffinePointInto cTick" } val cTick = parseAffinePointInto(blindSignature, "blind signature", scratch.fe4A, scratch.fe4B, scratch.pointA) - Log.i("BdhkeTrace") { " unblind: parseAffinePointInto k" } val k = parseAffinePointInto(mintPubKey, "mint public key", scratch.fe4C, scratch.fe4D, scratch.pointB) - Log.i("BdhkeTrace") { " unblind: computeNegRkInto" } val negRk = computeNegRkInto(k, r, scratch.fe4E, scratch.pointC, scratch.fe4F, scratch.fe4G, scratch.pointD) - Log.i("BdhkeTrace") { " unblind: ECPoint.addPoints" } ECPoint.addPoints(scratch.outPoint, cTick, negRk) - Log.i("BdhkeTrace") { " unblind: toCompressedScratch" } - val out = toCompressedScratch(scratch.outPoint, scratch) - Log.i("BdhkeTrace") { " unblind exit" } - return out + return toCompressedScratch(scratch.outPoint, scratch) } /** Parse a 33-byte compressed pubkey into [outPoint], using [xHolder]/[yHolder] as scratch. */ @@ -322,6 +273,12 @@ 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. @@ -334,22 +291,8 @@ object Bdhke { blindedMessage: ByteArray, blindSignature: ByteArray, mintPubKey: ByteArray, - ): Boolean = verifyDleq(e, s, blindedMessage, blindSignature, mintPubKey, BdhkeScratchpad()) - - /** - * Allocation-free [verifyDleq] variant — same ART JIT escape-analysis - * mitigation as [unblind] / [blind]. Carol-side verification runs once - * per inbound nutzap proof during auto-redeem, so it's on the hot - * path of every nutzap receive. - */ - fun verifyDleq( - e: ByteArray, - s: ByteArray, - blindedMessage: ByteArray, - blindSignature: ByteArray, - mintPubKey: ByteArray, - scratch: BdhkeScratchpad, ): 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 @@ -471,6 +414,16 @@ 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. */ @@ -481,40 +434,22 @@ object Bdhke { s: ByteArray, unblindedC: ByteArray, mintPubKey: ByteArray, - ): Boolean = verifyDleqCarol(secret, r, e, s, unblindedC, mintPubKey, BdhkeScratchpad()) - - /** - * Allocation-free [verifyDleqCarol] variant. The Carol verification - * pipeline is the heaviest in BDHKE (one blind + one addRTimesA + - * one verifyDleq per call) so a hot loop here without a shared - * scratchpad would burn ~40 short-lived holders per proof — exactly - * the shape that triggers the Android 15+ ART JIT crash. - */ - fun verifyDleqCarol( - secret: ByteArray, - r: ByteArray, - e: ByteArray, - s: ByteArray, - unblindedC: ByteArray, - mintPubKey: ByteArray, - scratch: BdhkeScratchpad, ): Boolean { if (r.size != 32) return false if (unblindedC.size != 33) return false if (mintPubKey.size != 33) return false // Reconstruct B' the way Alice did at mint time. - val bTick = runCatching { blind(secret, r, scratch) }.getOrNull() ?: return false + val bTick = runCatching { blind(secret, r) }.getOrNull() ?: return false // Reconstruct C' = C + r·A. Without this, verifyDleq is computing // the DLEQ check against the wrong point and always returns false // — this was a real production bug. - val cTick = runCatching { addRTimesA(unblindedC, r, mintPubKey, scratch) }.getOrNull() ?: return false + val cTick = runCatching { addRTimesA(unblindedC, r, mintPubKey) }.getOrNull() ?: return false return verifyDleq( e = e, s = s, blindedMessage = bTick, blindSignature = cTick, mintPubKey = mintPubKey, - scratch = scratch, ) } @@ -528,8 +463,8 @@ object Bdhke { c: ByteArray, r: ByteArray, mintPubKey: ByteArray, - scratch: BdhkeScratchpad, ): 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. @@ -659,7 +594,6 @@ object Bdhke { 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 // here leaks into wallet state. @@ -705,12 +639,12 @@ object Bdhke { repeat(JIT_WARMUP_ITERATIONS) { val secret = randomScalar() val r = randomScalar() - val bTick = blind(secret, r, scratch) + 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, scratch) + unblind(bTick, r, mintPubKey) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.kt index 3e7e11f3fa..25ee144943 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.kt @@ -120,3 +120,19 @@ class BdhkeScratchpad { 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 diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.jvmAndroid.kt new file mode 100644 index 0000000000..37b646dfc4 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.jvmAndroid.kt @@ -0,0 +1,35 @@ +/* + * 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() diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt index 025f3837e4..ab333a080d 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt @@ -23,12 +23,10 @@ package com.vitorpamplona.quartz.nip60Cashu.mintApi import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip60Cashu.bdhke.Bdhke -import com.vitorpamplona.quartz.nip60Cashu.bdhke.BdhkeScratchpad 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, @@ -224,15 +222,12 @@ class CashuMintOperations( recipientP2pkPubkeyHex: String, targetSplit: Long, ): SwapResult { - Log.i("CashuTrace") { "swapToLocked enter: inputs=${proofs.size}, target=$targetSplit" } if (proofs.isEmpty()) throw IllegalArgumentException("Nothing to swap") if (targetSplit <= 0) throw IllegalArgumentException("Target split must be > 0") val total = proofs.sumOf { it.amount } if (targetSplit > total) throw IllegalArgumentException("Target split exceeds available proofs") - Log.i("CashuTrace") { "swapToLocked: fetchKeyset begin" } val keyset = fetchKeyset() - Log.i("CashuTrace") { "swapToLocked: fetchKeyset end id=${keyset.id}" } // NUT-02: reserve per-input fees; change shrinks by the fee, send // amount stays whole (the recipient gets exactly targetSplit sats). val feeAtoms = computeInputFee(proofs.size, keyset.inputFeePpk) @@ -244,13 +239,10 @@ class CashuMintOperations( // proof, so NUT-13 recovery doesn't apply to those bytes), but // our change outputs go through the deterministic factory in one // batch — see [secretOutputsFor]. - Log.i("CashuTrace") { "swapToLocked: building ${splitAmounts(targetSplit).size} locked outputs (P2PK blinds)" } val sendOutputs = splitAmounts(targetSplit).map { lockedOutputFor(it, keyset, recipientP2pkPubkeyHex) } - Log.i("CashuTrace") { "swapToLocked: building ${if (keepAmount > 0L) splitAmounts(keepAmount).size else 0} keep outputs (NUT-13 deterministic blinds)" } val keepOutputs = if (keepAmount > 0L) secretOutputsFor(splitAmounts(keepAmount), keyset) else emptyList() val allOutputs = sendOutputs + keepOutputs - Log.i("CashuTrace") { "swapToLocked: POST /v1/swap with ${allOutputs.size} outputs" } val response = client.swap( @@ -259,7 +251,6 @@ class CashuMintOperations( outputs = allOutputs.map { it.toDto() }, ), ) - Log.i("CashuTrace") { "swapToLocked: swap response sigs=${response.signatures.size}" } if (response.signatures.size != allOutputs.size) { throw MintProtocolException( @@ -267,9 +258,7 @@ class CashuMintOperations( ) } - Log.i("CashuTrace") { "swapToLocked: unblindAll begin (${allOutputs.size})" } val unblinded = unblindAll(allOutputs, response.signatures, keyset) - Log.i("CashuTrace") { "swapToLocked: unblindAll end" } val sendProofs = unblinded.subList(0, sendOutputs.size) val keepProofs = unblinded.subList(sendOutputs.size, unblinded.size) return SwapResult(send = sendProofs, keep = keepProofs, keysetId = keyset.id) @@ -333,12 +322,11 @@ class CashuMintOperations( response.change?.let { sigs -> val byAmount = changeOutputs.associateBy { it.amount }.toMutableMap() val out = mutableListOf() - val scratch = BdhkeScratchpad() for (sig in sigs) { val src = byAmount.remove(sig.amount) ?: throw IllegalStateException("Mint returned change for amount ${sig.amount} we didn't request") - out += unblindOne(src, sig, keyset, scratch) + out += unblindOne(src, sig, keyset) } out } ?: emptyList() @@ -433,30 +421,21 @@ 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(effectiveBatchSize) val outputDtos = ArrayList(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 - Log.i("CashuTrace") { "restore.derive c=$c: CashuDeterministic" } // Per-counter derivation: same (secret, r) pair the // 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. + // slot, if any. Bdhke uses a thread-local scratchpad + // internally so no per-call allocation pressure. val secretBytes = CashuDeterministic.secretBytes(seed, keysetId, c) val r = CashuDeterministic.blindingFactor(seed, keysetId, c) val secretHex = secretBytes.toHexKey() - Log.i("CashuTrace") { "restore.derive c=$c: Bdhke.blind" } - val bTick = Bdhke.blind(secretHex.encodeToByteArray(), r, batchScratch) - Log.i("CashuTrace") { "restore.derive c=$c: build dtos for ${denominations.size} denoms" } + val bTick = Bdhke.blind(secretHex.encodeToByteArray(), r) val bTickHex = bTick.toHexKey() matsByBTick[bTickHex] = CounterMaterials(c, secretHex, r, bTick) for (amount in denominations) { @@ -464,9 +443,7 @@ class CashuMintOperations( } } - Log.i("CashuTrace") { "restore: HTTP /v1/restore (request ${outputDtos.size} outputs)" } val response = client.restore(RestoreRequestDto(outputs = outputDtos)) - Log.i("CashuTrace") { "restore: HTTP response sigs=${response.signatures.size} echoes=${response.outputs.size}" } if (response.signatures.isEmpty()) { emptyStreak++ } else { @@ -489,15 +466,10 @@ class CashuMintOperations( if (mat.counter in uniqueByCounter) continue uniqueByCounter[mat.counter] = mat to response.signatures[i] } - Log.i("CashuTrace") { "restore: dedup sigs=${response.signatures.size} -> unique=${uniqueByCounter.size}" } - - val restoreScratch = BdhkeScratchpad() for ((c, pair) in uniqueByCounter) { val (mat, sig) = pair - Log.i("CashuTrace") { "restore.unblind counter=$c amt=${sig.amount} begin" } val output = BlindOutput(sig.amount, keysetId, mat.r, mat.secretHex, mat.bTick) - val proof = unblindOne(output, sig, keyset, restoreScratch) - Log.i("CashuTrace") { "restore.unblind counter=$c amt=${sig.amount} end" } + val proof = unblindOne(output, sig, keyset) recovered += RecoveredProof(proof, c) if (c > highestSeenCounter) highestSeenCounter = c } @@ -556,13 +528,9 @@ 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 } - // One scratchpad reused across every per-proof Carol verification. - // Carol is the heaviest BDHKE pipeline (blind + addRTimesA + - // verifyDleq); without sharing this scratch the inbound-nutzap - // loop allocates dozens of short-lived holders per proof and - // trips the Android 15+ ART JIT crash. - val scratch = BdhkeScratchpad() for (proof in proofs) { val dleq = proof.dleq ?: continue val r = dleq.r ?: continue @@ -581,7 +549,6 @@ class CashuMintOperations( s = dleq.s.hexToByteArray(), unblindedC = proof.c.hexToByteArray(), mintPubKey = mintPubKeyHex.hexToByteArray(), - scratch = scratch, ) if (!ok) return false } @@ -598,15 +565,13 @@ class CashuMintOperations( suspend fun checkStates(proofs: List): Map { if (proofs.isEmpty()) return emptyMap() // NUT-07 keys check requests by `Y` (hash-to-curve of the secret). - // One scratchpad reused across the per-proof hashToCurve calls so - // the pre-send scrub doesn't allocate per-proof on wallets with - // many entries (and doesn't trip the ART JIT crash). - val scratch = BdhkeScratchpad() - val ys = proofs.map { Bdhke.hashToCurveCompressed(it.secret.encodeToByteArray(), scratch).toHexKey() } + // 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 = proofs.associateBy { - Bdhke.hashToCurveCompressed(it.secret.encodeToByteArray(), scratch).toHexKey() + Bdhke.hashToCurveCompressed(it.secret.encodeToByteArray()).toHexKey() } val out = mutableMapOf() for (row in response.states) { @@ -640,16 +605,15 @@ class CashuMintOperations( keyset: KeysetDto, ): List { if (amounts.isEmpty()) return emptyList() - Log.i("CashuTrace") { "secretOutputsFor: nextSecrets(${amounts.size})" } // NUT-00: secret is a UTF-8 hex string of 32 secret bytes. // [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) - val scratch = BdhkeScratchpad() return amounts.mapIndexed { i, amount -> val pair = derived[i] - val bTick = Bdhke.blind(pair.secretHex.encodeToByteArray(), pair.blindingFactor, scratch) + val bTick = Bdhke.blind(pair.secretHex.encodeToByteArray(), pair.blindingFactor) BlindOutput(amount, keyset.id, pair.blindingFactor, pair.secretHex, bTick) } } @@ -680,15 +644,12 @@ class CashuMintOperations( "Got ${signatures.size} signatures for ${outputs.size} outputs", ) } - // One scratchpad per loop, reused across every unblind call — - // makes the bdhke crypto allocation-free on the hot path and - // dodges the Android 15+ ART JIT escape-analysis crash that - // hit on tight per-iteration Fe4 / MutablePoint allocation. - // See [BdhkeScratchpad] for the full rationale. - val scratch = BdhkeScratchpad() + // 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(outputs.size) for (i in outputs.indices) { - out += unblindOne(outputs[i], signatures[i], keyset, scratch) + out += unblindOne(outputs[i], signatures[i], keyset) } return out } @@ -697,7 +658,6 @@ class CashuMintOperations( output: BlindOutput, signature: BlindSignatureDto, keyset: KeysetDto, - scratch: BdhkeScratchpad = BdhkeScratchpad(), ): CashuProof { if (signature.amount != output.amount) { throw IllegalStateException( @@ -725,7 +685,6 @@ class CashuMintOperations( blindSignature = cTickBytes, r = output.r, mintPubKey = mintPubKey, - scratch = scratch, ) // Retain (e, s, r) on the resulting proof per NUT-12 §3 so // anything that later forwards this proof to another wallet — diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.linux.kt new file mode 100644 index 0000000000..005ea73295 --- /dev/null +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.linux.kt @@ -0,0 +1,28 @@ +/* + * 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()