Files
amethyst/.claude/skills/auth-signers/SKILL.md
T
Claude 6d2c37c346 refactor(quartz): rename event classes to match their NIP names
Old class names drifted from the NIP wording (or described the wrong
kind). Every old name stays available as a @Deprecated typealias with a
ReplaceWith, so external Quartz users get an IDE quick-fix instead of a
break. Wire format is unchanged: parsing goes by kind.

Renames:
- FollowListEvent (39089) -> StarterPackEvent
- LnZapPaymentRequestEvent/ResponseEvent (NIP-47) -> NwcRequestEvent/NwcResponseEvent
- VideoHorizontalEvent/VideoVerticalEvent -> AddressableNormalVideoEvent/AddressableShortVideoEvent
- CalendarEvent (31924) -> CalendarCollectionEvent
- HashtagListEvent -> InterestListEvent
- ContactCardEvent (30382) -> UserAssertionEvent
- LnZapEvent/LnZapRequestEvent/LnZapEventInterface -> ZapReceiptEvent/ZapRequestEvent/ZapReceiptEventInterface
- PrivateDmEvent -> EncryptedDmEvent, DeletionEvent -> DeletionRequestEvent
- LongTextNoteEvent -> LongFormContentEvent, WikiNoteEvent -> WikiArticleEvent
- PeopleListEvent -> FollowSetEvent, LabeledBookmarkListEvent -> BookmarkSetEvent
- RelayFeedsListEvent -> FavoriteRelayListEvent, EmojiPackSelectionEvent -> EmojiListEvent
- ChannelListEvent -> PublicChatListEvent, ChatMessageRelayListEvent -> DmRelayListEvent
- SealedRumorEvent -> SealEvent, FileHeaderEvent -> FileMetadataEvent
- GoalEvent -> ZapGoalEvent, StatusEvent -> UserStatusEvent
- NIP90*Event -> Dvm*Event (all NIP-90 events)
- NIP-29 group events get the Group prefix to separate them from NIP-43:
  PutUser, RemoveUser, EditMetadata, DeleteEvent, CreateInvite,
  UpdatePinList, JoinRequest, LeaveRequest; SupportedRolesEvent -> GroupRolesEvent

Merged duplicate classes for the same kind:
- nip61 TokenEvent (7375) was unreachable in EventFactory; now an alias of CashuTokenEvent.
- nip61 NutzapRedemptionEvent (7376) was unreachable; CashuSpendingHistoryEvent now
  carries its hint providers and redeemedNutzaps(), plus
  CashuSpendingHistoryEvent.buildNutzapRedemption.
- NIP-82 SoftwareReleaseEvent (30063) was never built by EventFactory;
  ReleaseArtifactSetEvent now exposes appId/version/channel/assets/releaseNotes
  and buildSoftwareRelease, and the app no longer re-parses the event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VvJ8XpeYqBe8bYT3bJ7UD
2026-09-26 20:19:55 +00:00

