mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
docs(quartz): document core utilities (time, random, hashing, bech32, strings)
Same discoverability treatment as the Hex helpers: KDoc on the reusable, heavily-used primitives external integrators and AIs kept missing, plus skill coverage. - TimeUtils: object + key-fn KDoc emphasizing Unix *seconds* (created_at) vs the millisecond nowMillis() exception. - RandomInstance: object + per-fn KDoc (secure random; use over kotlin.random). - sha256(): KDoc pointing event-id work to EventHasher. - EventHasher: object + fn KDoc on canonical id serialization / verification. - StringUtils: KDoc on the allocation-free case-insensitive matchers + DualCase. - Bech32: "use NIP-19 helpers unless you need a custom prefix" pointer; KDoc on bechToBytes. - quartz-integration skill: new "Everyday utilities" section (time/random/ hashing/bech32/base64) + quick-reference rows. - nostr-expert skill: "Core Utilities" section. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011HM5uueF4a17umGpjC8wcz
This commit is contained in:
@@ -371,6 +371,29 @@ Hex.isEqual(hex, bytes) // compare hex to bytes without decoding
|
||||
|
||||
Constants `PUBKEY_LENGTH` / `EVENT_ID_LENGTH` (both 64) live in `nip01Core.core`.
|
||||
|
||||
## Core Utilities (time, random, event id)
|
||||
|
||||
Reuse these instead of hand-rolling — each avoids a common mistake:
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
|
||||
TimeUtils.now() // Unix SECONDS for created_at — not currentTimeMillis()/1000
|
||||
TimeUtils.oneHourAgo() // relative filter bounds (…Ago / …FromNow); all in seconds
|
||||
RandomInstance.bytes(32) // secure random (SecureRandom) — for nonces/keys, not kotlin.random.Random
|
||||
RandomInstance.randomChars() // 16-char subscription id
|
||||
|
||||
sha256(bytes) // raw hash primitive
|
||||
EventHasher.hashId(pubKey, createdAt, kind, tags, content) // canonical event id
|
||||
EventHasher.hashIdCheck(id, pubKey, createdAt, kind, tags, content) // verify untrusted events
|
||||
```
|
||||
|
||||
`EventHasher` serializes `[0, pubkey, created_at, kind, tags, content]` in the
|
||||
exact form NIP-01 requires — prefer it over calling `sha256` on your own JSON.
|
||||
|
||||
## Bech32 Encoding (NIP-19)
|
||||
|
||||
Encoding uses extension functions on `ByteArray` (`nip19Bech32/ByteArrayExt.kt`);
|
||||
|
||||
@@ -200,6 +200,77 @@ Constants `PUBKEY_LENGTH` and `EVENT_ID_LENGTH` (both `64`) live in the same
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Everyday utilities (time, random, hashing, bech32, base64)
|
||||
|
||||
These small helpers exist so you don't reinvent them — and several have a
|
||||
footgun the built-in avoids. **Prefer them over stdlib/hand-rolled equivalents.**
|
||||
|
||||
**Time — `TimeUtils` (`com.vitorpamplona.quartz.utils`).** Everything is in Unix
|
||||
**seconds** (what `created_at` and filter `since`/`until` use), *not* millis.
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
val createdAt = TimeUtils.now() // seconds — for created_at. NOT currentTimeMillis()/1000
|
||||
val since = TimeUtils.oneDayAgo() // relative filter bounds: oneHourAgo(), fiveMinutesAgo()…
|
||||
val fresh = TimeUtils.withinTenMinutes(event.createdAt) // NIP-42/NIP-98 freshness
|
||||
// TimeUtils.nowMillis() is the only millisecond helper — non-protocol use only.
|
||||
```
|
||||
|
||||
**Secure random — `RandomInstance` (`utils`).** Backed by `SecureRandom`; use it
|
||||
for anything security-sensitive instead of `kotlin.random.Random`.
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
|
||||
val nonce = RandomInstance.bytes(32) // nonces, salts, keys
|
||||
val subId = RandomInstance.randomChars() // 16-char [a-zA-Z0-9] subscription id
|
||||
```
|
||||
|
||||
**Hashing — `sha256(...)` + `EventHasher`.** `sha256` is the raw primitive; to
|
||||
compute/verify an **event id** use `EventHasher`, which canonically serializes
|
||||
`[0, pubkey, created_at, kind, tags, content]` before hashing (getting this wrong
|
||||
is what makes relays reject an event). Typed builders already do this for you.
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
|
||||
val digest = sha256(bytes) // raw 32-byte hash
|
||||
val id = EventHasher.hashId(pubKey, createdAt, kind, tags, content)
|
||||
val valid = EventHasher.hashIdCheck(event.id, event.pubKey, event.createdAt, event.kind, event.tags, event.content)
|
||||
```
|
||||
|
||||
**Bech32.** For `npub`/`nsec`/`note`/… prefer the NIP-19 layer (`ByteArray.toNpub()`,
|
||||
`Nip19Parser.uriToRoute(...)` — see §10). Drop to the low-level
|
||||
`Bech32` object (`nip19Bech32.bech32`) only for a custom prefix:
|
||||
|
||||
```kotlin
|
||||
import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32
|
||||
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
|
||||
|
||||
val addr = Bech32.encodeBytes("npub", pubKeyBytes, Bech32.Encoding.Bech32)
|
||||
val bytes = "npub1...".bechToBytes("npub") // decode + assert the prefix
|
||||
```
|
||||
|
||||
**Base64.** Quartz has no wrapper — use the Kotlin stdlib `kotlin.io.encoding.Base64`
|
||||
directly, and match the variant the spec wants: NIP-44/NIP-04 payloads use
|
||||
`Base64.Default` (standard, padded); url-safe contexts use `Base64.UrlSafe`
|
||||
(configure padding via `.withPadding(...)`).
|
||||
|
||||
| Need | Call |
|
||||
|------|------|
|
||||
| Now (event `created_at`) | `TimeUtils.now()` (seconds) |
|
||||
| Relative filter bound | `TimeUtils.oneDayAgo()` / `oneHourAgo()` / … |
|
||||
| Secure random bytes | `RandomInstance.bytes(n)` |
|
||||
| Subscription id | `RandomInstance.randomChars()` |
|
||||
| Raw hash | `sha256(bytes)` |
|
||||
| Event id / verify | `EventHasher.hashId(...)` / `hashIdCheck(...)` |
|
||||
| Bech32 custom prefix | `Bech32.encodeBytes(hrp, bytes, enc)` / `s.bechToBytes(hrp)` |
|
||||
| Base64 | `kotlin.io.encoding.Base64` (`.Default` / `.UrlSafe`) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Signing Events
|
||||
|
||||
### `NostrSignerInternal` (local key, JVM + Android)
|
||||
@@ -697,6 +768,10 @@ val results = store.query<Event>(Filter(search = "bitcoin"))
|
||||
| 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` |
|
||||
| Now (seconds) | `TimeUtils.now()` | `utils` |
|
||||
| Relative time | `TimeUtils.oneDayAgo()` / `oneHourAgo()` | `utils` |
|
||||
| Secure random | `RandomInstance.bytes(n)` / `randomChars()` | `utils` |
|
||||
| Hash / event id | `sha256(bytes)` / `EventHasher.hashId(...)` | `utils.sha256`, `nip01Core.crypto` |
|
||||
| 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` |
|
||||
|
||||
@@ -24,7 +24,23 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
|
||||
/**
|
||||
* Computes and verifies Nostr **event ids** (NIP-01).
|
||||
*
|
||||
* An id is the SHA-256 of the canonically serialized array
|
||||
* `[0, pubkey, created_at, kind, tags, content]` — UTF-8, no whitespace, in that
|
||||
* exact order. Getting that serialization right by hand is easy to botch (it is
|
||||
* what makes relays reject an event), so route through here rather than calling
|
||||
* `sha256` on your own JSON. Signing/building via the typed event builders already
|
||||
* does this for you; reach for `EventHasher` when validating events from an
|
||||
* untrusted source or hashing a not-yet-wrapped template.
|
||||
*
|
||||
* ```kotlin
|
||||
* val ok = EventHasher.hashIdCheck(event.id, event.pubKey, event.createdAt, event.kind, event.tags, event.content)
|
||||
* ```
|
||||
*/
|
||||
object EventHasher {
|
||||
/** Raw 32-byte id digest for the given event fields. See [hashId] for the hex form. */
|
||||
fun hashIdBytes(
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
@@ -33,6 +49,7 @@ object EventHasher {
|
||||
content: String,
|
||||
): ByteArray = sha256(EventHasherSerializer.fastMakeJsonForId(pubKey, createdAt, kind, tags, content))
|
||||
|
||||
/** The event id as a lower-case 64-char hex string. */
|
||||
fun hashId(
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
@@ -41,6 +58,7 @@ object EventHasher {
|
||||
content: String,
|
||||
): String = hashIdBytes(pubKey, createdAt, kind, tags, content).toHexKey()
|
||||
|
||||
/** True when [id] matches the id computed from the other fields — use to validate untrusted events. */
|
||||
fun hashIdCheck(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
+20
-2
@@ -43,8 +43,21 @@ package com.vitorpamplona.quartz.nip19Bech32.bech32
|
||||
private typealias Int5 = Byte
|
||||
|
||||
/**
|
||||
* Bech32 and Bech32m address formats. See
|
||||
* https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki and
|
||||
* Low-level Bech32 / Bech32m codec (BIP-173 / BIP-350).
|
||||
*
|
||||
* For the common Nostr entities (`npub`, `nsec`, `note`, `nevent`, `nprofile`,
|
||||
* `naddr`) you usually **don't** need this directly — prefer the NIP-19 helpers:
|
||||
* the `ByteArray.toNpub()/toNsec()/toNote()` extensions to encode and
|
||||
* `Nip19Parser.uriToRoute(...)` to decode. Reach for `Bech32` when you need a
|
||||
* custom human-readable prefix or raw 5-bit/8-bit access:
|
||||
*
|
||||
* ```kotlin
|
||||
* val addr = Bech32.encodeBytes("npub", pubKeyBytes, Bech32.Encoding.Bech32)
|
||||
* val (hrp, data, _) = Bech32.decodeBytes(addr) // hrp = "npub", data = 32 bytes
|
||||
* val bytes = "npub1...".bechToBytes("npub") // decode + assert the prefix
|
||||
* ```
|
||||
*
|
||||
* See https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki and
|
||||
* https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki.
|
||||
*/
|
||||
object Bech32 {
|
||||
@@ -284,6 +297,11 @@ object Bech32 {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes this Bech32 string to its raw data bytes. If [hrp] is given, throws
|
||||
* [IllegalArgumentException] when the decoded human-readable prefix doesn't match
|
||||
* (e.g. pass `"npub"` to reject anything that isn't a public key).
|
||||
*/
|
||||
fun String.bechToBytes(hrp: String? = null): ByteArray {
|
||||
val decodedForm = Bech32.decodeBytes(this)
|
||||
hrp?.also {
|
||||
|
||||
@@ -20,22 +20,41 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.utils
|
||||
|
||||
/**
|
||||
* Cryptographically secure random source shared across Quartz, backed by the
|
||||
* platform [SecureRandom]. **Use this — not `kotlin.random.Random`** — for
|
||||
* anything security-sensitive: NIP-44 nonces, private keys, gift-wrap timestamps,
|
||||
* random `d` tags, subscription ids.
|
||||
*
|
||||
* ```kotlin
|
||||
* val nonce = RandomInstance.bytes(32) // 32 secure random bytes
|
||||
* val dTag = RandomInstance.bytes(16).toHexKey()
|
||||
* val subId = RandomInstance.randomChars() // 16-char [a-zA-Z0-9] id
|
||||
* ```
|
||||
*/
|
||||
object RandomInstance {
|
||||
val randomizer = SecureRandom()
|
||||
|
||||
/** A secure random [Int] across the full 32-bit range (may be negative). */
|
||||
fun int() = randomizer.nextInt()
|
||||
|
||||
/** A secure random [Long] across the full 64-bit range (may be negative). */
|
||||
fun long() = randomizer.nextLong()
|
||||
|
||||
/** A secure random [Int] in `0 until bound`. */
|
||||
fun int(bound: Int) = randomizer.nextInt(bound)
|
||||
|
||||
/** A secure random [Long] in `0 until bound`. */
|
||||
fun long(bound: Long) = randomizer.nextLong(bound)
|
||||
|
||||
/** [size] secure random bytes — the go-to for nonces, keys and salts. */
|
||||
fun bytes(size: Int) = ByteArray(size).also { randomizer.nextBytes(it) }
|
||||
|
||||
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
|
||||
|
||||
/** A single secure random alphanumeric char from [charPool]. */
|
||||
fun randomChar() = charPool[randomizer.nextInt(charPool.size)]
|
||||
|
||||
/** A secure random alphanumeric string of [size] chars — handy for subscription ids. */
|
||||
fun randomChars(size: Int = 16) = CharArray(size) { randomChar() }.concatToString()
|
||||
}
|
||||
|
||||
@@ -32,6 +32,13 @@ fun Int.bytesUsedInMemory(): Int = 4
|
||||
|
||||
fun Boolean.bytesUsedInMemory(): Int = 8
|
||||
|
||||
/**
|
||||
* Case-insensitive [contains] that does **not** allocate a lowercased copy of the
|
||||
* receiver — it scans in place comparing each char against both cases of [term].
|
||||
* Preferred over `this.lowercase().contains(term.lowercase())` on hot paths (feed
|
||||
* search, tag matching). When testing the same term against many strings, precompute
|
||||
* a [DualCase] and use the two-arg overload / [containsAny] to hoist the casing work.
|
||||
*/
|
||||
fun String.containsIgnoreCase(term: String): Boolean {
|
||||
if (term.isEmpty()) return true // Empty string is contained
|
||||
|
||||
@@ -41,6 +48,7 @@ fun String.containsIgnoreCase(term: String): Boolean {
|
||||
return containsIgnoreCase(whatLowercase, whatUppercase)
|
||||
}
|
||||
|
||||
/** [containsIgnoreCase] variant taking the term's cases precomputed — see [DualCase]. */
|
||||
fun String.containsIgnoreCase(
|
||||
whatLowercase: String,
|
||||
whatUppercase: String,
|
||||
@@ -68,6 +76,7 @@ fun String.containsIgnoreCase(
|
||||
return false
|
||||
}
|
||||
|
||||
/** Case-insensitive [startsWith], allocation-free, taking the prefix's cases precomputed. */
|
||||
fun String.startsWithIgnoreCase(
|
||||
whatLowercase: String,
|
||||
whatUppercase: String,
|
||||
@@ -89,6 +98,7 @@ fun String.startsWithIgnoreCase(
|
||||
return true
|
||||
}
|
||||
|
||||
/** True when this contains any of [terms] (case-insensitive). Empty list ⇒ true. */
|
||||
fun String.containsAny(terms: List<DualCase>): Boolean {
|
||||
if (terms.isEmpty()) return true // Empty string is contained
|
||||
|
||||
@@ -99,6 +109,7 @@ fun String.containsAny(terms: List<DualCase>): Boolean {
|
||||
return terms.any { containsIgnoreCase(it.lowercase, it.uppercase) }
|
||||
}
|
||||
|
||||
/** True when this starts with any of [terms] (case-insensitive). Empty list ⇒ true. */
|
||||
fun String.startsWithAny(terms: List<DualCase>): Boolean {
|
||||
if (terms.isEmpty()) return true // Empty string is contained
|
||||
|
||||
@@ -111,6 +122,12 @@ fun String.startsWithAny(terms: List<DualCase>): Boolean {
|
||||
|
||||
fun String.startsWith(prefix: DualCase): Boolean = startsWithIgnoreCase(prefix.lowercase, prefix.uppercase)
|
||||
|
||||
/**
|
||||
* A search term with its lower- and upper-case forms computed once, so the
|
||||
* allocation-free case-insensitive matchers ([containsIgnoreCase], [startsWithIgnoreCase],
|
||||
* [containsAny], [startsWithAny]) can be run against many strings without re-casing
|
||||
* the term each time. Build once, reuse across a feed scan.
|
||||
*/
|
||||
class DualCase(
|
||||
val lowercase: String,
|
||||
val uppercase: String,
|
||||
|
||||
@@ -20,6 +20,25 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.utils
|
||||
|
||||
/**
|
||||
* Clock and duration helpers for Nostr timestamps.
|
||||
*
|
||||
* **Everything here is in Unix _seconds_, not milliseconds** — that is the unit
|
||||
* Nostr uses for an event's `created_at` and for filter `since`/`until` bounds.
|
||||
* Use [now] to stamp an event and the `...Ago` / `...FromNow` helpers to build
|
||||
* relative filter windows; do **not** hand-roll `currentTimeMillis() / 1000`.
|
||||
* The `const val` durations ([ONE_MINUTE], [ONE_HOUR], [ONE_DAY], …) are also in
|
||||
* seconds and can be added/subtracted directly.
|
||||
*
|
||||
* ```kotlin
|
||||
* val createdAt = TimeUtils.now() // seconds, for created_at
|
||||
* val since = TimeUtils.oneDayAgo() // filter: last 24h
|
||||
* val fresh = TimeUtils.withinTenMinutes(event.createdAt)
|
||||
* ```
|
||||
*
|
||||
* [nowMillis] is the one exception: it returns milliseconds, for the rare
|
||||
* non-protocol case (UI timers, latency measurements) that needs finer detail.
|
||||
*/
|
||||
object TimeUtils {
|
||||
const val TEN_SECONDS = 10
|
||||
const val ONE_MINUTE = 60
|
||||
@@ -34,8 +53,10 @@ object TimeUtils {
|
||||
const val ONE_MONTH = 30 * ONE_DAY
|
||||
const val ONE_YEAR = 365 * ONE_DAY
|
||||
|
||||
/** Current time in Unix **seconds** — the value for an event's `created_at`. */
|
||||
fun now() = currentTimeSeconds()
|
||||
|
||||
/** Current time in Unix **milliseconds**. For non-protocol use only; `created_at` wants [now]. */
|
||||
fun nowMillis() = currentTimeMillis()
|
||||
|
||||
fun tenSecondsFromNow() = now() + TEN_SECONDS
|
||||
@@ -74,6 +95,7 @@ object TimeUtils {
|
||||
|
||||
fun oneYearAgo() = now() - ONE_YEAR
|
||||
|
||||
/** True when [time] (Unix seconds) is within ±10 minutes of [now] — e.g. a fresh NIP-98/NIP-42 stamp. */
|
||||
fun withinTenMinutes(time: Long): Boolean {
|
||||
val now = now()
|
||||
return time > now - TEN_MINUTES && time < now + TEN_MINUTES
|
||||
|
||||
@@ -20,6 +20,15 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.utils.sha256
|
||||
|
||||
/**
|
||||
* SHA-256 of [data], returning a fresh 32-byte digest.
|
||||
*
|
||||
* This is the raw primitive. To compute or verify a Nostr **event id** don't hash
|
||||
* by hand — use `EventHasher.hashId(...)` / `hashIdCheck(...)`, which serialize the
|
||||
* `[0, pubkey, created_at, kind, tags, content]` array in the canonical form the
|
||||
* protocol requires before hashing. Use [sha256Into] on hot paths to avoid
|
||||
* allocating a new array per call.
|
||||
*/
|
||||
expect fun sha256(data: ByteArray): ByteArray
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user