mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
refactor(cashu): delete wrong-theory dodge scaffolding
Removes ~700 lines of code added across ~10 prior commits trying to dodge
the ART JIT crash from the wrong angle. With the real root cause fixed
upstream (uLtInline inline-expansion), none of this is needed.
Deleted:
- BdhkeScratchpad.kt + 3 platform actuals (apple/jvmAndroid/linux). The
thread-local Fe4/MutablePoint pool was added under the belief that
per-call allocation density was triggering an ART escape-analysis
bug. It wasn't. The original Bdhke functions allocated ~5-10 small
objects per call — well under any TLAB pressure threshold.
- Bdhke.warmup() and the 2048-cycle blind+unblind loop it ran. The
warmup was justified by "force the JIT compile to happen during init
where a crash isn't user-facing" — except the warmup itself was what
triggered the crash. ~4 seconds of wasted startup CPU.
- MintApiSerializerWarmup.kt (kotlinx.serialization decoder warmup).
Same wrong theory, same wasted startup work.
- The `scope.launch(Dispatchers.Default) { Bdhke.warmup(); ... }` block
in CashuWalletState.start() that called both warmups.
Reverted:
- Bdhke.kt to its pre-scratchpad shape. Drops `hashToCurveInto`,
`parseAffinePointInto`, `computeNegRkInto`, `negateInto`,
`toUncompressedOrNullScratch`, `compressedToUncompressedScratch`,
`toCompressedScratch`, `@Volatile warmupDone`, `fun warmup()`, and
the `JIT_WARMUP_ITERATIONS` constant. Restores the simple
fresh-allocations-per-call form of `hashToCurve`, `blind`,
`unblind`, `verifyDleq`, `addRTimesA`.
Stripped from CashuMintOperations.kt:
- Four `Log.i("CashuTrace") { ... }` diagnostic lines in the restore
loop, added to chase the wrong hypothesis.
- The `import com.vitorpamplona.quartz.utils.Log` they were the only
user of.
- Five "Bdhke uses a thread-local scratchpad internally" comments that
referenced the now-deleted scratchpad.
- The "easier on the ART JIT" rationale in the per-counter dedup
comment, replaced with the actual NUT-09 §2 echo-semantics
explanation. The dedup itself is a real algorithmic win (~378 → ~6
unblinds per batch), kept.
Cleaned in CashuPreferences.kt:
- "ART JIT crash on Android 15+" example in the durability rationale,
replaced with generic "OOM, signer dialog dismiss, unexpected
process death." The durability point stands regardless of crash
source.
Net: -704 / +117 lines.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
6b9573906b
commit
ebf8f195e4
+4
-4
@@ -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
|
||||
*
|
||||
|
||||
-17
@@ -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) {
|
||||
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip60Cashu.bdhke
|
||||
|
||||
/**
|
||||
* Apple actual: fresh allocation per call. Cashu hot paths don't
|
||||
* run in production on iOS — this implementation exists only to
|
||||
* satisfy the expect declaration for the multiplatform compile.
|
||||
*/
|
||||
internal actual fun bdhkeScratchpad(): BdhkeScratchpad = BdhkeScratchpad()
|
||||
@@ -21,7 +21,6 @@
|
||||
package com.vitorpamplona.quartz.nip60Cashu.bdhke
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import com.vitorpamplona.quartz.utils.secp256k1.ECPoint
|
||||
import com.vitorpamplona.quartz.utils.secp256k1.Fe4
|
||||
@@ -32,7 +31,6 @@ import com.vitorpamplona.quartz.utils.secp256k1.ScalarN
|
||||
import com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
|
||||
import com.vitorpamplona.quartz.utils.secp256k1.U256
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
/**
|
||||
* Blind Diffie-Hellman Key Exchange (BDHKE) — the cryptographic primitive
|
||||
@@ -68,20 +66,7 @@ object Bdhke {
|
||||
* Internal because [MutablePoint] is internal to the Quartz crypto package.
|
||||
* External callers should use [hashToCurveCompressed] to get a 33-byte point.
|
||||
*/
|
||||
internal fun hashToCurve(x: ByteArray): MutablePoint = hashToCurveInto(x, bdhkeScratchpad())
|
||||
|
||||
/**
|
||||
* Allocation-free [hashToCurve] variant — writes the resulting
|
||||
* affine point into [scratch.blindPointY] using
|
||||
* [scratch.blindFe4X] / [scratch.blindFe4Y] / [scratch.blindFe4Scalar]
|
||||
* as inner scratch, then returns that same shared point. The caller
|
||||
* must consume the result before the next BDHKE operation on the
|
||||
* same scratchpad — see [BdhkeScratchpad] for the contract.
|
||||
*/
|
||||
internal fun hashToCurveInto(
|
||||
x: ByteArray,
|
||||
scratch: BdhkeScratchpad,
|
||||
): MutablePoint {
|
||||
internal fun hashToCurve(x: ByteArray): MutablePoint {
|
||||
val msgToHash = sha256(DOMAIN_SEPARATOR + x)
|
||||
val buf = ByteArray(36)
|
||||
msgToHash.copyInto(buf, 0)
|
||||
@@ -94,10 +79,13 @@ object Bdhke {
|
||||
buf[35] = ((counter ushr 24) and 0xFF).toByte()
|
||||
|
||||
val candidate = sha256(buf)
|
||||
U256.fromBytesInto(scratch.blindFe4Scalar, candidate, 0)
|
||||
if (KeyCodec.liftX(scratch.blindFe4X, scratch.blindFe4Y, scratch.blindFe4Scalar)) {
|
||||
scratch.blindPointY.setAffine(scratch.blindFe4X, scratch.blindFe4Y)
|
||||
return scratch.blindPointY
|
||||
val x4 = U256.fromBytes(candidate)
|
||||
val outX = Fe4()
|
||||
val outY = Fe4()
|
||||
if (KeyCodec.liftX(outX, outY, x4)) {
|
||||
val point = MutablePoint()
|
||||
point.setAffine(outX, outY)
|
||||
return point
|
||||
}
|
||||
counter++
|
||||
}
|
||||
@@ -106,24 +94,12 @@ object Bdhke {
|
||||
|
||||
/**
|
||||
* Public form of [hashToCurve] that returns a 33-byte compressed point.
|
||||
* Uses the per-thread scratchpad from [bdhkeScratchpad] so the call
|
||||
* is allocation-free regardless of how the caller is structured.
|
||||
*/
|
||||
fun hashToCurveCompressed(x: ByteArray): ByteArray {
|
||||
val scratch = bdhkeScratchpad()
|
||||
return toCompressedScratch(hashToCurveInto(x, scratch), scratch)
|
||||
}
|
||||
fun hashToCurveCompressed(x: ByteArray): ByteArray = toCompressed(hashToCurve(x))
|
||||
|
||||
/**
|
||||
* Step 1 of BDHKE — Alice creates a blinded message.
|
||||
*
|
||||
* Allocation-free hot path: pulls a per-thread scratchpad from
|
||||
* [bdhkeScratchpad] and reuses it across every call on that
|
||||
* thread. Important for NUT-09 restore which runs [blind]
|
||||
* hundreds of times in sequence — without scratchpad reuse the
|
||||
* ~10 short-lived Fe4 / MutablePoint allocations per call would
|
||||
* trigger the Android 15+ ART JIT escape-analysis crash.
|
||||
*
|
||||
* @param secret 32-byte (or arbitrary length) message to be later unblinded.
|
||||
* @param r 32-byte blinding factor (must be a valid scalar < n).
|
||||
* @return 33-byte compressed point `B_ = hash_to_curve(secret) + r·G`.
|
||||
@@ -132,15 +108,19 @@ object Bdhke {
|
||||
secret: ByteArray,
|
||||
r: ByteArray,
|
||||
): ByteArray {
|
||||
val scratch = bdhkeScratchpad()
|
||||
require(r.size == 32) { "Blinding factor must be 32 bytes" }
|
||||
require(Secp256k1.secKeyVerify(r)) { "Invalid blinding factor" }
|
||||
|
||||
val y = hashToCurveInto(secret, scratch)
|
||||
U256.fromBytesInto(scratch.blindFe4Scalar, r, 0)
|
||||
ECPoint.mulG(scratch.blindPointRg, scratch.blindFe4Scalar)
|
||||
ECPoint.addPoints(scratch.blindPointOut, y, scratch.blindPointRg)
|
||||
return toCompressedScratch(scratch.blindPointOut, scratch)
|
||||
val y = hashToCurve(secret)
|
||||
val rg = MutablePoint()
|
||||
val rScalar = Fe4()
|
||||
U256.fromBytesInto(rScalar, r, 0)
|
||||
ECPoint.mulG(rg, rScalar)
|
||||
|
||||
val out = MutablePoint()
|
||||
ECPoint.addPoints(out, y, rg)
|
||||
|
||||
return toCompressed(out)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,21 +131,6 @@ object Bdhke {
|
||||
* Implemented as `C_ + (-r·K)` since we don't expose point subtraction
|
||||
* directly. `-r·K` is computed by negating the scalar: `(n - r)·K`.
|
||||
*
|
||||
* Allocation-free hot path: pulls a per-thread scratchpad from
|
||||
* [bdhkeScratchpad] and reuses its pre-allocated [Fe4] /
|
||||
* [MutablePoint] holders across every call on that thread. The
|
||||
* holders are fully overwritten on each call so re-use is safe.
|
||||
*
|
||||
* Why thread-local pooling instead of fresh allocations: Android
|
||||
* 15+ ART crashes the JIT compiler (SIGSEGV at offset 0x48 in
|
||||
* "Jit thread pool") when it tries to escape-analyze the original
|
||||
* inlined unblind body — about 10 short-lived holders in one
|
||||
* ~30-line method body. Pre-allocating them and storing the
|
||||
* reference on a thread-local lets the JIT see the objects clearly
|
||||
* escape (came from outside, returned from a function it can't
|
||||
* inline through), so the scalarization pass that's actually
|
||||
* buggy never runs.
|
||||
*
|
||||
* @param blindSignature 33-byte compressed `C_` returned by the mint.
|
||||
* @param r 32-byte blinding factor used in [blind].
|
||||
* @param mintPubKey 33-byte compressed `K = a·G` (the mint's public key
|
||||
@@ -177,45 +142,34 @@ object Bdhke {
|
||||
r: ByteArray,
|
||||
mintPubKey: ByteArray,
|
||||
): ByteArray {
|
||||
val scratch = bdhkeScratchpad()
|
||||
require(r.size == 32) { "Blinding factor must be 32 bytes" }
|
||||
|
||||
val cTick = parseAffinePointInto(blindSignature, "blind signature", scratch.fe4A, scratch.fe4B, scratch.pointA)
|
||||
val k = parseAffinePointInto(mintPubKey, "mint public key", scratch.fe4C, scratch.fe4D, scratch.pointB)
|
||||
val negRk = computeNegRkInto(k, r, scratch.fe4E, scratch.pointC, scratch.fe4F, scratch.fe4G, scratch.pointD)
|
||||
ECPoint.addPoints(scratch.outPoint, cTick, negRk)
|
||||
return toCompressedScratch(scratch.outPoint, scratch)
|
||||
}
|
||||
val cTickX = Fe4()
|
||||
val cTickY = Fe4()
|
||||
require(KeyCodec.parsePublicKey(blindSignature, cTickX, cTickY)) { "Invalid blind signature" }
|
||||
val cTick = MutablePoint().also { it.setAffine(cTickX, cTickY) }
|
||||
|
||||
/** Parse a 33-byte compressed pubkey into [outPoint], using [xHolder]/[yHolder] as scratch. */
|
||||
private fun parseAffinePointInto(
|
||||
compressed: ByteArray,
|
||||
label: String,
|
||||
xHolder: Fe4,
|
||||
yHolder: Fe4,
|
||||
outPoint: MutablePoint,
|
||||
): MutablePoint {
|
||||
require(KeyCodec.parsePublicKey(compressed, xHolder, yHolder)) { "Invalid $label" }
|
||||
outPoint.setAffine(xHolder, yHolder)
|
||||
return outPoint
|
||||
}
|
||||
val kx = Fe4()
|
||||
val ky = Fe4()
|
||||
require(KeyCodec.parsePublicKey(mintPubKey, kx, ky)) { "Invalid mint public key" }
|
||||
val k = MutablePoint().also { it.setAffine(kx, ky) }
|
||||
|
||||
/** Compute `-r·K` into [outPoint], using the remaining holders as scratch. */
|
||||
private fun computeNegRkInto(
|
||||
k: MutablePoint,
|
||||
r: ByteArray,
|
||||
rScalar: Fe4,
|
||||
rkPoint: MutablePoint,
|
||||
rkX: Fe4,
|
||||
rkY: Fe4,
|
||||
outPoint: MutablePoint,
|
||||
): MutablePoint {
|
||||
// r·K, then negate Y to get -r·K
|
||||
val rScalar = Fe4()
|
||||
U256.fromBytesInto(rScalar, r, 0)
|
||||
ECPoint.mul(rkPoint, k, rScalar)
|
||||
require(ECPoint.toAffine(rkPoint, rkX, rkY)) { "rK is point at infinity" }
|
||||
val rk = MutablePoint()
|
||||
ECPoint.mul(rk, k, rScalar)
|
||||
|
||||
// Convert to affine first, then negate Y.
|
||||
val rkX = Fe4()
|
||||
val rkY = Fe4()
|
||||
require(ECPoint.toAffine(rk, rkX, rkY)) { "rK is point at infinity" }
|
||||
FieldP.neg(rkY, rkY)
|
||||
outPoint.setAffine(rkX, rkY)
|
||||
return outPoint
|
||||
val negRk = MutablePoint().also { it.setAffine(rkX, rkY) }
|
||||
|
||||
val out = MutablePoint()
|
||||
ECPoint.addPoints(out, cTick, negRk)
|
||||
return toCompressed(out)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -275,12 +229,6 @@ object Bdhke {
|
||||
* outside [1, n), points not on curve) or DLEQ mismatch. Callers
|
||||
* should treat false as a protocol violation and abort the swap/mint.
|
||||
*
|
||||
* Allocation-free hot path: pulls a per-thread scratchpad from
|
||||
* [bdhkeScratchpad]. Carol-side verification runs once per
|
||||
* inbound nutzap proof during auto-redeem, so this is on the hot
|
||||
* path of every nutzap receive — same JIT-crash mitigation as
|
||||
* [blind] and [unblind].
|
||||
*
|
||||
* @param e 32-byte challenge scalar from the mint.
|
||||
* @param s 32-byte response scalar from the mint.
|
||||
* @param blindedMessage 33-byte compressed `B_` we sent.
|
||||
@@ -294,53 +242,72 @@ object Bdhke {
|
||||
blindSignature: ByteArray,
|
||||
mintPubKey: ByteArray,
|
||||
): Boolean {
|
||||
val scratch = bdhkeScratchpad()
|
||||
if (e.size != 32 || s.size != 32) return false
|
||||
if (blindedMessage.size != 33 || blindSignature.size != 33 || mintPubKey.size != 33) return false
|
||||
|
||||
U256.fromBytesInto(scratch.verifyFe4E, e, 0)
|
||||
U256.fromBytesInto(scratch.verifyFe4S, s, 0)
|
||||
val eScalar = Fe4()
|
||||
U256.fromBytesInto(eScalar, e, 0)
|
||||
val sScalar = Fe4()
|
||||
U256.fromBytesInto(sScalar, s, 0)
|
||||
// `s` is `r' + e*k mod n` by construction (NUT-12 §2), so it must
|
||||
// be a canonical scalar < n; reject if not.
|
||||
if (!ScalarN.isValid(scratch.verifyFe4S)) return false
|
||||
if (!ScalarN.isValid(sScalar)) return false
|
||||
// `e` is a SHA-256 hash output that NUT-12 treats as raw bytes,
|
||||
// NOT a scalar mod n — there's no requirement that `e < n`. With
|
||||
// probability ~2^-128 a valid proof has `e >= n`; rejecting on
|
||||
// `isValid` here would spuriously fail those. We only check that
|
||||
// `e` isn't zero (a zero `e` would let any junk signature pass
|
||||
// because R1_check / R2_check would collapse to `sG` / `sB'`,
|
||||
// independent of the mint's keyset key).
|
||||
if (scratch.verifyFe4E.isZero()) return false
|
||||
// independent of the mint's keyset key). Downstream point
|
||||
// multiplications (`ECPoint.mul(_, A, eScalar)`) handle e >= n
|
||||
// correctly via internal GLV reduction.
|
||||
if (eScalar.isZero()) return false
|
||||
|
||||
if (!KeyCodec.parsePublicKey(mintPubKey, scratch.verifyFe4Ax, scratch.verifyFe4Ay)) return false
|
||||
scratch.verifyPointA.setAffine(scratch.verifyFe4Ax, scratch.verifyFe4Ay)
|
||||
val aX = Fe4()
|
||||
val aY = Fe4()
|
||||
if (!KeyCodec.parsePublicKey(mintPubKey, aX, aY)) return false
|
||||
val aPoint = MutablePoint().also { it.setAffine(aX, aY) }
|
||||
|
||||
if (!KeyCodec.parsePublicKey(blindedMessage, scratch.verifyFe4Bx, scratch.verifyFe4By)) return false
|
||||
scratch.verifyPointB.setAffine(scratch.verifyFe4Bx, scratch.verifyFe4By)
|
||||
val bX = Fe4()
|
||||
val bY = Fe4()
|
||||
if (!KeyCodec.parsePublicKey(blindedMessage, bX, bY)) return false
|
||||
val bPoint = MutablePoint().also { it.setAffine(bX, bY) }
|
||||
|
||||
if (!KeyCodec.parsePublicKey(blindSignature, scratch.verifyFe4Cx, scratch.verifyFe4Cy)) return false
|
||||
scratch.verifyPointC.setAffine(scratch.verifyFe4Cx, scratch.verifyFe4Cy)
|
||||
val cX = Fe4()
|
||||
val cY = Fe4()
|
||||
if (!KeyCodec.parsePublicKey(blindSignature, cX, cY)) return false
|
||||
val cPoint = MutablePoint().also { it.setAffine(cX, cY) }
|
||||
|
||||
// R1 = sG - eA, R2 = sB' - eC'. Same affine-Y-flip negation
|
||||
// pattern as [unblind]. Uses scratchpad holders throughout.
|
||||
ECPoint.mulG(scratch.verifyPointSg, scratch.verifyFe4S)
|
||||
ECPoint.mul(scratch.verifyPointEa, scratch.verifyPointA, scratch.verifyFe4E)
|
||||
if (!negateInto(scratch.verifyPointEa, scratch.verifyPointNegEa, scratch.verifyFe4Ser, scratch.verifyFe4Ser2)) return false
|
||||
ECPoint.addPoints(scratch.verifyPointR1, scratch.verifyPointSg, scratch.verifyPointNegEa)
|
||||
// R1 = sG - eA, R2 = sB' - eC'. We use the unblind() pattern for
|
||||
// point negation: compute the positive multiple, take affine, flip
|
||||
// the Y coordinate. Cheaper than computing (n-e) and re-multiplying.
|
||||
val sG = MutablePoint().also { ECPoint.mulG(it, sScalar) }
|
||||
val eA = MutablePoint().also { ECPoint.mul(it, aPoint, eScalar) }
|
||||
val negEa = negate(eA) ?: return false
|
||||
val r1 = MutablePoint().also { ECPoint.addPoints(it, sG, negEa) }
|
||||
|
||||
ECPoint.mul(scratch.verifyPointSb, scratch.verifyPointB, scratch.verifyFe4S)
|
||||
ECPoint.mul(scratch.verifyPointEc, scratch.verifyPointC, scratch.verifyFe4E)
|
||||
if (!negateInto(scratch.verifyPointEc, scratch.verifyPointNegEc, scratch.verifyFe4Ser, scratch.verifyFe4Ser2)) return false
|
||||
ECPoint.addPoints(scratch.verifyPointR2, scratch.verifyPointSb, scratch.verifyPointNegEc)
|
||||
val sB = MutablePoint().also { ECPoint.mul(it, bPoint, sScalar) }
|
||||
val eC = MutablePoint().also { ECPoint.mul(it, cPoint, eScalar) }
|
||||
val negEc = negate(eC) ?: return false
|
||||
val r2 = MutablePoint().also { ECPoint.addPoints(it, sB, negEc) }
|
||||
|
||||
// NUT-12 §2 hash input: sha256(utf8(hex(R1) || hex(R2) || hex(A) || hex(C')))
|
||||
// where each hex is the 65-byte uncompressed form (04 || X || Y).
|
||||
// See the original verifyDleq comment for the spec / reference-impl details.
|
||||
val r1Uncompressed = toUncompressedOrNullScratch(scratch.verifyPointR1, scratch.verifyFe4Ser, scratch.verifyFe4Ser2) ?: return false
|
||||
val r2Uncompressed = toUncompressedOrNullScratch(scratch.verifyPointR2, scratch.verifyFe4Ser, scratch.verifyFe4Ser2) ?: return false
|
||||
val aUncompressed = compressedToUncompressedScratch(mintPubKey, scratch.verifyFe4Ser, scratch.verifyFe4Ser2) ?: return false
|
||||
val cUncompressed = compressedToUncompressedScratch(blindSignature, scratch.verifyFe4Ser, scratch.verifyFe4Ser2) ?: return false
|
||||
val r1Uncompressed = toUncompressedOrNull(r1) ?: return false
|
||||
val r2Uncompressed = toUncompressedOrNull(r2) ?: return false
|
||||
val aUncompressed = compressedToUncompressed(mintPubKey) ?: return false
|
||||
val cUncompressed = compressedToUncompressed(blindSignature) ?: return false
|
||||
|
||||
// NUT-12 §2 hash input. The spec text says `sha256(R1 || R2 || A || C')`
|
||||
// but the normative behaviour established by every reference impl
|
||||
// (cashu-ts, nutshell, CDK) is:
|
||||
// 1. Serialize each point as 65-byte UNCOMPRESSED form (04 || X || Y)
|
||||
// 2. Hex-encode each (130 chars per point)
|
||||
// 3. Concatenate the four hex strings (520 chars)
|
||||
// 4. Hash the UTF-8 bytes of that concatenation
|
||||
// Real mints emit `e` computed this way. Earlier versions of this
|
||||
// code used raw 33-byte compressed bytes — round-tripping our own
|
||||
// [signFull] hid the mismatch but every real mint rejected the
|
||||
// proof. CDK's `hash_e` in crates/cashu/src/dhke.rs is the
|
||||
// authoritative reference.
|
||||
val hashInput =
|
||||
(r1Uncompressed.toHexKey() + r2Uncompressed.toHexKey() + aUncompressed.toHexKey() + cUncompressed.toHexKey())
|
||||
.encodeToByteArray()
|
||||
@@ -349,36 +316,6 @@ object Bdhke {
|
||||
return computed.contentEquals(e)
|
||||
}
|
||||
|
||||
/** Allocation-free [negate] — writes into [out], using xHolder / yHolder as scratch. */
|
||||
private fun negateInto(
|
||||
p: MutablePoint,
|
||||
out: MutablePoint,
|
||||
xHolder: Fe4,
|
||||
yHolder: Fe4,
|
||||
): Boolean {
|
||||
if (!ECPoint.toAffine(p, xHolder, yHolder)) return false
|
||||
FieldP.neg(yHolder, yHolder)
|
||||
out.setAffine(xHolder, yHolder)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Allocation-free [toUncompressedOrNull] — returns the fresh ByteArray, scratch holders reused. */
|
||||
private fun toUncompressedOrNullScratch(
|
||||
p: MutablePoint,
|
||||
xHolder: Fe4,
|
||||
yHolder: Fe4,
|
||||
): ByteArray? = if (ECPoint.toAffine(p, xHolder, yHolder)) KeyCodec.serializeUncompressed(xHolder, yHolder) else null
|
||||
|
||||
/** Allocation-free [compressedToUncompressed] — returns the fresh ByteArray, scratch holders reused. */
|
||||
private fun compressedToUncompressedScratch(
|
||||
compressed: ByteArray,
|
||||
xHolder: Fe4,
|
||||
yHolder: Fe4,
|
||||
): ByteArray? {
|
||||
if (!KeyCodec.parsePublicKey(compressed, xHolder, yHolder)) return null
|
||||
return KeyCodec.serializeUncompressed(xHolder, yHolder)
|
||||
}
|
||||
|
||||
/**
|
||||
* NUT-12 §3 Carol-side DLEQ verification.
|
||||
*
|
||||
@@ -416,16 +353,6 @@ object Bdhke {
|
||||
* @param unblindedC 33-byte `C` from the proof — the unblinded
|
||||
* signature the wallet stores after Alice's
|
||||
* [unblind].
|
||||
* Allocation-free hot path: pulls a per-thread scratchpad from
|
||||
* [bdhkeScratchpad]. The Carol pipeline is the heaviest in BDHKE
|
||||
* (one blind + one addRTimesA + one verifyDleq per call) so a
|
||||
* hot loop here without sharing would burn ~40 short-lived
|
||||
* holders per proof — exactly the shape that triggers the
|
||||
* Android 15+ ART JIT crash. Each nested call grabs the same
|
||||
* thread-local scratchpad; the holder sets used by [blind],
|
||||
* [addRTimesA], and [verifyDleq] are disjoint so nested-on-same-
|
||||
* scratchpad is safe.
|
||||
*
|
||||
* @param mintPubKey 33-byte compressed `A = k·G` for this amount,
|
||||
* looked up from the mint's keyset.
|
||||
*/
|
||||
@@ -466,21 +393,24 @@ object Bdhke {
|
||||
r: ByteArray,
|
||||
mintPubKey: ByteArray,
|
||||
): ByteArray {
|
||||
val scratch = bdhkeScratchpad()
|
||||
// Reuses unblind's holder set — addRTimesA is only called from
|
||||
// verifyDleqCarol, which calls verifyDleq AFTER this one. The
|
||||
// unblind holders are free for the duration of this call.
|
||||
require(KeyCodec.parsePublicKey(c, scratch.fe4A, scratch.fe4B)) { "Invalid C" }
|
||||
scratch.pointA.setAffine(scratch.fe4A, scratch.fe4B)
|
||||
val cX = Fe4()
|
||||
val cY = Fe4()
|
||||
require(KeyCodec.parsePublicKey(c, cX, cY)) { "Invalid C" }
|
||||
val cPoint = MutablePoint().also { it.setAffine(cX, cY) }
|
||||
|
||||
require(KeyCodec.parsePublicKey(mintPubKey, scratch.fe4C, scratch.fe4D)) { "Invalid mint public key" }
|
||||
scratch.pointB.setAffine(scratch.fe4C, scratch.fe4D)
|
||||
val kx = Fe4()
|
||||
val ky = Fe4()
|
||||
require(KeyCodec.parsePublicKey(mintPubKey, kx, ky)) { "Invalid mint public key" }
|
||||
val k = MutablePoint().also { it.setAffine(kx, ky) }
|
||||
|
||||
U256.fromBytesInto(scratch.fe4E, r, 0)
|
||||
ECPoint.mul(scratch.pointC, scratch.pointB, scratch.fe4E)
|
||||
val rScalar = Fe4()
|
||||
U256.fromBytesInto(rScalar, r, 0)
|
||||
val rk = MutablePoint()
|
||||
ECPoint.mul(rk, k, rScalar)
|
||||
|
||||
ECPoint.addPoints(scratch.outPoint, scratch.pointA, scratch.pointC)
|
||||
return toCompressedScratch(scratch.outPoint, scratch)
|
||||
val out = MutablePoint()
|
||||
ECPoint.addPoints(out, cPoint, rk)
|
||||
return toCompressed(out)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -558,100 +488,6 @@ object Bdhke {
|
||||
*/
|
||||
fun randomSecret(): ByteArray = randomScalar()
|
||||
|
||||
/**
|
||||
* Tracks whether [warmup] has already been invoked this process.
|
||||
* At-most-once: the FIRST caller does the JIT-warming work; every
|
||||
* subsequent caller (including parallel ones from different
|
||||
* accounts' [com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState.start])
|
||||
* sees the flag and returns immediately.
|
||||
*
|
||||
* Multiple warmups running concurrently was itself a crash trigger
|
||||
* — two accounts each doing 32 blind+unblind cycles on
|
||||
* Dispatchers.Default at startup put ~128 concurrent BDHKE calls
|
||||
* in flight, contending for the JIT compiler and reproducing the
|
||||
* Android 15+ ART optimizer bug we were trying to dodge.
|
||||
*
|
||||
* Plain @Volatile + check is sufficient — the harm from a tiny
|
||||
* race window (two callers both seeing `false` before either
|
||||
* flips the flag) is one extra 32-cycle warmup, not a correctness
|
||||
* issue. We avoid `synchronized` to stay commonMain-portable.
|
||||
*/
|
||||
@Volatile
|
||||
private var warmupDone: Boolean = false
|
||||
|
||||
/**
|
||||
* Pre-warm the JIT for [blind] / [unblind] by running them N times
|
||||
* with synthetic data. Forces ART's optimizing compiler to do its
|
||||
* work during app init (low contention, no user waiting) instead
|
||||
* of mid-restore where the synchronous compile pause is visible
|
||||
* (~13 ms gap inside the loop) and a crash in the JIT thread tears
|
||||
* the whole process down.
|
||||
*
|
||||
* Called from `CashuWalletState.start()` on a background coroutine.
|
||||
* At-most-once per process — see [warmupDone].
|
||||
*
|
||||
* The synthetic data uses a fixed mint pubkey / random blinding
|
||||
* factors. It does NOT touch any wallet state or network.
|
||||
*/
|
||||
fun warmup() {
|
||||
if (warmupDone) return
|
||||
warmupDone = true
|
||||
Log.i("CashuTrace") { "Bdhke.warmup: begin" }
|
||||
// Fixed public key for warmup — generator point G's compressed form.
|
||||
// G is always on the curve and parses cleanly; nothing we do
|
||||
// here leaks into wallet state.
|
||||
val mintPubKey =
|
||||
byteArrayOf(
|
||||
0x02.toByte(),
|
||||
0x79.toByte(),
|
||||
0xBE.toByte(),
|
||||
0x66.toByte(),
|
||||
0x7E.toByte(),
|
||||
0xF9.toByte(),
|
||||
0xDC.toByte(),
|
||||
0xBB.toByte(),
|
||||
0xAC.toByte(),
|
||||
0x55.toByte(),
|
||||
0xA0.toByte(),
|
||||
0x62.toByte(),
|
||||
0x95.toByte(),
|
||||
0xCE.toByte(),
|
||||
0x87.toByte(),
|
||||
0x0B.toByte(),
|
||||
0x07.toByte(),
|
||||
0x02.toByte(),
|
||||
0x9B.toByte(),
|
||||
0xFC.toByte(),
|
||||
0xDB.toByte(),
|
||||
0x2D.toByte(),
|
||||
0xCE.toByte(),
|
||||
0x28.toByte(),
|
||||
0xD9.toByte(),
|
||||
0x59.toByte(),
|
||||
0xF2.toByte(),
|
||||
0x81.toByte(),
|
||||
0x5B.toByte(),
|
||||
0x16.toByte(),
|
||||
0xF8.toByte(),
|
||||
0x17.toByte(),
|
||||
0x98.toByte(),
|
||||
)
|
||||
// ART JIT typically tier-1 compiles after ~10 invocations on
|
||||
// Android 15+. 32 iterations is enough headroom that both blind
|
||||
// and unblind reach the optimized tier before user-facing calls.
|
||||
repeat(JIT_WARMUP_ITERATIONS) {
|
||||
val secret = randomScalar()
|
||||
val r = randomScalar()
|
||||
val bTick = blind(secret, r)
|
||||
// We don't have a real mint signature to unblind, but `blind`'s
|
||||
// own output is a valid curve point that unblind will process
|
||||
// identically from the JIT's perspective (same code paths, same
|
||||
// allocations) — the math result is meaningless and discarded.
|
||||
unblind(bTick, r, mintPubKey)
|
||||
}
|
||||
Log.i("CashuTrace") { "Bdhke.warmup: end" }
|
||||
}
|
||||
|
||||
private fun toCompressed(p: MutablePoint): ByteArray {
|
||||
val x = Fe4()
|
||||
val y = Fe4()
|
||||
@@ -659,24 +495,6 @@ object Bdhke {
|
||||
return KeyCodec.serializeCompressed(x, y)
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocation-free [toCompressed] — reuses [scratch.toCompressedFe4X] /
|
||||
* [scratch.toCompressedFe4Y] as scratch holders. The returned
|
||||
* ByteArray comes from [KeyCodec.serializeCompressed] which always
|
||||
* allocates fresh, so the result is independent of the scratchpad.
|
||||
*
|
||||
* Used at the tail of every hot crypto op (blind / unblind /
|
||||
* addRTimesA) so the final point-serialization step doesn't
|
||||
* sneak 2 Fe4 allocations back into the inlined body.
|
||||
*/
|
||||
private fun toCompressedScratch(
|
||||
p: MutablePoint,
|
||||
scratch: BdhkeScratchpad,
|
||||
): ByteArray {
|
||||
require(ECPoint.toAffine(p, scratch.toCompressedFe4X, scratch.toCompressedFe4Y)) { "Point is at infinity" }
|
||||
return KeyCodec.serializeCompressed(scratch.toCompressedFe4X, scratch.toCompressedFe4Y)
|
||||
}
|
||||
|
||||
/**
|
||||
* 65-byte uncompressed form `04 || X || Y`. Used only by the
|
||||
* NUT-12 hash input — the on-wire form for everything else is
|
||||
@@ -720,21 +538,4 @@ object Bdhke {
|
||||
// Spec doesn't bound this; in practice the first iteration succeeds with
|
||||
// probability ~1/2. We cap at 2^16 — astronomically unlikely to hit.
|
||||
private const val MAX_HASH_TO_CURVE_ITERATIONS = 65536
|
||||
|
||||
/**
|
||||
* How many times [warmup] exercises [blind] / [unblind].
|
||||
*
|
||||
* 32 iterations was enough for ART tier-0 (baseline) but NOT
|
||||
* tier-1 (optimizing). The diagnostic logs showed batches 1-3
|
||||
* completing fine, then batch 4 crashing — exactly when tier-1
|
||||
* compile triggers on [Bdhke.unblind] (~21 production unblinds
|
||||
* crossed the optimizer threshold). Bump to 2048 so tier-1
|
||||
* fires during the background warmup coroutine where a crash
|
||||
* would be visible but not user-facing.
|
||||
*
|
||||
* ~1ms per blind+unblind cycle on a mid-range Android 15 device
|
||||
* → ~4 seconds of background warmup at app start. Acceptable for
|
||||
* the protection it provides.
|
||||
*/
|
||||
private const val JIT_WARMUP_ITERATIONS = 2048
|
||||
}
|
||||
|
||||
-138
@@ -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
|
||||
-126
@@ -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<BlindedMessageDto>), so warm
|
||||
// both directions.
|
||||
val decoded = json.decodeFromString(RestoreResponseDto.serializer(), payload)
|
||||
json.encodeToString(RestoreResponseDto.serializer(), decoded)
|
||||
|
||||
// Also warm the SwapResponseDto / MintBolt11ResponseDto decoders
|
||||
// since they go through the same BlindSignatureDto inner code.
|
||||
val swap = buildSyntheticSwapPayload(WARMUP_ELEMENT_COUNT)
|
||||
json.decodeFromString(SwapResponseDto.serializer(), swap)
|
||||
Log.i("CashuTrace") { "MintApiSerializerWarmup: end" }
|
||||
}
|
||||
|
||||
private fun buildSyntheticRestorePayload(count: Int): String {
|
||||
val outputs =
|
||||
(0 until count).joinToString(",") {
|
||||
"""{"amount":1,"id":"00aabbccdd001122","B_":"02${"a".repeat(64)}"}"""
|
||||
}
|
||||
val sigs =
|
||||
(0 until count).joinToString(",") {
|
||||
"""{"amount":1,"id":"00aabbccdd001122","C_":"02${"b".repeat(64)}","dleq":{"e":"${"c".repeat(64)}","s":"${"d".repeat(64)}"}}"""
|
||||
}
|
||||
return """{"outputs":[$outputs],"signatures":[$sigs]}"""
|
||||
}
|
||||
|
||||
private fun buildSyntheticSwapPayload(count: Int): String {
|
||||
val sigs =
|
||||
(0 until count).joinToString(",") {
|
||||
"""{"amount":1,"id":"00aabbccdd001122","C_":"02${"b".repeat(64)}","dleq":{"e":"${"c".repeat(64)}","s":"${"d".repeat(64)}"}}"""
|
||||
}
|
||||
return """{"signatures":[$sigs]}"""
|
||||
}
|
||||
}
|
||||
-35
@@ -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()
|
||||
+3
-19
@@ -27,7 +27,6 @@ import com.vitorpamplona.quartz.nip60Cashu.p2pk.P2PK
|
||||
import com.vitorpamplona.quartz.nip60Cashu.seed.CashuDeterministic
|
||||
import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof
|
||||
import com.vitorpamplona.quartz.nip60Cashu.token.TokenContent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* High-level mint operations: mint-from-LN, swap, melt-to-LN, send-as-token,
|
||||
@@ -431,8 +430,7 @@ class CashuMintOperations(
|
||||
// wallet would have minted at this counter slot. We
|
||||
// try each amount denomination — the mint will only
|
||||
// return the signature(s) it actually issued at this
|
||||
// slot, if any. Bdhke uses a thread-local scratchpad
|
||||
// internally so no per-call allocation pressure.
|
||||
// slot, if any.
|
||||
val secretBytes = CashuDeterministic.secretBytes(seed, keysetId, c)
|
||||
val r = CashuDeterministic.blindingFactor(seed, keysetId, c)
|
||||
val secretHex = secretBytes.toHexKey()
|
||||
@@ -444,9 +442,7 @@ class CashuMintOperations(
|
||||
}
|
||||
}
|
||||
|
||||
Log.i("CashuTrace") { "restore: POST /v1/restore (req=${outputDtos.size} outputs)" }
|
||||
val response = client.restore(RestoreRequestDto(outputs = outputDtos))
|
||||
Log.i("CashuTrace") { "restore: decoded sigs=${response.signatures.size} echoes=${response.outputs.size}" }
|
||||
if (response.signatures.isEmpty()) {
|
||||
emptyStreak++
|
||||
} else {
|
||||
@@ -458,10 +454,8 @@ class CashuMintOperations(
|
||||
// 63× per counter (once per denomination we probed).
|
||||
//
|
||||
// Dedupe to one (counter → signature) pair BEFORE the
|
||||
// unblind loop. This trims a ~378-iteration mostly-no-op
|
||||
// loop down to ~6 real unblinds per batch — both faster
|
||||
// and easier on the ART JIT, which kept trying to compile
|
||||
// the wide hot loop and crashing on Android 15+.
|
||||
// unblind loop so a typical restore batch shrinks from
|
||||
// ~378 iterations to ~6 real unblinds.
|
||||
val uniqueByCounter = LinkedHashMap<Long, Pair<CounterMaterials, BlindSignatureDto>>()
|
||||
for (i in response.signatures.indices) {
|
||||
val echo = response.outputs.getOrNull(i) ?: continue
|
||||
@@ -469,7 +463,6 @@ class CashuMintOperations(
|
||||
if (mat.counter in uniqueByCounter) continue
|
||||
uniqueByCounter[mat.counter] = mat to response.signatures[i]
|
||||
}
|
||||
Log.i("CashuTrace") { "restore: deduped to ${uniqueByCounter.size} unique counter(s)" }
|
||||
for ((c, pair) in uniqueByCounter) {
|
||||
val (mat, sig) = pair
|
||||
val output = BlindOutput(sig.amount, keysetId, mat.r, mat.secretHex, mat.bTick)
|
||||
@@ -477,7 +470,6 @@ class CashuMintOperations(
|
||||
recovered += RecoveredProof(proof, c)
|
||||
if (c > highestSeenCounter) highestSeenCounter = c
|
||||
}
|
||||
Log.i("CashuTrace") { "restore: batch unblinded" }
|
||||
}
|
||||
|
||||
counter += effectiveBatchSize
|
||||
@@ -533,8 +525,6 @@ class CashuMintOperations(
|
||||
if (proofs.isEmpty()) return true
|
||||
// Fetch all keysets the mint exposes so cross-keyset tokens
|
||||
// verify against the right amount key. Cheap — one round-trip.
|
||||
// Bdhke.verifyDleqCarol uses a thread-local scratchpad
|
||||
// internally so the loop is allocation-free.
|
||||
val allKeysets = client.activeKeysets().keysets.associateBy { it.id }
|
||||
for (proof in proofs) {
|
||||
val dleq = proof.dleq ?: continue
|
||||
@@ -570,8 +560,6 @@ class CashuMintOperations(
|
||||
suspend fun checkStates(proofs: List<CashuProof>): Map<String, ProofState> {
|
||||
if (proofs.isEmpty()) return emptyMap()
|
||||
// NUT-07 keys check requests by `Y` (hash-to-curve of the secret).
|
||||
// Bdhke.hashToCurveCompressed uses a thread-local scratchpad
|
||||
// internally so the per-proof loop is allocation-free.
|
||||
val ys = proofs.map { Bdhke.hashToCurveCompressed(it.secret.encodeToByteArray()).toHexKey() }
|
||||
val response = client.checkState(CheckStateRequestDto(ys = ys))
|
||||
val secretByY =
|
||||
@@ -614,7 +602,6 @@ class CashuMintOperations(
|
||||
// [secretFactory] decides whether those bytes are pure-random or
|
||||
// NUT-13-derived from a wallet seed; either way the on-wire shape
|
||||
// is identical so the mint can't tell which scheme we're using.
|
||||
// Bdhke.blind uses a thread-local scratchpad internally.
|
||||
val derived = secretFactory.nextSecrets(keyset.id, amounts.size)
|
||||
return amounts.mapIndexed { i, amount ->
|
||||
val pair = derived[i]
|
||||
@@ -649,9 +636,6 @@ class CashuMintOperations(
|
||||
"Got ${signatures.size} signatures for ${outputs.size} outputs",
|
||||
)
|
||||
}
|
||||
// Bdhke.unblind uses a thread-local scratchpad internally so
|
||||
// each call on this thread reuses the same Fe4 / MutablePoint
|
||||
// holders — no per-iteration allocation pressure on the JIT.
|
||||
val out = ArrayList<CashuProof>(outputs.size)
|
||||
for (i in outputs.indices) {
|
||||
out += unblindOne(outputs[i], signatures[i], keyset)
|
||||
|
||||
-28
@@ -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()
|
||||
Reference in New Issue
Block a user