105 lines
6.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: auth-signers
description: Signer abstraction patterns in Amethyst. Use when working with event signing, choosing between a local keypair (`NostrSignerInternal`), a remote NIP-46 bunker signer (`NostrSignerRemote`), or a NIP-55 Android external-app signer (`NostrSignerExternal`). Covers the abstract `NostrSigner` base class, `SignerResult` contract, how to wire a new flow that needs to sign events, and the security/UX trade-offs between signer kinds.
---
# Auth & Signers
Any time Amethyst produces a signed Nostr event, it goes through a `NostrSigner`. There are three kinds; all three implement the same abstract contract so feature code doesn't care which one the user has configured.
## When to Use This Skill
- Adding a new flow that publishes an event (follow, post, react, zap, profile edit).
- Reviewing whether a feature works when the user has a remote bunker signer or an external Android signer.
- Debugging "Sign request approved but nothing happens" / timeouts on sign operations.
- Onboarding a new signer kind (hardware signer, browser extension, etc.).
- Understanding the NIP-46 bunker request/response taxonomy.
## The Abstract Contract
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt`:
```kotlin
abstract class NostrSigner(val pubKey: HexKey) {
abstract fun <T : Event> sign(
template: EventTemplate<T>,
onReady: (T) -> Unit,
)
abstract fun nip04Encrypt(plaintext: String, toPubKey: HexKey, onReady: (String) -> Unit)
abstract fun nip04Decrypt(ciphertext: String, fromPubKey: HexKey, onReady: (String) -> Unit)
abstract fun nip44Encrypt(...)
abstract fun nip44Decrypt(...)
abstract fun decryptZapEvent(event: ZapRequestEvent, onReady: (ZapRequestEvent) -> Unit)
}
```
Sibling files in the same folder:
- **`NostrSignerInternal.kt`** — in-process signer with the user's seckey in memory. Fastest; used for locally-stored accounts.
- **`NostrSignerSync.kt`** — blocking wrapper for scripts / migrations / tests where callbacks are inconvenient.
- **`EventTemplate.kt`** — the unsigned holder passed to `sign()`.
- **`SignerExceptions.kt`** — the error taxonomy (user denied, timeout, unsupported method, etc.).
- **`caches/`** — request cache so duplicate sign/encrypt requests coalesce.
### Concrete implementations
- **Local (in-process)**: `NostrSignerInternal` — direct `Secp256k1Instance.signSchnorr` + NIP-44 inline. Used by accounts created/imported into Amethyst.
- **Remote (NIP-46 bunker)**: `quartz/.../nip46RemoteSigner/signer/NostrSignerRemote.kt`. Talks to a bunker service over Nostr DMs using the `BunkerRequest*` / `BunkerResponse*` event taxonomy (`BunkerRequestConnect`, `BunkerRequestSign`, `BunkerRequestNip44Encrypt`, …).
- **Android external (NIP-55)**: `quartz/src/androidMain/.../nip55AndroidSigner/client/NostrSignerExternal.kt`. Uses Android intents + content provider to delegate to another app on the same device. Launcher: `ExternalSignerLogin.kt`, `IActivityLauncher.kt`. Install-check: `IsExternalSignerInstalled.kt`.
## The `SignerResult` Contract
Signers return via callback (and internally track via `SignerResult` sealed types in `nip46RemoteSigner/signer/SignerResult.kt` and `nip55AndroidSigner/api/SignerResult.kt`). Result variants cover success, user-denied, timeout, remote-disconnected, unsupported. Feature code should:
1. Pass a callback that handles success.
2. Trust the cache/timeout behavior — don't roll your own retry.
3. Surface `SignerExceptions` to the user with actionable messaging (e.g. "Bunker disconnected — reconnect?").
## Typical Flow (Feature Code)
```kotlin
// High-level: Account methods already do this internally.
val signer: NostrSigner = account.signer // whichever kind the user configured
val template = reactionEventTemplate(noteId, authorPubKey, "+")
signer.sign(template) { signed ->
account.sendToRelays(signed) // or similar pipeline
}
```
Most feature code should go through `Account`'s mutation methods (`account.sendReaction`, `account.follow`) rather than touching the signer directly — the account layer handles signing + publishing + local state update atomically. Reach for the signer directly only when `Account` doesn't have a helper.
## Choosing a Signer at Sign-Up
Entry points:
- **Existing private key** (`nsec`, 32-byte hex, file) → `NostrSignerInternal`.
- **Bunker URL** (`bunker://...`) → `NostrSignerRemote.fromBunkerUri(bunkerUri, localSigner, client)` in `nip46RemoteSigner/signer/NostrSignerRemote.kt` parses the URI and returns a `NostrSignerRemote`; then call its `suspend fun connect()` to perform the NIP-46 handshake.
- **Installed external signer app** (Amber, nos2x, etc. on Android) → `ExternalSignerLogin.launch(...)` opens the signer app; approval yields a `NostrSignerExternal`.
The UI hosts both flows via `amethyst/.../ui/screen/loggedOff/login/` — look there for `ExternalSignerButton.kt` and the bunker-URL paste screen.
## Trade-offs
| Signer | Latency | Offline OK? | Security | UX |
|--------|---------|-------------|----------|-----|
| Internal | µs | Yes | Key in app memory | No confirmation prompts |
| Remote (NIP-46) | 100ms–seconds | No (needs bunker reachable) | Key never touches Amethyst | Occasional approval prompts |
| External (NIP-55) | 100–500ms | Yes | Key in separate app | Prompt on every sign by default (configurable) |
## Gotchas
- **Callbacks may never fire.** External signers can be dismissed without result; remote signers can time out. Use `SignerExceptions` / timeout handling at every call site or rely on the `Account` layer's wrapping.
- **`nip04Encrypt` is legacy** for NIP-04 DMs. New DM code should use NIP-17 gift-wrap → `nip44Encrypt` path.
- **Don't cache signer output** beyond the `caches/` that quartz already maintains. Stale cache entries lead to duplicate publishes.
- **Remote signer disconnects** need explicit reconnection UX — `RemoteSignerManager` exposes state; hook into it for an account-switching warning.
- **External signer launch requires an Activity context** — it can't happen from a background service. Structure flows so signing is on the main dispatcher through an activity-scoped launcher.
- **`NostrSignerSync`** is rare. If you reach for it, you're probably in a test or migration — production code uses the async API.
## References
- `references/nip46-remote-signer.md` — the NIP-46 bunker message taxonomy and connection lifecycle.
- `references/nip55-android-signer.md` — Android intent-based external signer flow.
- Complements: `nostr-expert/references/crypto-and-encryption.md` (the crypto under all signers), `account-state` (which wraps signer calls), `android-expert` (intent launcher patterns).