| name | quartz-integration |
| description | Integration guide for using the Quartz Nostr KMP library in external projects. Use when: (1) adding Quartz as a Gradle dependency, (2) setting up NostrClient with WebSocket, (3) creating/signing/sending events, (4) building relay subscriptions with Filter, (5) handling keys with KeyPair/NostrSignerInternal, (6) using Bech32 encoding/decoding (NIP-19), (7) platform-specific setup (Android vs JVM/Desktop), (8) NIP-57 zaps, NIP-17 DMs, NIP-44 encryption in external projects, (9) running a relay on Quartz and serving/building its NIP-11 relay information document (application/nostr+json). |
Quartz Integration Guide
Reference for integrating com.vitorpamplona.quartz:quartz into external Nostr KMP projects.
Published artifact: com.vitorpamplona.quartz:quartz:1.13.1 (Maven Central)
Targets: JVM 21+, Android (minSdk 21+), iOS (XCFramework quartz-kmpKit)
License: MIT
1. Gradle Setup
Version Catalog (libs.versions.toml)
[versions]
quartz = "1.13.1"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
build.gradle.kts (KMP project)
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.quartz)
}
}
}
Android-only project
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.13.1")
}
Transitive dependencies pulled in automatically
Quartz exposes these as api (you get them transitively):
| Dependency | Used for |
|---|
fr.acinq.secp256k1:secp256k1-kmp-* | Schnorr signing |
com.github.anthonynsimon:rfc3986-normalizer | Relay URL normalization |
com.fasterxml.jackson.module:jackson-module-kotlin | Event JSON parsing |
For Android, add to build.gradle.kts:
android {
packaging {
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
2. Key Concepts
Core Types
typealias HexKey = String
typealias Kind = Int
typealias TagArray = Array<Array<String>>
Event Anatomy
@Immutable
open class Event(
val id: HexKey,
val pubKey: HexKey,
val createdAt: Long,
val kind: Kind,
val tags: TagArray,
val content: String,
val sig: HexKey,
)
Kind Classification
val isRegular = kind in 1..9999
val isReplaceable = kind == 0 || kind == 3 || kind in 10000..19999
val isAddressable = kind in 30000..39999
val isEphemeral = kind in 20000..29999
3. Key Management
Generate a new keypair
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
val keyPair = KeyPair()
val keyPair = KeyPair(privKey = myPrivKeyBytes)
val keyPair = KeyPair(pubKey = myPubKeyBytes)
val pubKeyHex: String = keyPair.pubKey.toHexKey()
val privKeyHex: String? = keyPair.privKey?.toHexKey()
Convert between formats
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
val hex = byteArray.toHexKey()
val bytes = hex.hexToByteArray()
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).
import com.vitorpamplona.quartz.nip01Core.core.HexKey
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 = pubKeyBytes.toHexKey()
val bytes: ByteArray = hex.hexToByteArray()
val maybe: ByteArray? = userInput.hexToByteArrayOrNull()
Hex.isHex(userInput)
Hex.isHex64(userInput)
hex.isValid()
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.
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.
import com.vitorpamplona.quartz.utils.TimeUtils
val createdAt = TimeUtils.now()
val since = TimeUtils.oneDayAgo()
val fresh = TimeUtils.withinTenMinutes(event.createdAt)
Secure random — RandomInstance (utils). Backed by SecureRandom; use it
for anything security-sensitive instead of kotlin.random.Random.
import com.vitorpamplona.quartz.utils.RandomInstance
val nonce = RandomInstance.bytes(32)
val subId = RandomInstance.randomChars()
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.
import com.vitorpamplona.quartz.utils.sha256.sha256
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
val digest = sha256(bytes)
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:
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")
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)
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
val keyPair = KeyPair()
val signer = NostrSignerInternal(keyPair)
val template = TextNoteEvent.build("Hello Nostr!")
val signedEvent: TextNoteEvent = signer.sign(template)
NostrSignerSync (synchronous, for testing)
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
val signerSync = NostrSignerSync(keyPair)
val event = signerSync.sign<TextNoteEvent>(
createdAt = TimeUtils.now(),
kind = 1,
tags = emptyArray(),
content = "Hello!"
)
NostrSigner interface (for custom signers)
abstract class NostrSigner(val pubKey: HexKey) {
abstract fun isWriteable(): Boolean
abstract suspend fun <T : Event> sign(createdAt: Long, kind: Int, tags: Array<Array<String>>, content: String): T
abstract suspend fun nip04Encrypt(plaintext: String, toPublicKey: HexKey): String
abstract suspend fun nip04Decrypt(ciphertext: String, fromPublicKey: HexKey): String
abstract suspend fun nip44Encrypt(plaintext: String, toPublicKey: HexKey): String
abstract suspend fun nip44Decrypt(ciphertext: String, fromPublicKey: HexKey): String
abstract suspend : HexKey
:
: String
}
5. Creating Events
Using typed event builders (recommended)
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
val template = TextNoteEvent.build("Hello Nostr!")
val event: TextNoteEvent = signer.sign(template)
val replyTemplate = TextNoteEvent.build(
note = "Interesting thread!",
replyingTo = originalEventHintBundle
)
val reactionTemplate = ReactionEvent.build(
content = "+",
originalNote = targetEvent
)
Using low-level Event.build DSL
import com.vitorpamplona.quartz.nip01Core.core.Event
val template = Event.build(
kind = 1,
content = "Hello world",
createdAt = TimeUtils.now()
) {
add(arrayOf("p", mentionedPubKey))
add(arrayOf("t", "nostr"))
add(arrayOf("subject", "Greeting"))
}
val event: Event = signer.sign(template)
TagArrayBuilder DSL methods
add(arrayOf("tagname", "value"))
addFirst(arrayOf("tagname", "value"))
addUnique(arrayOf("d", "my-slug"))
addAll(listOf(arrayOf("t", "tag1"), ...))
remove("tagname")
6. Relay Client Setup (JVM / Android)
The relay client requires an OkHttp WebSocket builder (available on JVM + Android).
Minimal setup
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import okhttp3.OkHttpClient
val okHttpClient = OkHttpClient.Builder().build()
val wsBuilder = BasicOkHttpWebSocket.Builder { _ -> okHttpClient }
val nostrClient = NostrClient(websocketBuilder = wsBuilder)
nostrClient.connect()
With custom OkHttpClient per relay
val wsBuilder = BasicOkHttpWebSocket.Builder { normalizedUrl ->
if (normalizedUrl.url.contains(".onion")) {
torEnabledOkHttpClient
} else {
regularOkHttpClient
}
}
With custom CoroutineScope
val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val nostrClient = NostrClient(wsBuilder, scope = appScope)
7. Subscribing to Events
Normalize relay URLs first
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
val relayUrl = RelayUrlNormalizer.normalize("wss://relay.damus.io")
val relayUrlOrNull = RelayUrlNormalizer.normalizeOrNull("wss://relay.damus.io")
Build a Filter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
val filter = Filter(
authors = listOf(pubKeyHex),
kinds = listOf(1),
limit = 50
)
val filter = Filter(
kinds = listOf(1, 6),
since = System.currentTimeMillis() / 1000 - 3600
)
val filter = Filter(
kinds = listOf(7),
tags = mapOf("e" to listOf(eventId))
)
val filter = Filter(
kinds = listOf(1),
tagsAll = mapOf(
"t" to listOf("nostr"),
"p" to listOf(specificPubKey)
)
)
val filter = Filter(
kinds = listOf(1),
search = "bitcoin lightning"
)
Open a subscription
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
val relay = RelayUrlNormalizer.normalize("wss://relay.damus.io")
val subId = "my-sub-${System.currentTimeMillis()}"
val filtersMap = mapOf(relay to listOf(filter))
nostrClient.openReqSubscription(
subId = subId,
filters = filtersMap,
listener = object : IRequestListener {
override fun onEvent(subId: String, event: Event, relay: IRelayClient) {
println("Got event: ${event.id}")
}
override fun onEOSE(subId: String, relay: IRelayClient) {
println("End of stored events from ${relay.url}")
}
}
)
nostrClient.close(subId)
Global relay listener
nostrClient.subscribe(object : IRelayClientListener {
override fun onIncomingMessage(relay: IRelayClient, msgStr: String, msg: Message) {
when (msg) {
is EventMessage -> handleEvent(msg.subscriptionId, msg.event)
is EoseMessage -> handleEose(msg.subscriptionId)
else -> {}
}
}
override fun onConnected(relay: IRelayClient, pingMillis: Int, compressed: Boolean) {
println("Connected to ${relay.url} in ${pingMillis}ms")
}
override fun onDisconnected(relay: IRelayClient) {
println("Disconnected from ${relay.url}")
}
})
8. Publishing Events
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
val relaySet = setOf(
RelayUrlNormalizer.normalize("wss://relay.damus.io"),
RelayUrlNormalizer.normalize("wss://nos.lol"),
)
val template = TextNoteEvent.build("Hello Nostr!")
val event: TextNoteEvent = signer.sign(template)
nostrClient.send(event, relaySet)
9. Event Serialization
import com.vitorpamplona.quartz.nip01Core.core.Event
val json: String = event.toJson()
val event: Event = Event.fromJson(json)
val event: Event? = Event.fromJsonOrNull(json)
val textNote = Event.fromJson(json) as? TextNoteEvent
10. Bech32 Encoding / Decoding (NIP-19)
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
when (val entity = Nip19Parser.uriToRoute(input)?.entity) {
is NPub -> println("pubkey: ${entity.hex}")
is NNote -> println("event id: ${entity.hex}")
is NEvent -> println("event: ${entity.hex}, relays: ${entity.relay}")
is NProfile -> println("profile: ${entity.hex}")
is NAddress -> println("address: ${entity.aTag()}")
null -> println("not a valid bech32 entity")
else -> {}
}
val npub = pubkeyBytes.toNpub()
val nevent = NEvent.create(eventIdHex, authorHex, kind, relays)
11. Encryption
NIP-44 (modern, recommended)
val encrypted = signer.nip44Encrypt(
plaintext = "Secret message",
toPublicKey = recipientPubKeyHex
)
val decrypted = signer.nip44Decrypt(
ciphertext = encrypted,
fromPublicKey = senderPubKeyHex
)
val plaintext = signer.decrypt(encryptedContent, fromPublicKeyHex)
NIP-04 (legacy, avoid for new code)
val encrypted = signer.nip04Encrypt(plaintext, recipientPubKeyHex)
val decrypted = signer.nip04Decrypt(ciphertext, senderPubKeyHex)
12. Common NIP Event Builders
NIP-02 — Follow list (kind 3)
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
val template = ContactListEvent.build(
follows = listOf(
ContactListEvent.Contact(pubKey = alicePubKey, relayUrl = "wss://relay.damus.io", petname = "alice"),
ContactListEvent.Contact(pubKey = bobPubKey)
)
)
NIP-25 — Reaction (kind 7)
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
val like = ReactionEvent.build("+", targetEvent)
val dislike = ReactionEvent.build("-", targetEvent)
val custom = ReactionEvent.build("🤙", targetEvent)
NIP-57 — Zap request (kind 9734)
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
val template = LnZapRequestEvent.build(
message = "Great post!",
relays = listOf("wss://relay.damus.io"),
target = targetEvent,
zapType = LnZapRequestEvent.ZapType.PUBLIC
)
val zapRequest: LnZapRequestEvent = signer.sign(template)
NIP-59 — Gift wrap / sealed DM (kind 1059 + 14)
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
val (dmEvent, giftWrap) = NIP17Factory.create(
msg = "Private message",
fromSigner = senderSigner,
toUsers = listOf(recipientPubKey),
relayList = listOf("wss://relay.damus.io")
)
NIP-23 — Long-form article (kind 30023)
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
val template = LongTextNoteEvent.build(
body = markdownContent,
title = "My Article",
image = "https://example.com/cover.jpg",
summary = "A brief summary",
slug = "my-article"
)
13. Platform-Specific Notes
JVM / Desktop
implementation("net.java.dev.jna:jna:5.18.1")
Android
import com.vitorpamplona.quartz.nip55AndroidSigner.ExternalSignerLauncher
iOS
The library produces an XCFramework named quartz-kmpKit.
./gradlew :quartz:assembleQuartz-kmpKitReleaseXCFramework
In Xcode: drag & drop the .xcframework into your project, then use from Swift via Kotlin/Native interop.
14. Event Store (SQLite, all platforms)
SQLite-backed storage in commonMain (JVM, Android, iOS — uses the bundled
androidx.sqlite driver) with full NIP support (NIP-09, NIP-40, NIP-45, NIP-50,
NIP-62). All operations are suspend:
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
val store = EventStore()
store.insert(event)
val events = store.query<Event>(
Filter(authors = listOf(pubKey), kinds = listOf(1), limit = 50)
)
val count = store.count(Filter(kinds = listOf(1)))
val results = store.query<Event>(Filter(search = "bitcoin"))
15. NIP-11 Relay Information Document
If you're standing up a relay on Quartz's relay-server code, serve your NIP-11
document with the type-safe builder — don't hand-write the JSON string.
Package: com.vitorpamplona.quartz.nip11RelayInfo
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip11RelayInfo.relayInformation
val info =
relayInformation {
name = "sot"
description = "NIP-50 profile search ranked by Nostr web-of-trust"
software = "https://github.com/vitorpamplona/sot"
version = "0.1"
supports(1, 11, 42, 50)
}
val json = info.toJson()
Serve it at the relay root, branching on the Accept header (Ktor example):
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import io.ktor.http.ContentType
get("/") {
val accept = call.request.headers[HttpHeaders.Accept].orEmpty()
if (accept.contains(Nip11RelayInformation.CONTENT_TYPE)) {
call.respondText(json, ContentType.parse(Nip11RelayInformation.CONTENT_TYPE))
} else {
call.respondText("Open a WebSocket (NIP-01) or send Accept: ${Nip11RelayInformation.CONTENT_TYPE}")
}
}
Nested objects, lists, and enforced limits
val info =
relayInformation {
name = "Paid Relay"
supports(1, 11, 42)
supportsExtensions("nip50-search")
countries("US", "CA")
nip50Features("profile_search")
limitation {
maxSubscriptions = 20
maxFilters = 10
authRequired = true
}
fees {
admission(amount = 1000, unit = "msats")
publication(amount = 100, unit = "msats", kinds = listOf(1, 30023))
}
retention(kinds = listOf(0, 3), count = 1)
}
Keep advertised limits in sync with enforced ones. If you build a
RelayLimits for the server's policy chain, hand the same object to the
builder so what you publish can never drift from what you enforce:
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits
val limits = RelayLimits(maxSubscriptions = 20, maxFilters = 10, maxLimit = 500, authRequired = true)
val info =
relayInformation {
name = "My Relay"
supports(1, 11, 42, 45)
limitation(limits)
}
To load an operator-supplied doc from disk or a string instead of building it,
use Nip11RelayInformation.fromJson(json).
geode (Quartz's standalone relay) builds its default document exactly this
way — see geode/.../RelayInfo.kt.
16. Quick Reference
| Task | API | Package |
|---|
| Generate keys | KeyPair() | nip01Core.crypto |
| Create signer | NostrSignerInternal(keyPair) | nip01Core.signers |
| Build event | TextNoteEvent.build(...) or Event.build(kind, content) { tags } | nip10Notes, nip01Core.core |
| 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 |
| 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://...") |
Common Event Kinds
| Kind | Event Type | NIP | Quartz class |
|---|
| 0 | User metadata | 01 | MetadataEvent |
| 1 | Text note | 10 | TextNoteEvent |
| 3 | Follow list | 02 | ContactListEvent |
| 4 | Legacy DM | 04 | PrivateDmEvent |
| 5 | Deletion | 09 | DeletionEvent |
| 6 | Repost | 18 | RepostEvent |
| 7 | Reaction | 25 | ReactionEvent |
| 14 | Chat message (sealed) | 17 | NIP17GroupMessage |
| 1059 | Gift wrap | 59 | GiftWrapEvent |
| 9734 | Zap request | 57 | LnZapRequestEvent |
| 9735 | Zap receipt | 57 | LnZapEvent |
| 10002 | Relay list | 65 | AdvertisedRelayListEvent |
| 30023 | Long-form content | 23 | LongTextNoteEvent |
Related Skills
- nostr-expert — Internal Quartz patterns for Amethyst development
- kotlin-multiplatform — KMP source sets, expect/actual patterns
- kotlin-coroutines — Flow patterns for relay event streams