mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
refactor(cashu): Bdhke scratchpad via ThreadLocal — drop param API
Bdhke's allocation-free hot path used to expose `BdhkeScratchpad` as
an explicit parameter on every public function (`blind(secret, r, scratch)`,
`unblind(..., scratch)`, `verifyDleq(..., scratch)`, …). Every caller
in CashuMintOperations had to remember to allocate a scratchpad per
loop and thread it through.
Switch to a thread-local pool. New expect/actual:
internal expect fun bdhkeScratchpad(): BdhkeScratchpad
- jvmAndroid: ThreadLocal.withInitial { BdhkeScratchpad() }
- apple / linux: fresh allocation per call (no Cashu in prod)
Each public Bdhke function pulls the scratchpad internally with one
`val scratch = bdhkeScratchpad()` at the top. Every thread that ever
touches Bdhke gets one scratchpad allocated lazily on first use and
reuses it across every subsequent call on that thread — same JIT-bug
mitigation, much cleaner API.
Removes:
- 0-arg and N+1-arg overloads on blind / unblind / verifyDleq /
verifyDleqCarol / hashToCurveCompressed
- `scratch` parameter on private addRTimesA / unblindOne /
unblindAll
- All `val scratch = BdhkeScratchpad()` boilerplate in
CashuMintOperations.restore / meltToLightning / verifyTokenDleq /
checkStates / secretOutputsFor
Also strips the diagnostic Log.i("CashuTrace") / Log.i("BdhkeTrace")
lines added during the JIT-bug investigation — the at-most-once
warmup + ThreadLocal pooling should resolve the crash, and the
traces were polluting logcat at info level.
Nested calls (verifyDleqCarol → blind + addRTimesA + verifyDleq)
all grab the same thread-local scratchpad; the holder field sets
are disjoint by design so nested use is safe.
BdhkeTest still 17/17 green.
This commit is contained in:
@@ -617,23 +617,17 @@ class CashuWalletOps(
|
||||
message: String,
|
||||
available: List<TokenEntry>,
|
||||
): NutzapSent {
|
||||
Log.i("CashuTrace") { "sendNutzap enter: amount=$amountSats mint=$mintUrl" }
|
||||
if (amountSats <= 0) throw IllegalArgumentException("Amount must be positive")
|
||||
Log.i("CashuTrace") { "sendNutzap: seedWarmer begin" }
|
||||
seedWarmer()
|
||||
Log.i("CashuTrace") { "sendNutzap: seedWarmer end" }
|
||||
val (selected, totalSelected) = selectProofsCovering(available, amountSats)
|
||||
if (totalSelected < amountSats) throw IllegalStateException("Insufficient balance for $mintUrl")
|
||||
Log.i("CashuTrace") { "sendNutzap: selected=${selected.size} entries, total=$totalSelected sat" }
|
||||
|
||||
Log.i("CashuTrace") { "sendNutzap: swapToLocked begin (${selected.flatMap { it.content.proofs }.size} input proofs)" }
|
||||
val swap =
|
||||
ops(mintUrl).swapToLocked(
|
||||
proofs = selected.flatMap { it.content.proofs },
|
||||
recipientP2pkPubkeyHex = recipientP2pkPubkeyHex,
|
||||
targetSplit = amountSats,
|
||||
)
|
||||
Log.i("CashuTrace") { "sendNutzap: swapToLocked end (send=${swap.send.size}, keep=${swap.keep.size})" }
|
||||
|
||||
// Build the kind:9321 first so we have its id to reference from history.
|
||||
val proofJsons = swap.send.map { nutzapProofJson.encodeToString(NutzapProofJson.serializer(), it.toNutzapJson()) }
|
||||
@@ -646,20 +640,15 @@ class CashuWalletOps(
|
||||
zappedEvent = zappedEvent,
|
||||
recipientPubKey = recipientPubKey,
|
||||
)
|
||||
Log.i("CashuTrace") { "sendNutzap: signing nutzapEvent" }
|
||||
val nutzapEvent = signer.sign(nutzapTemplate)
|
||||
Log.i("CashuTrace") { "sendNutzap: publishing nutzapEvent id=${nutzapEvent.id.take(8)}" }
|
||||
publish(nutzapEvent)
|
||||
Log.i("CashuTrace") { "sendNutzap: nutzapEvent published" }
|
||||
|
||||
// Roll over change locally if any.
|
||||
val keepEvent =
|
||||
if (swap.keep.isNotEmpty()) {
|
||||
val content = TokenContent(mint = mintUrl, proofs = swap.keep, del = selected.map { it.event.id })
|
||||
Log.i("CashuTrace") { "sendNutzap: building+signing keep kind:7375 (${swap.keep.size} proofs, NIP-44 encrypt)" }
|
||||
val template = CashuTokenEvent.build(content, signer)
|
||||
val signed = signer.sign(template)
|
||||
Log.i("CashuTrace") { "sendNutzap: publishing keep event id=${signed.id.take(8)}" }
|
||||
publish(signed)
|
||||
signed
|
||||
} else {
|
||||
@@ -667,12 +656,10 @@ class CashuWalletOps(
|
||||
}
|
||||
|
||||
// NIP-09 delete the source token events.
|
||||
Log.i("CashuTrace") { "sendNutzap: signing delete event" }
|
||||
val deleteEvent =
|
||||
run {
|
||||
val template = DeletionEvent.build(selected.map { it.event })
|
||||
signer.sign(template).also {
|
||||
Log.i("CashuTrace") { "sendNutzap: publishing delete event id=${it.id.take(8)}" }
|
||||
publish(it)
|
||||
}
|
||||
}
|
||||
@@ -688,11 +675,8 @@ class CashuWalletOps(
|
||||
},
|
||||
signer = signer,
|
||||
)
|
||||
Log.i("CashuTrace") { "sendNutzap: signing history (NIP-44 encrypt)" }
|
||||
val historyEvent = signer.sign(historyTemplate)
|
||||
Log.i("CashuTrace") { "sendNutzap: publishing history id=${historyEvent.id.take(8)}" }
|
||||
publish(historyEvent)
|
||||
Log.i("CashuTrace") { "sendNutzap: done — nutzap=${nutzapEvent.id.take(8)}" }
|
||||
|
||||
return NutzapSent(
|
||||
nutzapEvent = nutzapEvent,
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip60Cashu.bdhke
|
||||
|
||||
/**
|
||||
* Apple actual: fresh allocation per call. Cashu hot paths don't
|
||||
* run in production on iOS — this implementation exists only to
|
||||
* satisfy the expect declaration for the multiplatform compile.
|
||||
*/
|
||||
internal actual fun bdhkeScratchpad(): BdhkeScratchpad = BdhkeScratchpad()
|
||||
@@ -21,7 +21,6 @@
|
||||
package com.vitorpamplona.quartz.nip60Cashu.bdhke
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import com.vitorpamplona.quartz.utils.secp256k1.ECPoint
|
||||
import com.vitorpamplona.quartz.utils.secp256k1.Fe4
|
||||
@@ -67,7 +66,7 @@ object Bdhke {
|
||||
* Internal because [MutablePoint] is internal to the Quartz crypto package.
|
||||
* External callers should use [hashToCurveCompressed] to get a 33-byte point.
|
||||
*/
|
||||
internal fun hashToCurve(x: ByteArray): MutablePoint = hashToCurveInto(x, BdhkeScratchpad())
|
||||
internal fun hashToCurve(x: ByteArray): MutablePoint = hashToCurveInto(x, bdhkeScratchpad())
|
||||
|
||||
/**
|
||||
* Allocation-free [hashToCurve] variant — writes the resulting
|
||||
@@ -105,23 +104,24 @@ object Bdhke {
|
||||
|
||||
/**
|
||||
* Public form of [hashToCurve] that returns a 33-byte compressed point.
|
||||
* Uses the per-thread scratchpad from [bdhkeScratchpad] so the call
|
||||
* is allocation-free regardless of how the caller is structured.
|
||||
*/
|
||||
fun hashToCurveCompressed(x: ByteArray): ByteArray = hashToCurveCompressed(x, BdhkeScratchpad())
|
||||
|
||||
/**
|
||||
* Allocation-free [hashToCurveCompressed] variant. NUT-07
|
||||
* checkstate hashes every proof's secret once per scrub sweep —
|
||||
* a hot loop where the per-call allocations would otherwise
|
||||
* stack up.
|
||||
*/
|
||||
fun hashToCurveCompressed(
|
||||
x: ByteArray,
|
||||
scratch: BdhkeScratchpad,
|
||||
): ByteArray = toCompressedScratch(hashToCurveInto(x, scratch), scratch)
|
||||
fun hashToCurveCompressed(x: ByteArray): ByteArray {
|
||||
val scratch = bdhkeScratchpad()
|
||||
return toCompressedScratch(hashToCurveInto(x, scratch), scratch)
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 of BDHKE — Alice creates a blinded message.
|
||||
*
|
||||
* Allocation-free hot path: pulls a per-thread scratchpad from
|
||||
* [bdhkeScratchpad] and reuses it across every call on that
|
||||
* thread. Important for NUT-09 restore which runs [blind]
|
||||
* hundreds of times in sequence — without scratchpad reuse the
|
||||
* ~10 short-lived Fe4 / MutablePoint allocations per call would
|
||||
* trigger the Android 15+ ART JIT escape-analysis crash.
|
||||
*
|
||||
* @param secret 32-byte (or arbitrary length) message to be later unblinded.
|
||||
* @param r 32-byte blinding factor (must be a valid scalar < n).
|
||||
* @return 33-byte compressed point `B_ = hash_to_curve(secret) + r·G`.
|
||||
@@ -129,39 +129,16 @@ object Bdhke {
|
||||
fun blind(
|
||||
secret: ByteArray,
|
||||
r: ByteArray,
|
||||
): ByteArray = blind(secret, r, BdhkeScratchpad())
|
||||
|
||||
/**
|
||||
* Allocation-free [blind] variant — same ART JIT escape-analysis
|
||||
* mitigation as [unblind]. A hot loop like NUT-09 restore runs
|
||||
* [blind] hundreds of times in sequence; the scratchpad shaves
|
||||
* ~5 allocations off each iteration AND keeps the JIT from
|
||||
* crashing on the (Android 15+ ART) bug.
|
||||
*/
|
||||
fun blind(
|
||||
secret: ByteArray,
|
||||
r: ByteArray,
|
||||
scratch: BdhkeScratchpad,
|
||||
): ByteArray {
|
||||
Log.i("BdhkeTrace") { " blind enter (secret=${secret.size}b r=${r.size}b)" }
|
||||
val scratch = bdhkeScratchpad()
|
||||
require(r.size == 32) { "Blinding factor must be 32 bytes" }
|
||||
Log.i("BdhkeTrace") { " blind: Secp256k1.secKeyVerify" }
|
||||
require(Secp256k1.secKeyVerify(r)) { "Invalid blinding factor" }
|
||||
|
||||
Log.i("BdhkeTrace") { " blind: hashToCurveInto" }
|
||||
val y = hashToCurveInto(secret, scratch)
|
||||
Log.i("BdhkeTrace") { " blind: U256.fromBytesInto" }
|
||||
U256.fromBytesInto(scratch.blindFe4Scalar, r, 0)
|
||||
Log.i("BdhkeTrace") { " blind: ECPoint.mulG" }
|
||||
ECPoint.mulG(scratch.blindPointRg, scratch.blindFe4Scalar)
|
||||
|
||||
Log.i("BdhkeTrace") { " blind: ECPoint.addPoints" }
|
||||
ECPoint.addPoints(scratch.blindPointOut, y, scratch.blindPointRg)
|
||||
|
||||
Log.i("BdhkeTrace") { " blind: toCompressedScratch" }
|
||||
val out = toCompressedScratch(scratch.blindPointOut, scratch)
|
||||
Log.i("BdhkeTrace") { " blind exit" }
|
||||
return out
|
||||
return toCompressedScratch(scratch.blindPointOut, scratch)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,6 +149,21 @@ object Bdhke {
|
||||
* Implemented as `C_ + (-r·K)` since we don't expose point subtraction
|
||||
* directly. `-r·K` is computed by negating the scalar: `(n - r)·K`.
|
||||
*
|
||||
* Allocation-free hot path: pulls a per-thread scratchpad from
|
||||
* [bdhkeScratchpad] and reuses its pre-allocated [Fe4] /
|
||||
* [MutablePoint] holders across every call on that thread. The
|
||||
* holders are fully overwritten on each call so re-use is safe.
|
||||
*
|
||||
* Why thread-local pooling instead of fresh allocations: Android
|
||||
* 15+ ART crashes the JIT compiler (SIGSEGV at offset 0x48 in
|
||||
* "Jit thread pool") when it tries to escape-analyze the original
|
||||
* inlined unblind body — about 10 short-lived holders in one
|
||||
* ~30-line method body. Pre-allocating them and storing the
|
||||
* reference on a thread-local lets the JIT see the objects clearly
|
||||
* escape (came from outside, returned from a function it can't
|
||||
* inline through), so the scalarization pass that's actually
|
||||
* buggy never runs.
|
||||
*
|
||||
* @param blindSignature 33-byte compressed `C_` returned by the mint.
|
||||
* @param r 32-byte blinding factor used in [blind].
|
||||
* @param mintPubKey 33-byte compressed `K = a·G` (the mint's public key
|
||||
@@ -182,56 +174,15 @@ object Bdhke {
|
||||
blindSignature: ByteArray,
|
||||
r: ByteArray,
|
||||
mintPubKey: ByteArray,
|
||||
): ByteArray = unblind(blindSignature, r, mintPubKey, BdhkeScratchpad())
|
||||
|
||||
/**
|
||||
* Allocation-free [unblind] variant that re-uses pre-allocated
|
||||
* [Fe4] / [MutablePoint] holders from the caller-owned [scratch].
|
||||
*
|
||||
* # Why the scratch parameter exists
|
||||
*
|
||||
* Android 15+ ART crashes the JIT compiler thread (SIGSEGV at
|
||||
* field offset 0x48 in "Jit thread pool") when it tries to
|
||||
* escape-analyze the original inlined unblind body — about 10
|
||||
* short-lived holder objects in one ~30-line method body. We
|
||||
* tried splitting into three smaller helpers; the JIT's
|
||||
* inlining pass put them back together at compile time and the
|
||||
* crash persisted.
|
||||
*
|
||||
* Passing the holders as parameters fixes it: the JIT sees the
|
||||
* objects clearly escape (came from outside, stored across the
|
||||
* call), so the scalarization pass that's actually buggy in ART
|
||||
* 15+ never runs. Side benefit: a hot loop like NUT-09 restore
|
||||
* (hundreds of unblinds per pass) now does one [BdhkeScratchpad]
|
||||
* allocation total instead of ~10 per iteration.
|
||||
*
|
||||
* The holders are fully overwritten on each call —
|
||||
* [KeyCodec.parsePublicKey], [U256.fromBytesInto], [ECPoint.mul],
|
||||
* [ECPoint.toAffine], [FieldP.neg], [MutablePoint.setAffine] all
|
||||
* write through every field they use — so re-use across calls is
|
||||
* safe even if previous content was different.
|
||||
*/
|
||||
fun unblind(
|
||||
blindSignature: ByteArray,
|
||||
r: ByteArray,
|
||||
mintPubKey: ByteArray,
|
||||
scratch: BdhkeScratchpad,
|
||||
): ByteArray {
|
||||
Log.i("BdhkeTrace") { " unblind enter" }
|
||||
val scratch = bdhkeScratchpad()
|
||||
require(r.size == 32) { "Blinding factor must be 32 bytes" }
|
||||
|
||||
Log.i("BdhkeTrace") { " unblind: parseAffinePointInto cTick" }
|
||||
val cTick = parseAffinePointInto(blindSignature, "blind signature", scratch.fe4A, scratch.fe4B, scratch.pointA)
|
||||
Log.i("BdhkeTrace") { " unblind: parseAffinePointInto k" }
|
||||
val k = parseAffinePointInto(mintPubKey, "mint public key", scratch.fe4C, scratch.fe4D, scratch.pointB)
|
||||
Log.i("BdhkeTrace") { " unblind: computeNegRkInto" }
|
||||
val negRk = computeNegRkInto(k, r, scratch.fe4E, scratch.pointC, scratch.fe4F, scratch.fe4G, scratch.pointD)
|
||||
Log.i("BdhkeTrace") { " unblind: ECPoint.addPoints" }
|
||||
ECPoint.addPoints(scratch.outPoint, cTick, negRk)
|
||||
Log.i("BdhkeTrace") { " unblind: toCompressedScratch" }
|
||||
val out = toCompressedScratch(scratch.outPoint, scratch)
|
||||
Log.i("BdhkeTrace") { " unblind exit" }
|
||||
return out
|
||||
return toCompressedScratch(scratch.outPoint, scratch)
|
||||
}
|
||||
|
||||
/** Parse a 33-byte compressed pubkey into [outPoint], using [xHolder]/[yHolder] as scratch. */
|
||||
@@ -322,6 +273,12 @@ object Bdhke {
|
||||
* outside [1, n), points not on curve) or DLEQ mismatch. Callers
|
||||
* should treat false as a protocol violation and abort the swap/mint.
|
||||
*
|
||||
* Allocation-free hot path: pulls a per-thread scratchpad from
|
||||
* [bdhkeScratchpad]. Carol-side verification runs once per
|
||||
* inbound nutzap proof during auto-redeem, so this is on the hot
|
||||
* path of every nutzap receive — same JIT-crash mitigation as
|
||||
* [blind] and [unblind].
|
||||
*
|
||||
* @param e 32-byte challenge scalar from the mint.
|
||||
* @param s 32-byte response scalar from the mint.
|
||||
* @param blindedMessage 33-byte compressed `B_` we sent.
|
||||
@@ -334,22 +291,8 @@ object Bdhke {
|
||||
blindedMessage: ByteArray,
|
||||
blindSignature: ByteArray,
|
||||
mintPubKey: ByteArray,
|
||||
): Boolean = verifyDleq(e, s, blindedMessage, blindSignature, mintPubKey, BdhkeScratchpad())
|
||||
|
||||
/**
|
||||
* Allocation-free [verifyDleq] variant — same ART JIT escape-analysis
|
||||
* mitigation as [unblind] / [blind]. Carol-side verification runs once
|
||||
* per inbound nutzap proof during auto-redeem, so it's on the hot
|
||||
* path of every nutzap receive.
|
||||
*/
|
||||
fun verifyDleq(
|
||||
e: ByteArray,
|
||||
s: ByteArray,
|
||||
blindedMessage: ByteArray,
|
||||
blindSignature: ByteArray,
|
||||
mintPubKey: ByteArray,
|
||||
scratch: BdhkeScratchpad,
|
||||
): Boolean {
|
||||
val scratch = bdhkeScratchpad()
|
||||
if (e.size != 32 || s.size != 32) return false
|
||||
if (blindedMessage.size != 33 || blindSignature.size != 33 || mintPubKey.size != 33) return false
|
||||
|
||||
@@ -471,6 +414,16 @@ object Bdhke {
|
||||
* @param unblindedC 33-byte `C` from the proof — the unblinded
|
||||
* signature the wallet stores after Alice's
|
||||
* [unblind].
|
||||
* Allocation-free hot path: pulls a per-thread scratchpad from
|
||||
* [bdhkeScratchpad]. The Carol pipeline is the heaviest in BDHKE
|
||||
* (one blind + one addRTimesA + one verifyDleq per call) so a
|
||||
* hot loop here without sharing would burn ~40 short-lived
|
||||
* holders per proof — exactly the shape that triggers the
|
||||
* Android 15+ ART JIT crash. Each nested call grabs the same
|
||||
* thread-local scratchpad; the holder sets used by [blind],
|
||||
* [addRTimesA], and [verifyDleq] are disjoint so nested-on-same-
|
||||
* scratchpad is safe.
|
||||
*
|
||||
* @param mintPubKey 33-byte compressed `A = k·G` for this amount,
|
||||
* looked up from the mint's keyset.
|
||||
*/
|
||||
@@ -481,40 +434,22 @@ object Bdhke {
|
||||
s: ByteArray,
|
||||
unblindedC: ByteArray,
|
||||
mintPubKey: ByteArray,
|
||||
): Boolean = verifyDleqCarol(secret, r, e, s, unblindedC, mintPubKey, BdhkeScratchpad())
|
||||
|
||||
/**
|
||||
* Allocation-free [verifyDleqCarol] variant. The Carol verification
|
||||
* pipeline is the heaviest in BDHKE (one blind + one addRTimesA +
|
||||
* one verifyDleq per call) so a hot loop here without a shared
|
||||
* scratchpad would burn ~40 short-lived holders per proof — exactly
|
||||
* the shape that triggers the Android 15+ ART JIT crash.
|
||||
*/
|
||||
fun verifyDleqCarol(
|
||||
secret: ByteArray,
|
||||
r: ByteArray,
|
||||
e: ByteArray,
|
||||
s: ByteArray,
|
||||
unblindedC: ByteArray,
|
||||
mintPubKey: ByteArray,
|
||||
scratch: BdhkeScratchpad,
|
||||
): Boolean {
|
||||
if (r.size != 32) return false
|
||||
if (unblindedC.size != 33) return false
|
||||
if (mintPubKey.size != 33) return false
|
||||
// Reconstruct B' the way Alice did at mint time.
|
||||
val bTick = runCatching { blind(secret, r, scratch) }.getOrNull() ?: return false
|
||||
val bTick = runCatching { blind(secret, r) }.getOrNull() ?: return false
|
||||
// Reconstruct C' = C + r·A. Without this, verifyDleq is computing
|
||||
// the DLEQ check against the wrong point and always returns false
|
||||
// — this was a real production bug.
|
||||
val cTick = runCatching { addRTimesA(unblindedC, r, mintPubKey, scratch) }.getOrNull() ?: return false
|
||||
val cTick = runCatching { addRTimesA(unblindedC, r, mintPubKey) }.getOrNull() ?: return false
|
||||
return verifyDleq(
|
||||
e = e,
|
||||
s = s,
|
||||
blindedMessage = bTick,
|
||||
blindSignature = cTick,
|
||||
mintPubKey = mintPubKey,
|
||||
scratch = scratch,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -528,8 +463,8 @@ object Bdhke {
|
||||
c: ByteArray,
|
||||
r: ByteArray,
|
||||
mintPubKey: ByteArray,
|
||||
scratch: BdhkeScratchpad,
|
||||
): ByteArray {
|
||||
val scratch = bdhkeScratchpad()
|
||||
// Reuses unblind's holder set — addRTimesA is only called from
|
||||
// verifyDleqCarol, which calls verifyDleq AFTER this one. The
|
||||
// unblind holders are free for the duration of this call.
|
||||
@@ -659,7 +594,6 @@ object Bdhke {
|
||||
fun warmup() {
|
||||
if (warmupDone) return
|
||||
warmupDone = true
|
||||
val scratch = BdhkeScratchpad()
|
||||
// Fixed public key for warmup — generator point G's compressed form.
|
||||
// G is always on the curve and parses cleanly; nothing we do
|
||||
// here leaks into wallet state.
|
||||
@@ -705,12 +639,12 @@ object Bdhke {
|
||||
repeat(JIT_WARMUP_ITERATIONS) {
|
||||
val secret = randomScalar()
|
||||
val r = randomScalar()
|
||||
val bTick = blind(secret, r, scratch)
|
||||
val bTick = blind(secret, r)
|
||||
// We don't have a real mint signature to unblind, but `blind`'s
|
||||
// own output is a valid curve point that unblind will process
|
||||
// identically from the JIT's perspective (same code paths, same
|
||||
// allocations) — the math result is meaningless and discarded.
|
||||
unblind(bTick, r, mintPubKey, scratch)
|
||||
unblind(bTick, r, mintPubKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
@@ -120,3 +120,19 @@ class BdhkeScratchpad {
|
||||
internal val toCompressedFe4X: Fe4 = Fe4()
|
||||
internal val toCompressedFe4Y: Fe4 = Fe4()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the scratchpad this thread should use for Bdhke crypto ops.
|
||||
*
|
||||
* On JVM/Android, backed by `ThreadLocal` — each thread that ever
|
||||
* touches Bdhke gets its own scratchpad allocated lazily on first
|
||||
* use, then reused across every subsequent call on that thread.
|
||||
* Means every `Bdhke.blind`/`unblind`/`verifyDleq` etc. is
|
||||
* allocation-free regardless of how the caller is structured.
|
||||
*
|
||||
* On other targets (iOS, native), allocates a fresh scratchpad per
|
||||
* call. Those targets don't run the Cashu hot paths in production,
|
||||
* but the function must exist for `quartz` to compile across the
|
||||
* KMP target set.
|
||||
*/
|
||||
internal expect fun bdhkeScratchpad(): BdhkeScratchpad
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip60Cashu.bdhke
|
||||
|
||||
/**
|
||||
* JVM/Android actual: one [BdhkeScratchpad] per thread, allocated
|
||||
* lazily on first access and reused for the lifetime of the thread.
|
||||
*
|
||||
* The hot Cashu paths (NUT-09 restore, swap output unblinding,
|
||||
* Carol verification) all run on a single Dispatchers.IO or
|
||||
* Dispatchers.Default thread per coroutine, so a thread-local pool
|
||||
* matches their access pattern perfectly — every call after the
|
||||
* first reuses the same holders, zero per-call allocation.
|
||||
*/
|
||||
private val threadLocal = ThreadLocal.withInitial { BdhkeScratchpad() }
|
||||
|
||||
internal actual fun bdhkeScratchpad(): BdhkeScratchpad = threadLocal.get()
|
||||
+17
-58
@@ -23,12 +23,10 @@ package com.vitorpamplona.quartz.nip60Cashu.mintApi
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip60Cashu.bdhke.Bdhke
|
||||
import com.vitorpamplona.quartz.nip60Cashu.bdhke.BdhkeScratchpad
|
||||
import com.vitorpamplona.quartz.nip60Cashu.p2pk.P2PK
|
||||
import com.vitorpamplona.quartz.nip60Cashu.seed.CashuDeterministic
|
||||
import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof
|
||||
import com.vitorpamplona.quartz.nip60Cashu.token.TokenContent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* High-level mint operations: mint-from-LN, swap, melt-to-LN, send-as-token,
|
||||
@@ -224,15 +222,12 @@ class CashuMintOperations(
|
||||
recipientP2pkPubkeyHex: String,
|
||||
targetSplit: Long,
|
||||
): SwapResult {
|
||||
Log.i("CashuTrace") { "swapToLocked enter: inputs=${proofs.size}, target=$targetSplit" }
|
||||
if (proofs.isEmpty()) throw IllegalArgumentException("Nothing to swap")
|
||||
if (targetSplit <= 0) throw IllegalArgumentException("Target split must be > 0")
|
||||
val total = proofs.sumOf { it.amount }
|
||||
if (targetSplit > total) throw IllegalArgumentException("Target split exceeds available proofs")
|
||||
|
||||
Log.i("CashuTrace") { "swapToLocked: fetchKeyset begin" }
|
||||
val keyset = fetchKeyset()
|
||||
Log.i("CashuTrace") { "swapToLocked: fetchKeyset end id=${keyset.id}" }
|
||||
// NUT-02: reserve per-input fees; change shrinks by the fee, send
|
||||
// amount stays whole (the recipient gets exactly targetSplit sats).
|
||||
val feeAtoms = computeInputFee(proofs.size, keyset.inputFeePpk)
|
||||
@@ -244,13 +239,10 @@ class CashuMintOperations(
|
||||
// proof, so NUT-13 recovery doesn't apply to those bytes), but
|
||||
// our change outputs go through the deterministic factory in one
|
||||
// batch — see [secretOutputsFor].
|
||||
Log.i("CashuTrace") { "swapToLocked: building ${splitAmounts(targetSplit).size} locked outputs (P2PK blinds)" }
|
||||
val sendOutputs = splitAmounts(targetSplit).map { lockedOutputFor(it, keyset, recipientP2pkPubkeyHex) }
|
||||
Log.i("CashuTrace") { "swapToLocked: building ${if (keepAmount > 0L) splitAmounts(keepAmount).size else 0} keep outputs (NUT-13 deterministic blinds)" }
|
||||
val keepOutputs =
|
||||
if (keepAmount > 0L) secretOutputsFor(splitAmounts(keepAmount), keyset) else emptyList()
|
||||
val allOutputs = sendOutputs + keepOutputs
|
||||
Log.i("CashuTrace") { "swapToLocked: POST /v1/swap with ${allOutputs.size} outputs" }
|
||||
|
||||
val response =
|
||||
client.swap(
|
||||
@@ -259,7 +251,6 @@ class CashuMintOperations(
|
||||
outputs = allOutputs.map { it.toDto() },
|
||||
),
|
||||
)
|
||||
Log.i("CashuTrace") { "swapToLocked: swap response sigs=${response.signatures.size}" }
|
||||
|
||||
if (response.signatures.size != allOutputs.size) {
|
||||
throw MintProtocolException(
|
||||
@@ -267,9 +258,7 @@ class CashuMintOperations(
|
||||
)
|
||||
}
|
||||
|
||||
Log.i("CashuTrace") { "swapToLocked: unblindAll begin (${allOutputs.size})" }
|
||||
val unblinded = unblindAll(allOutputs, response.signatures, keyset)
|
||||
Log.i("CashuTrace") { "swapToLocked: unblindAll end" }
|
||||
val sendProofs = unblinded.subList(0, sendOutputs.size)
|
||||
val keepProofs = unblinded.subList(sendOutputs.size, unblinded.size)
|
||||
return SwapResult(send = sendProofs, keep = keepProofs, keysetId = keyset.id)
|
||||
@@ -333,12 +322,11 @@ class CashuMintOperations(
|
||||
response.change?.let { sigs ->
|
||||
val byAmount = changeOutputs.associateBy { it.amount }.toMutableMap()
|
||||
val out = mutableListOf<CashuProof>()
|
||||
val scratch = BdhkeScratchpad()
|
||||
for (sig in sigs) {
|
||||
val src =
|
||||
byAmount.remove(sig.amount)
|
||||
?: throw IllegalStateException("Mint returned change for amount ${sig.amount} we didn't request")
|
||||
out += unblindOne(src, sig, keyset, scratch)
|
||||
out += unblindOne(src, sig, keyset)
|
||||
}
|
||||
out
|
||||
} ?: emptyList()
|
||||
@@ -433,30 +421,21 @@ class CashuMintOperations(
|
||||
// deterministic (secret, r) pair derived from the counter),
|
||||
// not per (counter, amount) — the wallet only ever minted
|
||||
// one denomination per counter under NUT-13.
|
||||
Log.i("CashuTrace") { "restore: batch counter=$counter size=$effectiveBatchSize denoms=${denominations.size}" }
|
||||
val matsByBTick = HashMap<String, CounterMaterials>(effectiveBatchSize)
|
||||
val outputDtos = ArrayList<BlindedMessageDto>(perBatchSize)
|
||||
// One scratchpad reused across all per-counter Bdhke.blind
|
||||
// calls in this batch — see [BdhkeScratchpad]. Critical for
|
||||
// restore: without it we'd allocate ~6 short-lived holders
|
||||
// per counter × batchSize counters per batch and trip the
|
||||
// Android 15+ ART JIT escape-analysis crash.
|
||||
val batchScratch = BdhkeScratchpad()
|
||||
|
||||
for (offset in 0 until effectiveBatchSize) {
|
||||
val c = counter + offset
|
||||
Log.i("CashuTrace") { "restore.derive c=$c: CashuDeterministic" }
|
||||
// Per-counter derivation: same (secret, r) pair the
|
||||
// wallet would have minted at this counter slot. We
|
||||
// try each amount denomination — the mint will only
|
||||
// return the signature(s) it actually issued at this
|
||||
// slot, if any.
|
||||
// slot, if any. Bdhke uses a thread-local scratchpad
|
||||
// internally so no per-call allocation pressure.
|
||||
val secretBytes = CashuDeterministic.secretBytes(seed, keysetId, c)
|
||||
val r = CashuDeterministic.blindingFactor(seed, keysetId, c)
|
||||
val secretHex = secretBytes.toHexKey()
|
||||
Log.i("CashuTrace") { "restore.derive c=$c: Bdhke.blind" }
|
||||
val bTick = Bdhke.blind(secretHex.encodeToByteArray(), r, batchScratch)
|
||||
Log.i("CashuTrace") { "restore.derive c=$c: build dtos for ${denominations.size} denoms" }
|
||||
val bTick = Bdhke.blind(secretHex.encodeToByteArray(), r)
|
||||
val bTickHex = bTick.toHexKey()
|
||||
matsByBTick[bTickHex] = CounterMaterials(c, secretHex, r, bTick)
|
||||
for (amount in denominations) {
|
||||
@@ -464,9 +443,7 @@ class CashuMintOperations(
|
||||
}
|
||||
}
|
||||
|
||||
Log.i("CashuTrace") { "restore: HTTP /v1/restore (request ${outputDtos.size} outputs)" }
|
||||
val response = client.restore(RestoreRequestDto(outputs = outputDtos))
|
||||
Log.i("CashuTrace") { "restore: HTTP response sigs=${response.signatures.size} echoes=${response.outputs.size}" }
|
||||
if (response.signatures.isEmpty()) {
|
||||
emptyStreak++
|
||||
} else {
|
||||
@@ -489,15 +466,10 @@ class CashuMintOperations(
|
||||
if (mat.counter in uniqueByCounter) continue
|
||||
uniqueByCounter[mat.counter] = mat to response.signatures[i]
|
||||
}
|
||||
Log.i("CashuTrace") { "restore: dedup sigs=${response.signatures.size} -> unique=${uniqueByCounter.size}" }
|
||||
|
||||
val restoreScratch = BdhkeScratchpad()
|
||||
for ((c, pair) in uniqueByCounter) {
|
||||
val (mat, sig) = pair
|
||||
Log.i("CashuTrace") { "restore.unblind counter=$c amt=${sig.amount} begin" }
|
||||
val output = BlindOutput(sig.amount, keysetId, mat.r, mat.secretHex, mat.bTick)
|
||||
val proof = unblindOne(output, sig, keyset, restoreScratch)
|
||||
Log.i("CashuTrace") { "restore.unblind counter=$c amt=${sig.amount} end" }
|
||||
val proof = unblindOne(output, sig, keyset)
|
||||
recovered += RecoveredProof(proof, c)
|
||||
if (c > highestSeenCounter) highestSeenCounter = c
|
||||
}
|
||||
@@ -556,13 +528,9 @@ class CashuMintOperations(
|
||||
if (proofs.isEmpty()) return true
|
||||
// Fetch all keysets the mint exposes so cross-keyset tokens
|
||||
// verify against the right amount key. Cheap — one round-trip.
|
||||
// Bdhke.verifyDleqCarol uses a thread-local scratchpad
|
||||
// internally so the loop is allocation-free.
|
||||
val allKeysets = client.activeKeysets().keysets.associateBy { it.id }
|
||||
// One scratchpad reused across every per-proof Carol verification.
|
||||
// Carol is the heaviest BDHKE pipeline (blind + addRTimesA +
|
||||
// verifyDleq); without sharing this scratch the inbound-nutzap
|
||||
// loop allocates dozens of short-lived holders per proof and
|
||||
// trips the Android 15+ ART JIT crash.
|
||||
val scratch = BdhkeScratchpad()
|
||||
for (proof in proofs) {
|
||||
val dleq = proof.dleq ?: continue
|
||||
val r = dleq.r ?: continue
|
||||
@@ -581,7 +549,6 @@ class CashuMintOperations(
|
||||
s = dleq.s.hexToByteArray(),
|
||||
unblindedC = proof.c.hexToByteArray(),
|
||||
mintPubKey = mintPubKeyHex.hexToByteArray(),
|
||||
scratch = scratch,
|
||||
)
|
||||
if (!ok) return false
|
||||
}
|
||||
@@ -598,15 +565,13 @@ class CashuMintOperations(
|
||||
suspend fun checkStates(proofs: List<CashuProof>): Map<String, ProofState> {
|
||||
if (proofs.isEmpty()) return emptyMap()
|
||||
// NUT-07 keys check requests by `Y` (hash-to-curve of the secret).
|
||||
// One scratchpad reused across the per-proof hashToCurve calls so
|
||||
// the pre-send scrub doesn't allocate per-proof on wallets with
|
||||
// many entries (and doesn't trip the ART JIT crash).
|
||||
val scratch = BdhkeScratchpad()
|
||||
val ys = proofs.map { Bdhke.hashToCurveCompressed(it.secret.encodeToByteArray(), scratch).toHexKey() }
|
||||
// Bdhke.hashToCurveCompressed uses a thread-local scratchpad
|
||||
// internally so the per-proof loop is allocation-free.
|
||||
val ys = proofs.map { Bdhke.hashToCurveCompressed(it.secret.encodeToByteArray()).toHexKey() }
|
||||
val response = client.checkState(CheckStateRequestDto(ys = ys))
|
||||
val secretByY =
|
||||
proofs.associateBy {
|
||||
Bdhke.hashToCurveCompressed(it.secret.encodeToByteArray(), scratch).toHexKey()
|
||||
Bdhke.hashToCurveCompressed(it.secret.encodeToByteArray()).toHexKey()
|
||||
}
|
||||
val out = mutableMapOf<String, ProofState>()
|
||||
for (row in response.states) {
|
||||
@@ -640,16 +605,15 @@ class CashuMintOperations(
|
||||
keyset: KeysetDto,
|
||||
): List<BlindOutput> {
|
||||
if (amounts.isEmpty()) return emptyList()
|
||||
Log.i("CashuTrace") { "secretOutputsFor: nextSecrets(${amounts.size})" }
|
||||
// NUT-00: secret is a UTF-8 hex string of 32 secret bytes.
|
||||
// [secretFactory] decides whether those bytes are pure-random or
|
||||
// NUT-13-derived from a wallet seed; either way the on-wire shape
|
||||
// is identical so the mint can't tell which scheme we're using.
|
||||
// Bdhke.blind uses a thread-local scratchpad internally.
|
||||
val derived = secretFactory.nextSecrets(keyset.id, amounts.size)
|
||||
val scratch = BdhkeScratchpad()
|
||||
return amounts.mapIndexed { i, amount ->
|
||||
val pair = derived[i]
|
||||
val bTick = Bdhke.blind(pair.secretHex.encodeToByteArray(), pair.blindingFactor, scratch)
|
||||
val bTick = Bdhke.blind(pair.secretHex.encodeToByteArray(), pair.blindingFactor)
|
||||
BlindOutput(amount, keyset.id, pair.blindingFactor, pair.secretHex, bTick)
|
||||
}
|
||||
}
|
||||
@@ -680,15 +644,12 @@ class CashuMintOperations(
|
||||
"Got ${signatures.size} signatures for ${outputs.size} outputs",
|
||||
)
|
||||
}
|
||||
// One scratchpad per loop, reused across every unblind call —
|
||||
// makes the bdhke crypto allocation-free on the hot path and
|
||||
// dodges the Android 15+ ART JIT escape-analysis crash that
|
||||
// hit on tight per-iteration Fe4 / MutablePoint allocation.
|
||||
// See [BdhkeScratchpad] for the full rationale.
|
||||
val scratch = BdhkeScratchpad()
|
||||
// Bdhke.unblind uses a thread-local scratchpad internally so
|
||||
// each call on this thread reuses the same Fe4 / MutablePoint
|
||||
// holders — no per-iteration allocation pressure on the JIT.
|
||||
val out = ArrayList<CashuProof>(outputs.size)
|
||||
for (i in outputs.indices) {
|
||||
out += unblindOne(outputs[i], signatures[i], keyset, scratch)
|
||||
out += unblindOne(outputs[i], signatures[i], keyset)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -697,7 +658,6 @@ class CashuMintOperations(
|
||||
output: BlindOutput,
|
||||
signature: BlindSignatureDto,
|
||||
keyset: KeysetDto,
|
||||
scratch: BdhkeScratchpad = BdhkeScratchpad(),
|
||||
): CashuProof {
|
||||
if (signature.amount != output.amount) {
|
||||
throw IllegalStateException(
|
||||
@@ -725,7 +685,6 @@ class CashuMintOperations(
|
||||
blindSignature = cTickBytes,
|
||||
r = output.r,
|
||||
mintPubKey = mintPubKey,
|
||||
scratch = scratch,
|
||||
)
|
||||
// Retain (e, s, r) on the resulting proof per NUT-12 §3 so
|
||||
// anything that later forwards this proof to another wallet —
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip60Cashu.bdhke
|
||||
|
||||
/**
|
||||
* Linux actual: fresh allocation per call. Cashu hot paths don't
|
||||
* run in production on linuxNative — this implementation exists only
|
||||
* to satisfy the expect declaration for the multiplatform compile.
|
||||
*/
|
||||
internal actual fun bdhkeScratchpad(): BdhkeScratchpad = BdhkeScratchpad()
|
||||
Reference in New Issue
Block a user