Refreshes .claude/ skill library: fixes stale refs, adds 4 new skills

- Updates CLAUDE.md tech stack to current versions (Compose 1.10.3, Kotlin 2.3.20).
- Reframes kotlin-multiplatform iOS as mature; adds secp256k1-kmp 0.23.0 references.
- Updates desktop-expert Main.kt references (code grew from ~270 to 1341 lines and
  NavigationRail moved to ui/deck/SinglePaneLayout.kt); replaces obsolete
  "hardcoded ctrl = true" anti-pattern note with accurate isMacOS branching.
- Removes compose-desktop.md (superseded by desktop-expert/).
- Adds nostr-expert references: nip19-bech32, event-factory, crypto-and-encryption,
  large-cache. Adds kotlin-expert/common-utilities, compose-expert/rich-text-parsing,
  android-expert/image-loading.
- New skills: account-state (Account + LocalCache), relay-client (subscriptions,
  filter assemblers, preloaders), feed-patterns (FeedFilter + FeedViewModel family),
  auth-signers (NostrSigner across internal / NIP-46 / NIP-55).
This commit is contained in:
Claude
2026-04-21 21:00:45 +00:00
parent 9147f1b08b
commit 60edd473c7
31 changed files with 1743 additions and 373 deletions
+4
View File
@@ -514,6 +514,10 @@ Or see `references/nip-catalog.md` for complete catalog.
- **references/nip-catalog.md** - All 57 NIPs with package locations and key files
- **references/event-hierarchy.md** - Event class hierarchy, kind classifications, common types
- **references/tag-patterns.md** - Tag structure, TagArrayBuilder DSL, common tag types, parsing patterns
- **references/nip19-bech32.md** - `Nip19Parser`, `Bech32Util`, `TlvBuilder`, entity types (NPub, NSec, NEvent, NAddress, NProfile, NRelay, NEmbed)
- **references/event-factory.md** - `EventFactory` dispatch pattern and how to register a new kind
- **references/crypto-and-encryption.md** - Event signing/verification, secp256k1 abstraction, NIP-44 encryption, `SharedKeyCache`
- **references/large-cache.md** - `LargeCache<K,V>` expect/actual + `ICacheOperations` functional API
- **scripts/nip-lookup.sh** - Find NIP implementations by number or search term
## Quick Reference
@@ -0,0 +1,80 @@
# Crypto & Encryption in Quartz
Event signing, hashing, and NIP-44 payload encryption.
## Layout
### Core crypto (`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/`)
- `EventHasher.kt` — canonical JSON serialization + SHA-256 → event id. NIP-01 §1.
- `EventHasherSerializer.kt` — Jackson serializer that emits the exact byte layout NIP-01 hashing requires.
- `KeyPair.kt` — holder for `privateKey: ByteArray` + derived `pubKey: ByteArray`. Generates fresh key pairs via `secureRandom`.
- `Nip01Crypto.kt` — one-stop helper: sign an event, verify a signature, derive pubkey from seckey.
- `EventAssembler.kt` — takes an unsigned template + signer and produces a fully populated `Event`.
- `EventExt.kt``Event.verify()` / `Event.hasValidSignature()` extensions.
### secp256k1 abstraction (`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/`)
- `Secp256k1Instance.kt``expect object` with `signSchnorr`, `verifySchnorr`, `pubKey(seckey)`, `sharedSecret`.
- `Secp256k1InstanceC.kt` — C-based actual using secp256k1 JNI (Android/JVM).
- `Secp256k1InstanceKotlin.kt` — pure-Kotlin actual (iOS via native, etc.).
- Android actual: `secp256k1-kmp-jni-android` (0.23.0). JVM actual: `secp256k1-kmp-jni-jvm`.
### NIP-44 encryption (`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/`)
- `Nip44.kt` — dispatcher that handles both v1 (ChaCha20 w/o Poly1305, legacy) and v2 (XChaCha20-Poly1305).
- `Nip44v2.kt` — current spec: HKDF key derivation → XChaCha20-Poly1305 → padded plaintext → Base64.
- `Nip44v1.kt` — legacy path (decrypt-only for backward compat; do not encrypt with v1).
- `crypto/``ChaCha20Poly1305`, `HKDF`, `Hmac`, etc. (pure Kotlin, MPP-friendly).
- `SharedKeyCache.kt` — in-process LRU for ECDH shared secrets. Critical for performance in chat/list screens that decrypt many messages with the same counterparty.
- `EncryptedInfoString.kt` — versioned payload envelope that the parser reads to pick v1 vs v2.
## Typical Flows
### Sign an event
```kotlin
// Direct (when you have the privkey in memory)
val signed = Nip01Crypto.sign(unsignedEvent, keyPair.privateKey)
// Via signer (preferred — honors external/remote signers)
val signer: NostrSigner = ... // NostrSignerInternal, Nip46RemoteSigner, NostrSignerExternal
signer.sign(template) { signed -> /* emit signed event */ }
```
Use `NostrSigner` whenever the key might not live in the current process (NIP-46 bunker, NIP-55 Android external signer). See the `auth-signers` skill.
### Verify an event
```kotlin
event.verify() // throws on failure
event.hasValidSignature() // returns Boolean
```
Both recompute `sha256(canonicalJson(event))` and call Schnorr `verifySchnorr(sig, hash, pubKey)`.
### NIP-44 encrypt / decrypt
```kotlin
// Always compute shared secret through the cache — direct ECDH is expensive
val sharedSecret = SharedKeyCache.getOrComputeShared(mySeckey, theirPubkey)
val cipherText = Nip44.encrypt(plaintext, sharedSecret) // v2 by default
val plain = Nip44.decrypt(cipherText, sharedSecret) // dispatches on version byte
```
Callers rarely touch `Nip44v2` directly; go through `Nip44`.
## Gotchas
- **Never log private keys, shared secrets, or raw plaintext.** `KeyPair.privateKey` is a `ByteArray` on purpose so it doesn't get interned as a String.
- **Don't recompute ECDH per message.** `SharedKeyCache` exists because the same counterparty appears in many messages; bypassing the cache produces noticeable UI lag.
- **`EventHasher` ordering is canonical.** Serialize tags / content exactly as `EventHasherSerializer` emits, or ids won't match relays.
- **secp256k1 JNI is platform-specific**: if you add crypto that must run in `commonTest`, wrap it in `expect/actual` or you'll get `UnsatisfiedLinkError` in JVM unit tests.
- **NIP-44 pads messages**. Don't assert exact ciphertext length; assert decrypt round-trips.
## Tests
- `quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/` — sign/verify/hash round-trips.
- `quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/` — NIP-44 vectors (encryption parity with reference vectors).
- JNI crypto is exercised in `androidUnitTest` / JVM integration tests.
@@ -0,0 +1,68 @@
# EventFactory: Parsing JSON into Typed Events
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt` is the single dispatch point that turns a parsed `(id, pubKey, createdAt, kind, tags, content, sig)` tuple into the correct `Event` subclass.
## What It Does
EventFactory is a giant `when` over `kind` that maps integer kind values to concrete event classes. If a kind isn't recognized, it falls back to the generic base `Event` (so unknown kinds still round-trip). Every NIP that defines a new kind registers its class here.
Typical shape:
```kotlin
object EventFactory {
fun create(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
kind: Int,
tags: TagArray,
content: String,
sig: HexKey,
): Event = when (kind) {
MetadataEvent.KIND -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
TextNoteEvent.KIND -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig)
ContactListEvent.KIND -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
ReactionEvent.KIND -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
// …hundreds more…
else -> Event(id, pubKey, createdAt, kind, tags, content, sig)
}
}
```
Callers are normally upstream of this: `Event.fromJson(...)` / `EventMapper.fromJson(...)` / the relay client's message parser. You rarely call EventFactory directly — you consume typed events it produces.
## Registering a New Event Kind
Adding a NIP is roughly:
1. Create the event class under `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXX…/` alongside the NIP package.
2. Subclass the right base:
- `Event` — regular events (stored forever).
- `BaseReplaceableEvent` — kinds `10000-19999`, `0`, `3`.
- `BaseAddressableEvent` — kinds `30000-39999` (identified by `kind:pubkey:d-tag`).
- Ephemeral events extend `Event` but have kind `20000-29999`.
3. Define `companion object { const val KIND = <n> }`.
4. If the event has tag builders, define a `TagArrayBuilder<YourEvent>` DSL in a `TagArrayBuilder` extension — see `nostr-expert/references/tag-patterns.md`.
5. Add a branch to `EventFactory.create(...)` so JSON parsing produces your typed class.
6. If the event is addressable, ensure it exposes a stable `dTag()` and `address()`.
7. Add tests under `quartz/src/commonTest/...`.
## Why a Monolithic when?
- **Zero overhead**: compiled to a dense lookup. No reflection, no registry map.
- **Exhaustive browsing**: every known kind lives at one search location. `grep KIND = 1234 quartz/...` finds everything.
- **Obvious migration path**: adding a kind means adding a case; removing a kind is a grep-and-delete.
The tradeoff is the file is large and every new kind edits the same file — expect merge conflicts in PRs that touch it, and resolve by keeping both branches.
## Supporting Utilities
- `EventAssembler.kt` (crypto/) — higher-level helper that takes a signer and a `kind + tags + content` and produces a fully signed event (id + sig populated).
- `EventTemplate.kt` (signers/) — unsigned-event holder, useful in signer flows.
- `Event.fromJson(...)` / `Event.toJson()` — JSON round-trip using `OptimizedJsonMapper` (Jackson on jvmAndroid).
## Related References
- `event-hierarchy.md` — class hierarchy, Kind ranges
- `nip-catalog.md` — which kind maps to which NIP
- `tag-patterns.md``TagArrayBuilder` DSL for writing tags cleanly
@@ -0,0 +1,63 @@
# LargeCache: Platform-Aware In-Memory Store
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/cache/LargeCache.kt` provides a thread-safe key-value cache with a functional iteration API. Used everywhere Amethyst needs to hold many events, users, or derived state in memory.
## Files
- `LargeCache.kt``expect class LargeCache<K, V>` and its factory `createLargeCache()`.
- `ICacheOperations.kt` — interface the cache exposes: `forEach`, `filter`, `map`, `mapNotNull`, `groupBy`, `maxOrNullOf`, `sumOf`, `count`, `any`, `firstOrNull`, etc.
- `CacheCollectors.kt` — functional collector helpers used by the cache API.
### Actual implementations
- **Android** (`androidMain`) — backed by a `ConcurrentHashMap` (and optionally `androidx.collection.LruCache` variants for size-bounded caches).
- **JVM/Desktop** (`jvmMain`) — `ConcurrentHashMap` directly.
- **iOS** (`iosMain`) — `NSMapTable`/Kotlin concurrent map wrapper.
## Core API
```kotlin
val cache: LargeCache<HexKey, Note> = LargeCache()
cache.put(id, note)
cache.get(id) // V?
cache.getOrCreate(id) { Note(id) } // atomic compute-if-absent
cache.containsKey(id)
cache.remove(id)
cache.size()
// Functional iteration — thread-safe snapshot semantics
cache.forEach { key, value -> ... }
cache.filter { key, value -> value.kind == 1 }
cache.map { key, value -> value.pubKey }
cache.count { _, v -> v.isUnread }
cache.maxOrNullOf { _, v -> v.createdAt }
cache.groupBy { _, v -> v.kind }
```
The important contract: **functional operations iterate a consistent snapshot**, so you can `filter` inside a coroutine without racing concurrent writers. This is why `LocalCache` (the Amethyst event store) can be scanned to build a feed while relays are still inserting.
## When to Use
- **Event / note stores** — `LocalCache.notes: LargeCache<HexKey, Note>`.
- **User profiles** — `LocalCache.users: LargeCache<HexKey, User>`.
- **Address → event** lookups for addressable (parameterized replaceable) events.
- **Shared-secret caches** (see `SharedKeyCache.kt` — a similar pattern at smaller scale).
## When Not to Use
- Small maps (<100 entries) — regular `mutableMapOf` is fine.
- Off-process state (DB, disk) — use the `store/` event DB, not LargeCache.
- Hot one-shot lookups — if you're already inside a Flow pipeline, chain operators rather than maintaining a parallel cache.
## Gotchas
- **`getOrCreate` vs `put`** — `getOrCreate` is atomic and safe under contention; `get` then `put` is a race.
- **Iteration during mutation is safe** but the snapshot may include or exclude a concurrent write. Don't rely on a just-put value being visible inside a currently-running `forEach`.
- **Don't store `Flow`s inside LargeCache.** Cache values should be immutable / thread-safe objects. For reactive state, keep a `StateFlow` next to the cache and emit on writes.
- **No TTL / eviction by default.** If you need bounded size, wrap with `LruCache` or build an explicit eviction loop keyed off a secondary structure.
## Related
- `amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt` — the canonical user of `LargeCache<HexKey, Note>` and `LargeCache<HexKey, User>`.
- `nip44Encryption/SharedKeyCache.kt` — smaller domain-specific cache using the same pattern.
@@ -0,0 +1,83 @@
# NIP-19: Bech32 Encoding & Parsing
Quartz implementation for `npub`, `nsec`, `note`, `nevent`, `nprofile`, `naddr`, `nrelay`, `nembed` — the user-facing encoded forms of Nostr identifiers.
## Layout
All under `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/`:
- `Nip19Parser.kt` — the entry point. Parses any Bech32 or `nostr:` URI into a typed `Entity`.
- `bech32/Bech32Util.kt` — raw Bech32 encode/decode (bits ↔ 5-bit groups).
- `tlv/Tlv.kt` / `tlv/TlvBuilder.kt` — Type-Length-Value codec for composite entities (`nevent`, `nprofile`, `naddr`).
- `TlvTypes.kt` — TLV type constants (0 = special payload, 1 = relay, 2 = author, 3 = kind).
- `entities/` — one class per entity type (see below).
- `ATagExt.kt`, `ByteArrayExt.kt`, `EventExt.kt`, `ListEntityExt.kt`, `TlvBuilderExt.kt` — convenience extensions for encoding domain objects directly.
## Entity Types
Each is a `sealed class Entity` subclass under `entities/`:
| Class | Prefix | Payload | Purpose |
|-------------|------------|---------------------------------------------------|---------|
| `NPub` | `npub1...` | 32-byte pubkey | Public key |
| `NSec` | `nsec1...` | 32-byte private key | Private key (never log/share) |
| `NNote` | `note1...` | 32-byte event id | Bare note reference (no hints) |
| `NEvent` | `nevent1…` | TLV: event id + relays + author + kind | Rich note reference |
| `NProfile` | `nprofile…`| TLV: pubkey + relays | User reference with relay hints |
| `NAddress` | `naddr1…` | TLV: d-tag + relays + author + kind (addressable) | Parameterized replaceable event |
| `NRelay` | `nrelay1…` | TLV: relay URL | Relay pointer |
| `NEmbed` | `nembed1…` | Compressed event JSON | Full event embedded inline |
## Parsing
```kotlin
// From anywhere (URI, Bech32, nostr: prefix, "nostr:" + data):
val entity: Entity? = Nip19Parser.uriToRoute(input)?.entity
// More forgiving — strips scheme, whitespace, surrounding chars:
val parsed = Nip19Parser.tryParseAndClean(dirtyInput)
when (entity) {
is NPub -> entity.hex // 32-byte pubkey hex
is NEvent -> entity.hex + entity.relay + entity.author + entity.kind
is NAddress -> entity.atag // kind:pubkey:d-tag
is NProfile -> entity.hex + entity.relay
// …
}
```
## Encoding
The cleanest path is the entity's `toNostrUri()` / `toBech32()` methods (each entity class defines them). For composite entities (NEvent, NProfile, NAddress), internally the code builds a TLV buffer via `TlvBuilder`:
```kotlin
// TlvBuilder DSL (tlv/TlvBuilder.kt)
val bytes = TlvBuilder().apply {
addHex(TlvTypes.SPECIAL, eventIdHex)
addString(TlvTypes.RELAY, relayUrl)
addHex(TlvTypes.AUTHOR, authorHex)
addInt(TlvTypes.KIND, kind)
}.build()
Bech32Util.encode("nevent", bytes)
```
Kotlin-idiomatic extension helpers live in `TlvBuilderExt.kt`, `EventExt.kt`, and `ATagExt.kt` — prefer those over hand-building TLV.
## When to Use
- **Pasted input from users** → `Nip19Parser.tryParseAndClean` (handles prefixes, whitespace, leftover `nostr:`)
- **Internal routing / deep links** → `Nip19Parser.uriToRoute`
- **Outbound share links** → call the entity's `toNostrUri()` / `toBech32()` directly
- **Building a custom TLV entity** → `TlvBuilder` DSL + `Bech32Util.encode`
## Gotchas
- `NSec` should never be logged or propagated. Parse and discard the string buffer.
- Relay hints in `NEvent`/`NProfile`/`NAddress` are hints, not guarantees. The Outbox model (NIP-65) overrides them.
- TLV types are fixed (see `TlvTypes.kt`); do not reorder or invent new types without NIP-19 support.
- `NEmbed` is an Amethyst-specific compressed-event extension, not part of NIP-19 proper.
## Tests
See `quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/` for round-trip tests covering every entity and `Nip19Parser` input cleaning.