From 9ea4b81c1f9692d6c234ca779c69e140738b49f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 00:14:17 +0000 Subject: [PATCH 1/3] feat(quartz): size-enforcing Hex.decode64/128 and encode64/128 Adds exact-size codec entry points to the Hex utility so 32-byte pubkeys/event ids (64 chars) and 64-byte signatures (128 chars) with the wrong size or invalid characters are rejected instead of silently decoded: - decode64 / decode128 throw IllegalArgumentException; the OrNull variants return null for untrusted input. - encode64 / encode128 require exactly 32 / 64 input bytes. The decode is single-pass: character validation is folded into the decode loop via a sign-bit OR-accumulator (the lookup table yields -1 for invalid chars), so it is faster than the isHex64 + decode two-pass combination. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Hwv6XwT9mwGUQc57zH4ky4 --- .../com/vitorpamplona/quartz/utils/Hex.kt | 70 +++++++++++ .../quartz/utils/HexExactSizeTest.kt | 113 ++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/HexExactSizeTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt index 49137ac8aa..18f457d6d1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt @@ -36,6 +36,8 @@ package com.vitorpamplona.quartz.utils * val hex = Hex.encode(bytes) // ByteArray -> lower-case hex * val bytes = Hex.decode(hex) // hex (any case) -> ByteArray * if (Hex.isHex64(id)) { ... } // is this a valid 32-byte hex id? + * val id = Hex.decode64(idHex) // exactly 64 chars or it throws + * val sig = Hex.decode128OrNull(sigHex) // exactly 128 chars or null * ``` */ object Hex { @@ -188,6 +190,74 @@ object Hex { } } + /** + * Decodes a 32-byte pubkey/event id, accepting only exactly 64 hex chars + * (upper or lower case). Throws [IllegalArgumentException] on any other + * length or on non-hex characters — use [decode64OrNull] for untrusted + * input. Single pass: validation is folded into the decode, so this is + * faster than `isHex64` + [decode]. + */ + fun decode64(hex: String): ByteArray = decode64OrNull(hex) ?: throw IllegalArgumentException("Invalid 64-char hex $hex") + + /** Like [decode64] but returns null instead of throwing. */ + fun decode64OrNull(hex: String): ByteArray? = if (hex.length == 64) decodeExactOrNull(hex, 32) else null + + /** + * Decodes a 64-byte value (a Schnorr signature), accepting only exactly + * 128 hex chars (upper or lower case). Throws [IllegalArgumentException] + * on any other length or on non-hex characters — use [decode128OrNull] + * for untrusted input. + */ + fun decode128(hex: String): ByteArray = decode128OrNull(hex) ?: throw IllegalArgumentException("Invalid 128-char hex $hex") + + /** Like [decode128] but returns null instead of throwing. */ + fun decode128OrNull(hex: String): ByteArray? = if (hex.length == 128) decodeExactOrNull(hex, 64) else null + + /** + * Decodes [hex] into [byteLen] bytes, or null if any char is not a hex + * digit. The caller has already checked `hex.length == 2 * byteLen`. + * Validation is free: the lookup table yields -1 for invalid chars, which + * keeps the OR-accumulator negative, so one sign check at the end covers + * every char with no branches inside the loop. + */ + private fun decodeExactOrNull( + hex: String, + byteLen: Int, + ): ByteArray? = + try { + val out = ByteArray(byteLen) + var acc = 0 + var c = 0 + for (i in 0 until byteLen) { + val b = (hexToByte[hex[c++].code] shl 4) or hexToByte[hex[c++].code] + acc = acc or b + out[i] = b.toByte() + } + if (acc < 0) null else out + } catch (_: IndexOutOfBoundsException) { + // chars above 0xFF (e.g. emoji) fall outside the lookup table + null + } + + /** + * Encodes a 32-byte pubkey/event id as a 64-char lower-case hex string. + * Throws [IllegalArgumentException] when [input] is not exactly 32 bytes. + */ + fun encode64(input: ByteArray): String { + require(input.size == 32) { "Expected 32 bytes, got ${input.size}" } + return encode(input) + } + + /** + * Encodes a 64-byte value (a Schnorr signature) as a 128-char lower-case + * hex string. Throws [IllegalArgumentException] when [input] is not + * exactly 64 bytes. + */ + fun encode128(input: ByteArray): String { + require(input.size == 64) { "Expected 64 bytes, got ${input.size}" } + return encode(input) + } + /** Encodes [input] as a lower-case hex string (two chars per byte). */ fun encode(input: ByteArray): String { val out = CharArray(input.size * 2) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/HexExactSizeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/HexExactSizeTest.kt new file mode 100644 index 0000000000..2b9f4a5c94 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/HexExactSizeTest.kt @@ -0,0 +1,113 @@ +/* + * 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.utils + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class HexExactSizeTest { + val id64 = "48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf" + val sig128 = id64 + "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9" + + @Test + fun decode64RoundTrip() { + assertEquals(id64, Hex.encode64(Hex.decode64(id64))) + assertContentEquals(Hex.decode(id64), Hex.decode64(id64)) + assertContentEquals(Hex.decode(id64), Hex.decode64OrNull(id64)) + } + + @Test + fun decode64AcceptsUpperCase() { + assertContentEquals(Hex.decode(id64), Hex.decode64(id64.uppercase())) + } + + @Test + fun decode64RejectsWrongLengths() { + assertFailsWith { Hex.decode64("") } + assertFailsWith { Hex.decode64(id64.drop(1)) } + assertFailsWith { Hex.decode64(id64.drop(2)) } + assertFailsWith { Hex.decode64(id64 + "ab") } + assertFailsWith { Hex.decode64(sig128) } + + assertNull(Hex.decode64OrNull("")) + assertNull(Hex.decode64OrNull(id64.drop(2))) + assertNull(Hex.decode64OrNull(id64 + "ab")) + assertNull(Hex.decode64OrNull(sig128)) + } + + @Test + fun decode64RejectsInvalidChars() { + // every position, both a plain non-hex char and an emoji (code > 0xFF) + for (i in 0 until 64) { + val withG = id64.substring(0, i) + "g" + id64.substring(i + 1) + assertNull(Hex.decode64OrNull(withG), withG) + assertFailsWith { Hex.decode64(withG) } + } + val withEmoji = "🥰" + id64.drop(2) + assertNull(Hex.decode64OrNull(withEmoji)) + assertFailsWith { Hex.decode64(withEmoji) } + } + + @Test + fun decode128RoundTrip() { + assertEquals(sig128, Hex.encode128(Hex.decode128(sig128))) + assertContentEquals(Hex.decode(sig128), Hex.decode128(sig128)) + assertContentEquals(Hex.decode(sig128), Hex.decode128OrNull(sig128.uppercase())) + } + + @Test + fun decode128RejectsWrongLengthsAndInvalidChars() { + assertFailsWith { Hex.decode128("") } + assertFailsWith { Hex.decode128(id64) } + assertFailsWith { Hex.decode128(sig128.drop(2)) } + assertFailsWith { Hex.decode128(sig128 + "ab") } + + assertNull(Hex.decode128OrNull(id64)) + assertNull(Hex.decode128OrNull(sig128.dropLast(1) + "x")) + assertNull(Hex.decode128OrNull("🥰" + sig128.drop(2))) + } + + @Test + fun encodeRejectsWrongSizes() { + assertFailsWith { Hex.encode64(ByteArray(31)) } + assertFailsWith { Hex.encode64(ByteArray(33)) } + assertFailsWith { Hex.encode64(ByteArray(64)) } + assertFailsWith { Hex.encode128(ByteArray(32)) } + assertFailsWith { Hex.encode128(ByteArray(63)) } + assertFailsWith { Hex.encode128(ByteArray(65)) } + } + + @Test + fun randomsMatchGenericDecode() { + for (i in 0..1000) { + val id = RandomInstance.bytes(32) + assertEquals(Hex.encode(id), Hex.encode64(id)) + assertContentEquals(id, Hex.decode64(Hex.encode64(id))) + + val sig = RandomInstance.bytes(64) + assertEquals(Hex.encode(sig), Hex.encode128(sig)) + assertContentEquals(sig, Hex.decode128(Hex.encode128(sig))) + } + } +} From d8bae8c6289423f0ee48d97cabcfb21e24d0dde8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 00:33:51 +0000 Subject: [PATCH 2/3] perf(quartz): tune Hex.decodeExactOrNull at the bytecode level javap on the previous version showed the hexToByte field re-loaded twice per iteration, the trip count as a runtime parameter, and the whole loop wrapped in an exception table (only needed because chars above 0xFF overflow the 256-entry lookup table). Now the table is hoisted into a local, the function is inline so the 32/64-byte length becomes a compile-time constant at each call site, and out-of-range chars are rejected branchlessly: the index is masked with 'and 0xFF' so it cannot overflow, while '255 - code' goes negative for any char above 0xFF and is folded into the same sign-bit accumulator that already catches invalid hex digits. No try/catch, no exception table, no branches in the loop. Measured on the JVM (4096 random ids, best-of-200 rounds, two runs with variant order reversed to rule out JIT profile artifacts): ~25% faster than the previous version and ~2x faster than the isHex64 + decode two-pass combination. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Hwv6XwT9mwGUQc57zH4ky4 --- .../com/vitorpamplona/quartz/utils/Hex.kt | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt index 18f457d6d1..b6434e3a8b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt @@ -216,28 +216,36 @@ object Hex { /** * Decodes [hex] into [byteLen] bytes, or null if any char is not a hex * digit. The caller has already checked `hex.length == 2 * byteLen`. - * Validation is free: the lookup table yields -1 for invalid chars, which - * keeps the OR-accumulator negative, so one sign check at the end covers - * every char with no branches inside the loop. + * + * Tuned at the bytecode level (see `HexBenchmark`): the table is hoisted + * into a local (the JVM/ART can't always prove the field load loop + * invariant), `inline` turns [byteLen] into a compile-time trip count at + * each call site, and validation is branchless — the table yields -1 for + * invalid chars and `255 - code` goes negative for chars above 0xFF (e.g. + * emoji, kept in bounds by the `and 0xFF` mask), so OR-ing everything into + * one accumulator and sign-checking it at the end rejects all bad input + * with no branches and no exception table. ~25% faster than the same loop + * with a per-iteration field load and a try/catch guard, and ~2x faster + * than `isHex64` + [decode]. */ - private fun decodeExactOrNull( + @Suppress("NOTHING_TO_INLINE") + private inline fun decodeExactOrNull( hex: String, byteLen: Int, - ): ByteArray? = - try { - val out = ByteArray(byteLen) - var acc = 0 - var c = 0 - for (i in 0 until byteLen) { - val b = (hexToByte[hex[c++].code] shl 4) or hexToByte[hex[c++].code] - acc = acc or b - out[i] = b.toByte() - } - if (acc < 0) null else out - } catch (_: IndexOutOfBoundsException) { - // chars above 0xFF (e.g. emoji) fall outside the lookup table - null + ): ByteArray? { + val table = hexToByte + val out = ByteArray(byteLen) + var acc = 0 + var c = 0 + for (i in 0 until byteLen) { + val c0 = hex[c++].code + val c1 = hex[c++].code + val b = (table[c0 and 0xFF] shl 4) or table[c1 and 0xFF] + acc = acc or b or (255 - c0) or (255 - c1) + out[i] = b.toByte() } + return if (acc < 0) null else out + } /** * Encodes a 32-byte pubkey/event id as a 64-char lower-case hex string. From 729fb1bc17ad450f6919e523898262744f38c848 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 01:47:01 +0000 Subject: [PATCH 3/3] perf(quartz): hoist lookup tables in Hex.decode/encode/isEqual/readLong; bench new codecs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit javap showed the hexToByte/byteToHex field re-loaded on every use inside these methods (16 times per readLong call) — the JVM/ART doesn't reliably prove the load loop-invariant. Hoisting it into a local measured ~25% faster for decode and ~10% for isEqual and readLong on the JVM (4096 random 32-byte ids, best-of-150 rounds, 3 repeats); encode was neutral on HotSpot but is hoisted too since ART is historically worse at this (see the internalIsHex comment). Branchless variants of isHex/isHex64 were also measured and were a wash-to-slightly-worse than the branchy early-exit versions on valid input, so those keep their current implementations. Also adds the new exact-size codecs to the on-device HexBenchmark (decode64, decode64OrNull, encode64, decode128, encode128, toLong256, and the old isHex64+decode two-pass for comparison) so ART numbers can be collected with the existing benchmark harness. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Hwv6XwT9mwGUQc57zH4ky4 --- .../quartz/benchmark/HexBenchmark.kt | 40 ++++++++++++++ .../com/vitorpamplona/quartz/utils/Hex.kt | 53 +++++++++++-------- 2 files changed, 72 insertions(+), 21 deletions(-) diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt index 64aa1cf179..3b11aa84df 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HexBenchmark.kt @@ -39,9 +39,13 @@ class HexBenchmark { @get:Rule val r = BenchmarkRule() val hex = "48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf" + val hex128 = hex + "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9" val bytes = fr.acinq.secp256k1.Hex .decode(hex) + val bytes64 = + fr.acinq.secp256k1.Hex + .decode(hex128) @Test fun hexIsEqual() { @@ -103,4 +107,40 @@ class HexBenchmark { fun isHex64() { r.measureRepeated { Hex.isHex64(hex) } } + + @Test + fun hexDecode64() { + r.measureRepeated { Hex.decode64(hex) } + } + + @Test + fun hexDecode64OrNull() { + r.measureRepeated { Hex.decode64OrNull(hex) } + } + + @Test + fun hexEncode64() { + r.measureRepeated { Hex.encode64(bytes) } + } + + @Test + fun hexDecode128() { + r.measureRepeated { Hex.decode128(hex128) } + } + + @Test + fun hexEncode128() { + r.measureRepeated { Hex.encode128(bytes64) } + } + + /** The pre-existing two-pass way to safely decode an id, for comparison with [hexDecode64OrNull]. */ + @Test + fun hexIsHex64ThenDecode() { + r.measureRepeated { if (Hex.isHex64(hex)) Hex.decode(hex) else null } + } + + @Test + fun hexToLong256() { + r.measureRepeated { Hex.toLong256(hex) } + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt index b6434e3a8b..1b7a0e542d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt @@ -185,9 +185,15 @@ object Hex { require(hex.length and 1 == 0) { "Invalid hex $hex" } - return ByteArray(hex.length / 2) { - (hexToByte[hex[2 * it].code] shl 4 or hexToByte[hex[2 * it + 1].code]).toByte() + // table hoisted into a local: the JVM/ART doesn't reliably prove the + // field load loop-invariant, and re-loading it per char costs ~25% + val table = hexToByte + val out = ByteArray(hex.length shr 1) + var c = 0 + for (i in out.indices) { + out[i] = ((table[hex[c++].code] shl 4) or table[hex[c++].code]).toByte() } + return out } /** @@ -268,10 +274,11 @@ object Hex { /** Encodes [input] as a lower-case hex string (two chars per byte). */ fun encode(input: ByteArray): String { + val table = byteToHex val out = CharArray(input.size * 2) var outIdx = 0 for (i in 0 until input.size) { - val chars = byteToHex[input[i].toInt() and 0xFF] + val chars = table[input[i].toInt() and 0xFF] out[outIdx++] = (chars shr 8).toChar() out[outIdx++] = (chars and 0xFF).toChar() } @@ -290,23 +297,26 @@ object Hex { fun readLong( hex: String, offset: Int, - ): Long = - (hexToByte[hex[offset].code].toLong() shl 60) or - (hexToByte[hex[offset + 1].code].toLong() shl 56) or - (hexToByte[hex[offset + 2].code].toLong() shl 52) or - (hexToByte[hex[offset + 3].code].toLong() shl 48) or - (hexToByte[hex[offset + 4].code].toLong() shl 44) or - (hexToByte[hex[offset + 5].code].toLong() shl 40) or - (hexToByte[hex[offset + 6].code].toLong() shl 36) or - (hexToByte[hex[offset + 7].code].toLong() shl 32) or - (hexToByte[hex[offset + 8].code].toLong() shl 28) or - (hexToByte[hex[offset + 9].code].toLong() shl 24) or - (hexToByte[hex[offset + 10].code].toLong() shl 20) or - (hexToByte[hex[offset + 11].code].toLong() shl 16) or - (hexToByte[hex[offset + 12].code].toLong() shl 12) or - (hexToByte[hex[offset + 13].code].toLong() shl 8) or - (hexToByte[hex[offset + 14].code].toLong() shl 4) or - hexToByte[hex[offset + 15].code].toLong() + ): Long { + // table hoisted into a local — one field load instead of sixteen + val t = hexToByte + return (t[hex[offset].code].toLong() shl 60) or + (t[hex[offset + 1].code].toLong() shl 56) or + (t[hex[offset + 2].code].toLong() shl 52) or + (t[hex[offset + 3].code].toLong() shl 48) or + (t[hex[offset + 4].code].toLong() shl 44) or + (t[hex[offset + 5].code].toLong() shl 40) or + (t[hex[offset + 6].code].toLong() shl 36) or + (t[hex[offset + 7].code].toLong() shl 32) or + (t[hex[offset + 8].code].toLong() shl 28) or + (t[hex[offset + 9].code].toLong() shl 24) or + (t[hex[offset + 10].code].toLong() shl 20) or + (t[hex[offset + 11].code].toLong() shl 16) or + (t[hex[offset + 12].code].toLong() shl 12) or + (t[hex[offset + 13].code].toLong() shl 8) or + (t[hex[offset + 14].code].toLong() shl 4) or + t[hex[offset + 15].code].toLong() + } /** * Reads the first 64 bits (16 hex chars) of [hex] as a single [Long]. @@ -350,9 +360,10 @@ object Hex { id: String, ourId: ByteArray, ): Boolean { + val table = byteToHex var charIndex = 0 for (i in 0 until ourId.size) { - val chars = byteToHex[ourId[i].toInt() and 0xFF] + val chars = table[ourId[i].toInt() and 0xFF] if ( id[charIndex++] != (chars shr 8).toChar() || id[charIndex++] != (chars and 0xFF).toChar()