From ebf8f195e4ac424efde18aa459096de091d25d25 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 28 May 2026 15:15:18 -0400 Subject: [PATCH] refactor(cashu): delete wrong-theory dodge scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../model/nip60Cashu/CashuPreferences.kt | 8 +- .../model/nip60Cashu/CashuWalletState.kt | 17 - .../nip60Cashu/bdhke/BdhkeScratchpad.apple.kt | 28 -- .../quartz/nip60Cashu/bdhke/Bdhke.kt | 407 +++++------------- .../nip60Cashu/bdhke/BdhkeScratchpad.kt | 138 ------ .../mintApi/MintApiSerializerWarmup.kt | 126 ------ .../bdhke/BdhkeScratchpad.jvmAndroid.kt | 35 -- .../nip60Cashu/mintApi/CashuMintOperations.kt | 22 +- .../nip60Cashu/bdhke/BdhkeScratchpad.linux.kt | 28 -- 9 files changed, 111 insertions(+), 698 deletions(-) delete mode 100644 quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.apple.kt delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.kt delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintApiSerializerWarmup.kt delete mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.jvmAndroid.kt delete 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/CashuPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuPreferences.kt index 4cd8a900db..721beb2704 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuPreferences.kt @@ -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 * diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index f2acb265d0..404a3cd16c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -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) { 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 deleted file mode 100644 index 22728c1390..0000000000 --- a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.apple.kt +++ /dev/null @@ -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() 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 aa0d6c8da1..9e07e61c14 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 @@ -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 } 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 deleted file mode 100644 index 25ee144943..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.kt +++ /dev/null @@ -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 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 deleted file mode 100644 index abe3a27855..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintApiSerializerWarmup.kt +++ /dev/null @@ -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 300–400 [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), 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]}""" - } -} 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 deleted file mode 100644 index 37b646dfc4..0000000000 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.jvmAndroid.kt +++ /dev/null @@ -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() 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 1954cd1f55..771d9007c9 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 @@ -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>() 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): Map { 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(outputs.size) for (i in outputs.indices) { out += unblindOne(outputs[i], signatures[i], keyset) 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 deleted file mode 100644 index 005ea73295..0000000000 --- a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.linux.kt +++ /dev/null @@ -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()