From 23c3edbec82c4f0804a1f116e6dfc80171419cd6 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 26 Feb 2026 19:37:44 -0500 Subject: [PATCH] docs: add quartz-integration skill for using Quartz in external KMP projects Adds a comprehensive Claude skill covering Gradle setup, key management, event creation/signing, relay client, subscriptions, NIP builders, and platform-specific notes. Also updates the legacy quartz-kmp.md to redirect to the new skill. Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/quartz-integration/SKILL.md | 688 ++++++++++++++++++ .../references/gradle-setup.md | 143 ++++ .claude/skills/quartz-kmp.md | 178 +---- 3 files changed, 849 insertions(+), 160 deletions(-) create mode 100644 .claude/skills/quartz-integration/SKILL.md create mode 100644 .claude/skills/quartz-integration/references/gradle-setup.md diff --git a/.claude/skills/quartz-integration/SKILL.md b/.claude/skills/quartz-integration/SKILL.md new file mode 100644 index 0000000000..173f4a755e --- /dev/null +++ b/.claude/skills/quartz-integration/SKILL.md @@ -0,0 +1,688 @@ +--- +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. +--- + +# Quartz Integration Guide + +Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects. + +**Published artifact**: `com.vitorpamplona.quartz:quartz:1.05.1` (Maven Central) +**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`) +**License**: MIT + +--- + +## 1. Gradle Setup + +### Version Catalog (`libs.versions.toml`) + +```toml +[versions] +quartz = "1.05.1" + +[libraries] +quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } +``` + +### `build.gradle.kts` (KMP project) + +```kotlin +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.quartz) + } + } +} +``` + +### Android-only project + +```kotlin +dependencies { + implementation("com.vitorpamplona.quartz:quartz:1.05.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 | +| `com.linkedin.urls:url-detector` | URL extraction from content | + +For Android, add to `build.gradle.kts`: +```kotlin +android { + packaging { + resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" + } +} +``` + +--- + +## 2. Key Concepts + +### Core Types + +```kotlin +typealias HexKey = String // 64-char hex string (pubkey, event id, sig) +typealias Kind = Int // Event kind number +typealias TagArray = Array> +``` + +### Event Anatomy + +```kotlin +@Immutable +open class Event( + val id: HexKey, // SHA-256 of canonical JSON (64 hex chars) + val pubKey: HexKey, // Author public key (64 hex chars) + val createdAt: Long, // Unix timestamp (seconds) + val kind: Kind, // Event type + val tags: TagArray, // [["e","eventid"], ["p","pubkey"], ...] + val content: String, + val sig: HexKey, // Schnorr signature (128 hex chars) +) +``` + +### Kind Classification + +```kotlin +// Regular events — stored by relays forever +val isRegular = kind in 1..9999 + +// Replaceable events — relay keeps only latest per (pubkey, kind) +val isReplaceable = kind == 0 || kind == 3 || kind in 10000..19999 + +// Addressable events — relay keeps latest per (pubkey, kind, d-tag) +val isAddressable = kind in 30000..39999 + +// Ephemeral events — relays don't persist +val isEphemeral = kind in 20000..29999 +``` + +--- + +## 3. Key Management + +### Generate a new keypair + +```kotlin +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair + +// Generate fresh random keys +val keyPair = KeyPair() + +// From existing private key bytes +val keyPair = KeyPair(privKey = myPrivKeyBytes) + +// Read-only (public key only, cannot sign) +val keyPair = KeyPair(pubKey = myPubKeyBytes) + +// Access +val pubKeyHex: String = keyPair.pubKey.toHexKey() +val privKeyHex: String? = keyPair.privKey?.toHexKey() +``` + +### Convert between formats + +```kotlin +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser + +// ByteArray → hex +val hex = byteArray.toHexKey() + +// hex → ByteArray +val bytes = HexKey.decodeHex(hex) + +// Bech32 import (npub, nsec) +val parsed = Nip19Parser.uriToRoute("npub1abc...") +// or +val parsed = Nip19Parser.uriToRoute("nsec1abc...") +``` + +--- + +## 4. Signing Events + +### `NostrSignerInternal` (local key, JVM + Android) + +```kotlin +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal + +val keyPair = KeyPair() +val signer = NostrSignerInternal(keyPair) + +// Sign any EventTemplate +val template = TextNoteEvent.build("Hello Nostr!") +val signedEvent: TextNoteEvent = signer.sign(template) +``` + +### `NostrSignerSync` (synchronous, for testing) + +```kotlin +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync + +val signerSync = NostrSignerSync(keyPair) +val event = signerSync.sign( + createdAt = TimeUtils.now(), + kind = 1, + tags = emptyArray(), + content = "Hello!" +) +``` + +### NostrSigner interface (for custom signers) + +```kotlin +abstract class NostrSigner(val pubKey: HexKey) { + abstract fun isWriteable(): Boolean + abstract suspend fun sign(createdAt: Long, kind: Int, tags: Array>, 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 fun deriveKey(nonce: HexKey): HexKey + abstract fun hasForegroundSupport(): Boolean + // Convenience: auto-detects NIP-04 vs NIP-44 by ciphertext format + suspend fun decrypt(encryptedContent: String, fromPublicKey: HexKey): String +} +``` + +--- + +## 5. Creating Events + +### Using typed event builders (recommended) + +```kotlin +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent + +// Kind 1 — Text note +val template = TextNoteEvent.build("Hello Nostr!") +val event: TextNoteEvent = signer.sign(template) + +// Kind 1 — Reply +val replyTemplate = TextNoteEvent.build( + note = "Interesting thread!", + replyingTo = originalEventHintBundle +) + +// Kind 7 — Reaction +val reactionTemplate = ReactionEvent.build( + content = "+", // "+" = like, "-" = dislike, emoji = custom + originalNote = targetEvent +) +``` + +### Using low-level `Event.build` DSL + +```kotlin +import com.vitorpamplona.quartz.nip01Core.core.Event + +val template = Event.build( + kind = 1, + content = "Hello world", + createdAt = TimeUtils.now() +) { + // TagArrayBuilder DSL + add(arrayOf("p", mentionedPubKey)) + add(arrayOf("t", "nostr")) + add(arrayOf("subject", "Greeting")) +} + +val event: Event = signer.sign(template) +``` + +### TagArrayBuilder DSL methods + +```kotlin +// In the DSL lambda: +add(arrayOf("tagname", "value")) // append +addFirst(arrayOf("tagname", "value")) // prepend +addUnique(arrayOf("d", "my-slug")) // replace all tags with same name +addAll(listOf(arrayOf("t", "tag1"), ...)) // bulk add +remove("tagname") // remove all with this name +``` + +### Common tags + +```kotlin +arrayOf("e", eventId) // event reference +arrayOf("e", eventId, relayHint, "reply") // threaded reply marker +arrayOf("p", pubKey) // person mention +arrayOf("p", pubKey, relayHint, "author") // with role +arrayOf("a", "30023:pubkey:slug") // addressable event +arrayOf("d", "unique-identifier") // addressable event id +arrayOf("t", "hashtag") // hashtag +arrayOf("subject", "Title") // NIP-14 subject +arrayOf("relay", "wss://relay.example") // relay hint +``` + +--- + +## 6. Relay Client Setup (JVM / Android) + +The relay client requires an OkHttp WebSocket builder (available on JVM + Android). + +### Minimal setup + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import okhttp3.OkHttpClient + +// Build the WebSocket factory +val okHttpClient = OkHttpClient.Builder().build() +val wsBuilder = BasicOkHttpWebSocket.Builder { _ -> okHttpClient } + +// Create client (manages its own CoroutineScope internally) +val nostrClient = NostrClient(websocketBuilder = wsBuilder) +nostrClient.connect() +``` + +### With custom OkHttpClient per relay + +```kotlin +val wsBuilder = BasicOkHttpWebSocket.Builder { normalizedUrl -> + if (normalizedUrl.url.contains(".onion")) { + torEnabledOkHttpClient // Tor proxy for .onion relays + } else { + regularOkHttpClient + } +} +``` + +### With custom CoroutineScope + +```kotlin +val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) +val nostrClient = NostrClient(wsBuilder, scope = appScope) +``` + +--- + +## 7. Subscribing to Events + +### Normalize relay URLs first + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +// Returns NormalizedRelayUrl (wrapper with validated wss:// URL) +val relayUrl = RelayUrlNormalizer.normalize("wss://relay.damus.io") +val relayUrlOrNull = RelayUrlNormalizer.normalizeOrNull("wss://relay.damus.io") + +// Handles common fixes: https:// → wss://, strips whitespace, etc. +``` + +### Build a Filter + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +// Fetch a user's notes +val filter = Filter( + authors = listOf(pubKeyHex), + kinds = listOf(1), + limit = 50 +) + +// Since a timestamp +val filter = Filter( + kinds = listOf(1, 6), + since = System.currentTimeMillis() / 1000 - 3600 // last hour +) + +// By event tags +val filter = Filter( + kinds = listOf(7), + tags = mapOf("e" to listOf(eventId)) // reactions to an event +) + +// AND tag filter (NIP-91) +val filter = Filter( + kinds = listOf(1), + tagsAll = mapOf( + "t" to listOf("nostr"), + "p" to listOf(specificPubKey) + ) +) + +// Full-text search (NIP-50) +val filter = Filter( + kinds = listOf(1), + search = "bitcoin lightning" +) +``` + +### Open a subscription + +```kotlin +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}") + } + } +) + +// Close when done +nostrClient.close(subId) +``` + +### Global relay listener + +```kotlin +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}") + } + // other callbacks: onConnecting, onSent, onCannotConnect +}) +``` + +--- + +## 8. Publishing Events + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +val relaySet = setOf( + RelayUrlNormalizer.normalize("wss://relay.damus.io"), + RelayUrlNormalizer.normalize("wss://nos.lol"), +) + +// Sign the event +val template = TextNoteEvent.build("Hello Nostr!") +val event: TextNoteEvent = signer.sign(template) + +// Send to relays (handles retry + reconnect automatically) +nostrClient.send(event, relaySet) +``` + +--- + +## 9. Event Serialization + +```kotlin +import com.vitorpamplona.quartz.nip01Core.core.Event + +// Serialize to JSON string +val json: String = event.toJson() + +// Parse from JSON string +val event: Event = Event.fromJson(json) + +// Null-safe parse +val event: Event? = Event.fromJsonOrNull(json) + +// Specific typed parse (returns base Event, cast if needed) +val textNote = Event.fromJson(json) as? TextNoteEvent +``` + +--- + +## 10. Bech32 Encoding / Decoding (NIP-19) + +```kotlin +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser + +// Decode any bech32 entity +val result = Nip19Parser.uriToRoute("npub1abc...") +// Returns: NPub | NSec | Note | NEvent | NProfile | NAddr | null + +when (val r = Nip19Parser.uriToRoute(input)) { + is Nip19Parser.Return.NPub -> println("pubkey: ${r.hex}") + is Nip19Parser.Return.Note -> println("event id: ${r.hex}") + is Nip19Parser.Return.NEvent -> println("event: ${r.hex}, relays: ${r.relays}") + is Nip19Parser.Return.NProfile -> println("profile: ${r.hex}") + is Nip19Parser.Return.NAddr -> println("address: ${r.kind}:${r.pubKey}:${r.dTag}") + null -> println("not a valid bech32 entity") + else -> {} +} + +// The parser also handles nostr: URI scheme +val result = Nip19Parser.uriToRoute("nostr:npub1abc...") +``` + +--- + +## 11. Encryption + +### NIP-44 (modern, recommended) + +```kotlin +// Via signer (preferred) +val encrypted = signer.nip44Encrypt( + plaintext = "Secret message", + toPublicKey = recipientPubKeyHex +) +val decrypted = signer.nip44Decrypt( + ciphertext = encrypted, + fromPublicKey = senderPubKeyHex +) + +// Auto-detect format (NIP-04 or NIP-44) +val plaintext = signer.decrypt(encryptedContent, fromPublicKeyHex) +``` + +### NIP-04 (legacy, avoid for new code) + +```kotlin +val encrypted = signer.nip04Encrypt(plaintext, recipientPubKeyHex) +val decrypted = signer.nip04Decrypt(ciphertext, senderPubKeyHex) +``` + +--- + +## 12. Common NIP Event Builders + +### NIP-02 — Follow list (kind 3) + +```kotlin +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) + +```kotlin +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) + +```kotlin +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) + +```kotlin +import com.vitorpamplona.quartz.nip17Dm.NIP17Factory + +// Creates sealed rumor + gift wrap pair +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) + +```kotlin +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" // d-tag +) +``` + +--- + +## 13. Platform-Specific Notes + +### JVM / Desktop + +```kotlin +// jvmMain dependencies needed in consuming project: +// secp256k1-kmp-jni-jvm and lazysodium-java are transitive from quartz +// But you need JNA on the classpath for libsodium: +implementation("net.java.dev.jna:jna:5.18.1") +``` + +### Android + +```kotlin +// androidMain dependencies (transitive from quartz): +// secp256k1-kmp-jni-android, lazysodium-android, jna (aar) +// No extra setup needed beyond the maven dependency. + +// For NIP-55 (Android external signer apps): +import com.vitorpamplona.quartz.nip55AndroidSigner.ExternalSignerLauncher +``` + +### iOS + +The library produces an XCFramework named `quartz-kmpKit`. + +```bash +# Build XCFramework +./gradlew :quartz:assembleQuartz-kmpKitReleaseXCFramework +# Output: quartz/build/XCFrameworks/release/quartz-kmpKit.xcframework +``` + +In Xcode: drag & drop the `.xcframework` into your project, then use from Swift via Kotlin/Native interop. + +--- + +## 14. Event Store (Android only) + +SQLite-based storage with full NIP support (NIP-09, NIP-40, NIP-45, NIP-50, NIP-62): + +```kotlin +import com.vitorpamplona.quartz.nip01Core.store.EventStore +import android.content.Context + +val store = EventStore(context) + +// Insert +store.insertOrReplace(event) + +// Query +val events = store.query( + Filter(authors = listOf(pubKey), kinds = listOf(1), limit = 50) +) + +// Count (NIP-45) +val count = store.count(Filter(kinds = listOf(1))) + +// Full-text search (NIP-50) +val results = store.query(Filter(search = "bitcoin")) +``` + +--- + +## 15. 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` | +| 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` | +| Publish | `client.send(event, setOf(relayUrl))` | `nip01Core.relay.client` | +| NIP-44 encrypt | `signer.nip44Encrypt(text, recipientPubKey)` | `nip01Core.signers` | +| Bech32 decode | `Nip19Parser.uriToRoute("npub1...")` | `nip19Bech32` | +| Bech32 encode | `Nip19Bech32.createNPub(pubKeyHex)` | `nip19Bech32` | + +## 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 diff --git a/.claude/skills/quartz-integration/references/gradle-setup.md b/.claude/skills/quartz-integration/references/gradle-setup.md new file mode 100644 index 0000000000..7a17c0be5e --- /dev/null +++ b/.claude/skills/quartz-integration/references/gradle-setup.md @@ -0,0 +1,143 @@ +# Quartz Gradle Dependency Setup + +## Current version + +``` +com.vitorpamplona.quartz:quartz:1.05.1 +``` + +Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz + +--- + +## KMP Project Setup + +### `gradle/libs.versions.toml` + +```toml +[versions] +quartz = "1.05.1" + +[libraries] +quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } +``` + +### `build.gradle.kts` (library/app module) + +```kotlin +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidKotlinMultiplatformLibrary) // or androidLibrary/androidApplication +} + +kotlin { + jvm() + androidLibrary { + namespace = "com.example.myapp" + compileSdk = 35 + minSdk = 21 + } + // optional: iOS targets + + sourceSets { + commonMain.dependencies { + implementation(libs.quartz) + } + // No platform-specific deps needed — Quartz provides them transitively + } +} +``` + +--- + +## Android-only Project + +```kotlin +// build.gradle.kts (app module) +dependencies { + implementation("com.vitorpamplona.quartz:quartz:1.05.1") +} +``` + +--- + +## JVM (Desktop) standalone + +```kotlin +// build.gradle.kts +plugins { + kotlin("jvm") +} + +dependencies { + implementation("com.vitorpamplona.quartz:quartz:1.05.1") + // JNA needed for libsodium (NIP-44) on JVM + implementation("net.java.dev.jna:jna:5.18.1") +} +``` + +--- + +## Transitive Dependencies (what you get automatically) + +### All platforms (`commonMain`) +- `org.jetbrains.kotlin:kotlin-stdlib` +- `org.jetbrains.kotlinx:kotlinx-coroutines-core` +- `org.jetbrains.kotlinx:kotlinx-collections-immutable` +- `org.jetbrains.kotlinx:kotlinx-serialization-json` +- `androidx.collection:collection` (LruCache) +- `androidx.compose.runtime:runtime-annotation` (@Immutable/@Stable) +- `fr.acinq.secp256k1:secp256k1-kmp` (Schnorr crypto — common) + +### JVM + Android (`jvmAndroid`) +- `com.github.anthonynsimon:rfc3986-normalizer` (URL normalization) +- `com.fasterxml.jackson.module:jackson-module-kotlin` (JSON) +- `com.linkedin.urls:url-detector` (URL extraction) +- `com.squareup.okhttp3:okhttp` (WebSocket) +- `ru.gildor.coroutines:kotlin-coroutines-okhttp` +- `nl.bommber:kchesslib` (NIP-64 chess, version pinned to 1.0.0) + +### JVM only +- `fr.acinq.secp256k1:secp256k1-kmp-jni-jvm` +- `com.goterl:lazysodium-java` (NIP-44 encryption) +- `net.java.dev.jna:jna` + +### Android only +- `fr.acinq.secp256k1:secp256k1-kmp-jni-android` +- `com.goterl:lazysodium-android` +- `net.java.dev.jna:jna` (aar) +- `androidx.core:core-ktx` + +--- + +## Packaging / ProGuard + +Quartz ships consumer ProGuard rules automatically (`publish = true` in the library). +You don't need to add manual keep rules for Quartz classes in your app. + +For Android release builds, these classes are preserved: +- All `com.vitorpamplona.quartz.**` event and model classes +- Jackson serialization annotations + +--- + +## Common Build Errors + +### `Duplicate class kotlin.collections.jdk8` +Add to `gradle.properties`: +```properties +android.useFullClasspathForDexingTransform=true +``` + +### `Could not find lazysodium-java` on JVM +Ensure JNA is on the classpath: +```kotlin +implementation("net.java.dev.jna:jna:5.18.1") +``` + +### iOS: `Framework not found quartz-kmpKit` +Build the XCFramework first: +```bash +./gradlew :quartz:assembleQuartz-kmpKitReleaseXCFramework +``` +Then drag `quartz/build/XCFrameworks/release/quartz-kmpKit.xcframework` into Xcode. diff --git a/.claude/skills/quartz-kmp.md b/.claude/skills/quartz-kmp.md index 49e5000ffb..246c840e37 100644 --- a/.claude/skills/quartz-kmp.md +++ b/.claude/skills/quartz-kmp.md @@ -1,165 +1,23 @@ -# Quartz KMP Conversion Skill +# Quartz KMP (Legacy Skill — Migration Complete) -When working with Quartz library conversion to Kotlin Multiplatform: +> The KMP migration of Quartz is **complete**. This file is kept for historical reference. +> +> For integrating Quartz into external projects, use the **`quartz-integration`** skill instead. +> For working with Quartz internals within Amethyst, use the **`nostr-expert`** skill. + +## What was migrated + +The Quartz library was successfully converted from Android-only to full KMP supporting: +- **commonMain** — All Nostr protocol logic, events, filters, tags +- **jvmAndroid** — OkHttp WebSocket, Jackson JSON, relay serializers +- **androidMain** — SQLite event store, NIP-55 Android signer +- **jvmMain** — Desktop JVM crypto (lazysodium-java, secp256k1-jni-jvm) +- **iosMain** — iOS targets (XCFramework `quartz-kmpKit`) + +## Current artifact -## Current Structure (Android-only) ``` -quartz/ -├── build.gradle -└── src/ - ├── main/kotlin/ # All Nostr code here - ├── test/ - └── androidTest/ +com.vitorpamplona.quartz:quartz:1.05.1 ``` -## Target Structure (KMP) -``` -quartz/ -├── build.gradle.kts -└── src/ - ├── commonMain/kotlin/ # Shared protocol code - ├── commonTest/kotlin/ # Shared tests - ├── androidMain/kotlin/ # Android crypto, storage - ├── androidTest/kotlin/ - ├── jvmMain/kotlin/ # Desktop crypto, storage - └── jvmTest/kotlin/ -``` - -## Platform Abstractions Required - -### 1. Cryptography (expect/actual) -```kotlin -// commonMain -expect object Secp256k1 { - fun sign(data: ByteArray, privateKey: ByteArray): ByteArray - fun verify(data: ByteArray, signature: ByteArray, pubKey: ByteArray): Boolean - fun pubKeyCreate(privateKey: ByteArray): ByteArray -} - -// androidMain - uses secp256k1-kmp-jni-android -actual object Secp256k1 { - actual fun sign(data: ByteArray, privateKey: ByteArray): ByteArray { - return fr.acinq.secp256k1.Secp256k1.sign(data, privateKey) - } - // ... -} - -// jvmMain - uses secp256k1-kmp-jni-jvm -actual object Secp256k1 { - actual fun sign(data: ByteArray, privateKey: ByteArray): ByteArray { - return fr.acinq.secp256k1.Secp256k1.sign(data, privateKey) - } - // ... -} -``` - -### 2. NIP-44 Encryption (Sodium) -```kotlin -// commonMain -expect object Nip44 { - fun encrypt(plaintext: String, sharedSecret: ByteArray): String - fun decrypt(ciphertext: String, sharedSecret: ByteArray): String -} - -// androidMain - lazysodium-android -// jvmMain - lazysodium-java or libsodium-jni -``` - -### 3. Secure Random -```kotlin -// commonMain -expect fun secureRandomBytes(size: Int): ByteArray - -// androidMain -actual fun secureRandomBytes(size: Int): ByteArray { - return SecureRandom().let { random -> - ByteArray(size).also { random.nextBytes(it) } - } -} - -// jvmMain -actual fun secureRandomBytes(size: Int): ByteArray { - return java.security.SecureRandom().let { random -> - ByteArray(size).also { random.nextBytes(it) } - } -} -``` - -## Build Configuration - -```kotlin -// quartz/build.gradle.kts -plugins { - kotlin("multiplatform") - id("com.android.library") -} - -kotlin { - androidTarget { - compilations.all { - kotlinOptions.jvmTarget = "17" - } - } - - jvm("desktop") { - compilations.all { - kotlinOptions.jvmTarget = "17" - } - } - - sourceSets { - val commonMain by getting { - dependencies { - implementation(libs.kotlinx.coroutines.core) - implementation(libs.kotlinx.collections.immutable) - } - } - - val androidMain by getting { - dependencies { - implementation(libs.secp256k1.kmp.jni.android) - implementation(libs.lazysodium.android) - } - } - - val desktopMain by getting { - dependencies { - implementation(libs.secp256k1.kmp.jni.jvm) - // lazysodium-java or alternative - } - } - } -} - -android { - namespace = "com.vitorpamplona.quartz" - compileSdk = 35 - defaultConfig.minSdk = 26 -} -``` - -## Migration Steps - -1. **Convert build.gradle to build.gradle.kts** with KMP plugin -2. **Move pure Kotlin code** to `commonMain/` -3. **Identify platform dependencies** (crypto, JNA, Android APIs) -4. **Create expect declarations** for platform-specific APIs -5. **Implement actuals** in androidMain and jvmMain -6. **Update imports** in amethyst module -7. **Test on both platforms** - -## Files to Move to commonMain - -Most of Quartz can be shared: -- Event classes and parsing -- Filter definitions -- Relay message types -- NIP implementations (logic only) -- Utilities (hex encoding, bech32) - -## Files Needing expect/actual - -- `Secp256k1.kt` - Signature operations -- `Nip04.kt` - Legacy encryption (uses AES) -- `Nip44.kt` - Modern encryption (uses ChaCha) -- `KeyPair.kt` - Key generation +See `.claude/skills/quartz-integration/SKILL.md` for full integration guide. \ No newline at end of file