docs(quartz): document Hex/HexKey utilities for discoverability

AI agents integrating Quartz were re-implementing hex encoding or pulling
in third-party codecs because the built-in helpers weren't discoverable.

- Add KDoc to the `HexKey` typealias, its extension functions
  (`toHexKey`/`hexToByteArray`/`hexToByteArrayOrNull`/`isValid`) and the
  `PUBKEY_LENGTH`/`EVENT_ID_LENGTH` constants.
- Add KDoc to the `Hex` object and its public API
  (`isHex`/`isHex64`/`decode`/`encode`/`isEqual`).
- Add a dedicated "Hex utilities" section + quick-reference rows to the
  quartz-integration skill, and fix the wrong `HexKey.decodeHex(hex)`
  snippet (no such API) to the real `hex.hexToByteArray()`.
- Add a "Hex Encoding" section to the nostr-expert skill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HM5uueF4a17umGpjC8wcz
This commit is contained in:
Claude
2026-07-01 17:17:23 +00:00
parent 2cb058b323
commit 68a28faab2
4 changed files with 156 additions and 2 deletions
+26
View File
@@ -345,6 +345,32 @@ object Nip04 {
**Note**: Use NIP-44 (`Nip44`) for new implementations. NIP-04 has security issues.
## Hex Encoding (HexKey ↔ ByteArray)
Pubkeys, event ids and signatures are lower-case hex. Quartz uses the `HexKey`
typealias (`= String`) plus extensions in `nip01Core/core/HexKey.kt`, backed by
the `Hex` object in `utils/Hex.kt`. **Use these — never hand-roll a byte loop or
import a third-party hex codec.**
```kotlin
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.utils.Hex
val hex: HexKey = bytes.toHexKey() // ByteArray -> lower-case hex
val back: ByteArray = hex.hexToByteArray() // hex -> ByteArray (throws on odd length)
val safe: ByteArray? = input.hexToByteArrayOrNull() // null on invalid hex
Hex.isHex(input) // valid hex, any length
Hex.isHex64(input) // ~30% faster fast-path for a 32-byte key/id
hex.isValid() // 64 chars + valid hex (pubkey / event-id shape)
Hex.isEqual(hex, bytes) // compare hex to bytes without decoding
```
Constants `PUBKEY_LENGTH` / `EVENT_ID_LENGTH` (both 64) live in `nip01Core.core`.
## Bech32 Encoding (NIP-19)
Encoding uses extension functions on `ByteArray` (`nip19Bech32/ByteArrayExt.kt`);
+54 -1
View File
@@ -134,13 +134,14 @@ val privKeyHex: String? = keyPair.privKey?.toHexKey()
```kotlin
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
// ByteArray → hex
val hex = byteArray.toHexKey()
// hex → ByteArray
val bytes = HexKey.decodeHex(hex)
val bytes = hex.hexToByteArray()
// Bech32 import (npub, nsec)
val parsed = Nip19Parser.uriToRoute("npub1abc...")
@@ -148,6 +149,55 @@ val parsed = Nip19Parser.uriToRoute("npub1abc...")
val parsed = Nip19Parser.uriToRoute("nsec1abc...")
```
> Hex ↔ ByteArray is a first-class utility in Quartz — see **§3.1 Hex utilities** below.
---
### 3.1 Hex utilities (HexKey ↔ ByteArray)
Nostr keys, event ids and signatures travel as lower-case hex strings. Quartz
models this with the `HexKey` typealias (just a `String`) plus extension
functions — **do not** write your own byte loop or pull in a third-party codec.
**Packages:** `com.vitorpamplona.quartz.nip01Core.core` (the extensions) and
`com.vitorpamplona.quartz.utils` (the underlying `Hex` object).
```kotlin
import com.vitorpamplona.quartz.nip01Core.core.HexKey // typealias = String
import com.vitorpamplona.quartz.nip01Core.core.toHexKey // ByteArray → hex
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray // hex → ByteArray
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.utils.Hex
// Encode / decode
val hex: HexKey = pubKeyBytes.toHexKey() // lower-case, 2 chars per byte
val bytes: ByteArray = hex.hexToByteArray() // throws on odd length
// Untrusted input → decode safely
val maybe: ByteArray? = userInput.hexToByteArrayOrNull() // null if not valid hex
// Validate without decoding (no allocation)
Hex.isHex(userInput) // even-length, all hex digits (any length)
Hex.isHex64(userInput) // fast path for a 32-byte key/id (checks first 64 chars)
hex.isValid() // 64 chars AND valid hex (pubkey / event-id shape)
// Compare a hex string to raw bytes without decoding
Hex.isEqual(incomingHexId, myIdBytes)
```
| Need | Call | Notes |
|------|------|-------|
| ByteArray → hex | `bytes.toHexKey()` | lower-case output |
| hex → ByteArray (strict) | `hex.hexToByteArray()` | throws on odd length |
| hex → ByteArray (safe) | `hex.hexToByteArrayOrNull()` | `null` on invalid hex |
| is this valid hex? | `Hex.isHex(s)` / `Hex.isHex64(s)` | `isHex64` ~30% faster for keys/ids |
| is this a pubkey/id shape? | `hex.isValid()` | 64 chars + valid hex |
| hex == bytes? | `Hex.isEqual(hex, bytes)` | no decode allocation |
Constants `PUBKEY_LENGTH` and `EVENT_ID_LENGTH` (both `64`) live in the same
`nip01Core.core` package.
---
## 4. Signing Events
@@ -644,6 +694,9 @@ val results = store.query<Event>(Filter(search = "bitcoin"))
| Sign event | `signer.sign(template)` | `nip01Core.signers` |
| Serialize | `event.toJson()` | `nip01Core.core` |
| Parse | `Event.fromJson(json)` | `nip01Core.core` |
| ByteArray → hex | `bytes.toHexKey()` | `nip01Core.core` |
| hex → ByteArray | `hex.hexToByteArray()` / `hex.hexToByteArrayOrNull()` | `nip01Core.core` |
| Validate hex | `Hex.isHex(s)` / `Hex.isHex64(s)` / `hex.isValid()` | `utils`, `nip01Core.core` |
| Normalize relay URL | `RelayUrlNormalizer.normalize("wss://...")` | `nip01Core.relay.normalizer` |
| Setup relay client | `NostrClient(BasicOkHttpWebSocket.Builder { okhttp })` | `nip01Core.relay.client` |
| Subscribe | `client.openReqSubscription(subId, mapOf(relay to filters), listener)` | `nip01Core.relay.client` |
@@ -22,17 +22,49 @@ package com.vitorpamplona.quartz.nip01Core.core
import com.vitorpamplona.quartz.utils.Hex
/** Makes the distinction between String and Hex * */
/**
* A lower-case hexadecimal string. This is the canonical wire format for the
* 32-byte values Nostr deals with everywhere: public keys, event ids, and the
* 64-byte Schnorr signature. It is only a [String] alias — it documents intent
* and does no validation on its own — so pair it with [isValid] (or [Hex.isHex])
* whenever the value comes from an untrusted source.
*
* Convert with the extension functions below rather than reaching for a byte
* loop or a third-party codec:
*
* ```kotlin
* import com.vitorpamplona.quartz.nip01Core.core.toHexKey
* import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
*
* val hex: HexKey = pubKeyBytes.toHexKey() // ByteArray -> hex
* val bytes: ByteArray = hex.hexToByteArray() // hex -> ByteArray
* ```
*
* For byte-array-free, allocation-light checks use [Hex.isHex64] (32-byte keys
* and ids) or [Hex.isEqual] (compare a hex string to raw bytes without decoding).
*/
typealias HexKey = String
/** Encodes these bytes as a lower-case [HexKey]. Inverse of [hexToByteArray]. */
fun ByteArray.toHexKey(): HexKey = Hex.encode(this)
/**
* Decodes this hex string into its bytes. Inverse of [toHexKey].
*
* Accepts upper- or lower-case input but requires an even length; throws
* [IllegalArgumentException] on odd-length input. Use [hexToByteArrayOrNull]
* when the string may contain non-hex characters.
*/
fun HexKey.hexToByteArray(): ByteArray = Hex.decode(this)
/** Like [hexToByteArray] but returns null instead of throwing when this is not valid hex. */
fun HexKey.hexToByteArrayOrNull(): ByteArray? = if (Hex.isHex(this)) Hex.decode(this) else null
/** True when this is a 64-char (32-byte) hex string — the shape of a pubkey or event id. */
fun HexKey.isValid(): Boolean = length == PUBKEY_LENGTH && Hex.isHex(this)
/** Length in hex chars of a 32-byte public key. */
const val PUBKEY_LENGTH = 64
/** Length in hex chars of a 32-byte event id. */
const val EVENT_ID_LENGTH = 64
@@ -20,6 +20,24 @@
*/
package com.vitorpamplona.quartz.utils
/**
* Fast, allocation-conscious hex codec used throughout Quartz for keys, event
* ids and signatures. Backed by pre-computed lookup tables and benchmarked
* against the secp256k1 codec and Kotlin's stdlib `HexFormat` (see
* `benchmark/.../HexBenchmark.kt`).
*
* Most call sites should prefer the extension functions in
* [com.vitorpamplona.quartz.nip01Core.core] — `ByteArray.toHexKey()` and
* `HexKey.hexToByteArray()` — which delegate here. Reach for this object
* directly when you want to validate without decoding ([isHex] / [isHex64]) or
* compare a hex string to raw bytes without allocating ([isEqual]).
*
* ```kotlin
* 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?
* ```
*/
object Hex {
private const val LOWER_CASE_HEX = "0123456789abcdef"
private const val UPPER_CASE_HEX = "0123456789ABCDEF"
@@ -36,6 +54,12 @@ object Hex {
(LOWER_CASE_HEX[(it shr 4)].code shl 8) or LOWER_CASE_HEX[(it and 0xF)].code
}
/**
* True when [hex] is a non-null, even-length string of only hex digits
* (upper or lower case). Rejects odd lengths and stray non-hex chars (e.g.
* emoji in `p` tags) instead of throwing. ~47ns in debug on the Emulator;
* use [isHex64] when the length is known to be 64.
*/
// 47ns in debug on the Emulator
fun isHex(hex: String?): Boolean {
if (hex == null) return false
@@ -63,6 +87,12 @@ object Hex {
return true
}
/**
* Validates the first 64 chars of [hex] as hex digits — the fast path for
* checking a 32-byte pubkey or event id. ~30% faster than [isHex] because
* the length is fixed and the checks are unrolled. Assumes [hex] is at least
* 64 chars long; it does not verify the total length.
*/
// 30% faster than isHex
fun isHex64(hex: String): Boolean =
try {
@@ -144,6 +174,12 @@ object Hex {
false
}
/**
* Decodes [hex] (upper or lower case) into bytes. Requires an even length —
* throws [IllegalArgumentException] otherwise. Does not itself validate the
* characters, so guard untrusted input with [isHex] first (or use
* `HexKey.hexToByteArrayOrNull()`).
*/
fun decode(hex: String): ByteArray {
// faster version of hex decoder
require(hex.length and 1 == 0) {
@@ -154,6 +190,7 @@ object Hex {
}
}
/** Encodes [input] as a lower-case hex string (two chars per byte). */
fun encode(input: ByteArray): String {
val out = CharArray(input.size * 2)
var outIdx = 0
@@ -165,6 +202,12 @@ object Hex {
return out.concatToString()
}
/**
* True when the hex string [id] encodes exactly the bytes [ourId], compared
* without allocating a decode buffer. Handy for matching an incoming hex id
* against bytes you already hold. Assumes [id] is at least `2 * ourId.size`
* chars and lower-case (as produced by [encode]).
*/
fun isEqual(
id: String,
ourId: ByteArray,