diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/Bdhke.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/Bdhke.kt index e4dab770d9..93630fd391 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/Bdhke.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/Bdhke.kt @@ -21,6 +21,7 @@ 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 @@ -116,7 +117,7 @@ object Bdhke { fun hashToCurveCompressed( x: ByteArray, scratch: BdhkeScratchpad, - ): ByteArray = toCompressed(hashToCurveInto(x, scratch)) + ): ByteArray = toCompressedScratch(hashToCurveInto(x, scratch), scratch) /** * Step 1 of BDHKE — Alice creates a blinded message. @@ -142,16 +143,25 @@ object Bdhke { r: ByteArray, scratch: BdhkeScratchpad, ): ByteArray { + Log.i("BdhkeTrace") { " blind enter (secret=${secret.size}b r=${r.size}b)" } 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) - return toCompressed(scratch.blindPointOut) + Log.i("BdhkeTrace") { " blind: toCompressedScratch" } + val out = toCompressedScratch(scratch.blindPointOut, scratch) + Log.i("BdhkeTrace") { " blind exit" } + return out } /** @@ -207,13 +217,21 @@ object Bdhke { mintPubKey: ByteArray, scratch: BdhkeScratchpad, ): ByteArray { + Log.i("BdhkeTrace") { " unblind enter" } 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) - return toCompressed(scratch.outPoint) + Log.i("BdhkeTrace") { " unblind: toCompressedScratch" } + val out = toCompressedScratch(scratch.outPoint, scratch) + Log.i("BdhkeTrace") { " unblind exit" } + return out } /** Parse a 33-byte compressed pubkey into [outPoint], using [xHolder]/[yHolder] as scratch. */ @@ -525,7 +543,7 @@ object Bdhke { ECPoint.mul(scratch.pointC, scratch.pointB, scratch.fe4E) ECPoint.addPoints(scratch.outPoint, scratch.pointA, scratch.pointC) - return toCompressed(scratch.outPoint) + return toCompressedScratch(scratch.outPoint, scratch) } /** @@ -610,6 +628,24 @@ 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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.kt index 60af87ce86..3e7e11f3fa 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/bdhke/BdhkeScratchpad.kt @@ -109,4 +109,14 @@ class BdhkeScratchpad { 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() } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt index 5562b654dd..bec87c1aea 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt @@ -446,6 +446,7 @@ class CashuMintOperations( 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 @@ -454,7 +455,9 @@ class CashuMintOperations( 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 bTickHex = bTick.toHexKey() matsByBTick[bTickHex] = CounterMaterials(c, secretHex, r, bTick) for (amount in denominations) { @@ -462,7 +465,9 @@ 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 { @@ -476,6 +481,7 @@ class CashuMintOperations( // is the source of truth, the echoed amount is not. val restoreScratch = BdhkeScratchpad() for (i in response.signatures.indices) { + Log.i("CashuTrace") { "restore.unblind[$i/${response.signatures.size}] begin" } val echo = response.outputs.getOrNull(i) ?: continue val sig = response.signatures[i] val mat = matsByBTick[echo.bTick] ?: continue @@ -485,6 +491,7 @@ class CashuMintOperations( if (!recoveredCounters.add(mat.counter)) continue val output = BlindOutput(sig.amount, keysetId, mat.r, mat.secretHex, mat.bTick) val proof = unblindOne(output, sig, keyset, restoreScratch) + Log.i("CashuTrace") { "restore.unblind[$i/${response.signatures.size}] end (counter=${mat.counter} amt=${sig.amount})" } recovered += RecoveredProof(proof, mat.counter) if (mat.counter > highestSeenCounter) highestSeenCounter = mat.counter }