diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index c1ba392b9d..3ee4d735e9 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -76,7 +76,9 @@ amethyst/ - `nestsClient/` = MoQ + audio-rooms client; takes `:quic` as transport, Quartz for crypto, `MediaCodec` / `AudioRecord` / `AudioTrack` for audio. - `amethyst/` & `desktopApp/` = Platform-native layouts and navigation -- `cli/` = Thin assembly layer over `quartz/` + `commons/` (no new logic allowed) +- `cli/` = Thin assembly layer over `quartz/` + `commons/` (no new logic + allowed). May also depend on `:geode` (for `amy serve`, which embeds the + standalone relay); never on `:amethyst` or `:desktopApp`. **Plans per module:** design docs for new subsystems live in the owning module's `plans/YYYY-MM-DD-.md` (e.g. `cli/plans/`, `commons/plans/`). diff --git a/.claude/skills/amy-expert/SKILL.md b/.claude/skills/amy-expert/SKILL.md index f6cbce8416..b7eaa489f6 100644 --- a/.claude/skills/amy-expert/SKILL.md +++ b/.claude/skills/amy-expert/SKILL.md @@ -189,15 +189,30 @@ cli/ ├── MessageCommands.kt ├── MarmotResetCommand.kt ├── AwaitCommands.kt - └── StoreCommands.kt + ├── StoreCommands.kt + ├── AdminCommand.kt # `amy admin RELAY METHOD` (NIP-86) + ├── ServeCommand.kt # `amy serve` (embeds :geode) + └── cashu/ # `amy cashu …` (NIP-60/61) — thin wrappers + ├── CashuCommands.kt # over commons CashuWalletOps / CashuWalletReader + ├── CashuWalletCommands.kt + CashuBalanceCommand.kt + CashuMintCommands.kt + └── CashuReceiveCommands.kt + CashuSendCommands.kt + + CashuMaintenanceCommands.kt + CashuMintRecCommands.kt ``` Shared logic consumed by Amy lives in `commons/`: - `commons/account/` — account bootstrap - `commons/marmot/` — MLS / group state +- `commons/cashu/` — `ops/CashuWalletOps` (jvmAndroid) + `CashuWalletReader` + + `CashuKeysetCounterStore`; the NIP-60/61 wallet, shared with Android. +- `commons/relayManagement/Nip86Retriever` — NIP-86 HTTP client, shared with + the Android relay-management screen. - `commons/defaults/` — default relays, kinds - Consult `commons/plans/` for cross-cutting design work in flight. +A few amy verbs lean on modules beyond `quartz`/`commons`: `amy serve` +depends on `:geode` (the standalone relay) — the one allowed extra module +dependency. `:amethyst` / `:desktopApp` remain forbidden (Rule 5). + ## Common mistakes to refuse - **Adding protocol logic to `cli/`.** Push back, offer to extract. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index 82961b923d..48f372ae84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -20,6 +20,14 @@ */ package com.vitorpamplona.amethyst.model.nip60Cashu +import com.vitorpamplona.amethyst.commons.cashu.CashuWalletReader +import com.vitorpamplona.amethyst.commons.cashu.ops.CashuWalletOps +import com.vitorpamplona.amethyst.commons.cashu.ops.MeltCompleted +import com.vitorpamplona.amethyst.commons.cashu.ops.NutzapSent +import com.vitorpamplona.amethyst.commons.cashu.ops.RestoreOutcome +import com.vitorpamplona.amethyst.commons.cashu.ops.SendTokenCompleted +import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry +import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletQueryState import com.vitorpamplona.amethyst.model.AccountSettings @@ -35,7 +43,6 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent import com.vitorpamplona.quartz.nip60Cashu.mintApi.DeterministicSecretFactory import com.vitorpamplona.quartz.nip60Cashu.mintApi.MeltQuoteBolt11ResponseDto -import com.vitorpamplona.quartz.nip60Cashu.mintApi.ProofState import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent import com.vitorpamplona.quartz.nip60Cashu.seed.CashuDeterministic import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent @@ -45,7 +52,6 @@ import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent import com.vitorpamplona.quartz.utils.Log -import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.secp256k1.Secp256k1 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -732,44 +738,13 @@ class CashuWalletState( } } - // Apply `del` rollover. - val deletedIds = mutableSetOf() - all.forEach { evt -> tokenContents[evt.id]?.del?.let(deletedIds::addAll) } - - val unspent = - all - .filter { it.id !in deletedIds && tokenContents.containsKey(it.id) } - .mapNotNull { evt -> tokenContents[evt.id]?.let { TokenEntry(evt, it) } } - .sortedByDescending { it.event.createdAt } - - _tokenEntries.value = unspent + // Shared del-rollover + sort with the headless reader. + _tokenEntries.value = CashuWalletReader.computeUnspent(all, tokenContents) } private fun recomputePending() { - val now = TimeUtils.now() - // A quote is "pending" if (1) not expired, and (2) no kind:7376 history - // event references its id with a "destroyed" marker — completion of the - // mint flow deletes the kind:7374, and history records a `destroyed` - // reference to the now-fulfilled quote. - val destroyedQuoteIds = - historyEvents.values - .asSequence() - .flatMap { it.tags.asSequence() } - .filter { it.size >= 4 && it[0] == "e" && it[3] == "destroyed" } - .map { it[1] } - .toSet() - - _pendingQuotes.value = - quoteEvents.values - .filter { it.id !in destroyedQuoteIds } - .filter { evt -> - val exp = - evt.tags - .firstOrNull { it.size >= 2 && it[0] == "expiration" } - ?.get(1) - ?.toLongOrNull() - exp == null || exp > now - }.sortedByDescending { it.createdAt } + // Shared destroyed/expired filter with the headless reader. + _pendingQuotes.value = CashuWalletReader.computePending(quoteEvents.values, historyEvents.values) } private fun scanCacheForOwnEvents(): List { @@ -1166,38 +1141,21 @@ class CashuWalletState( .groupBy { it.content.mint } .filterKeys { mintUrlFilter == null || it == mintUrlFilter } for ((mintUrl, entries) in byMint) { - val allProofs = entries.flatMap { it.content.proofs } - if (allProofs.isEmpty()) continue - val states = - runCatching { ops.checkProofStates(mintUrl, allProofs) } + // Shared NUT-07 check + NIP-09 delete with amy's `cashu maintenance + // scrub`. Returns the stale token events it published a deletion for. + val staleEvents = + runCatching { ops.scrubStaleProofs(mintUrl, entries) } .onFailure { - Log.w("CashuWallet", "checkProofStates($mintUrl) failed; skipping sweep", it) + Log.w("CashuWallet", "scrubStaleProofs($mintUrl) failed; skipping sweep", it) }.getOrNull() ?: continue - - // Any entry with at least one SPENT proof gets purged. Keeping - // mixed-state entries around would let the next send pick them - // and trip the same HTTP 400 we're trying to prevent. - val staleEntries = - entries.filter { entry -> - entry.content.proofs.any { states[it.secret] == ProofState.SPENT } - } - if (staleEntries.isEmpty()) continue + if (staleEvents.isEmpty()) continue Log.i("CashuWallet") { - "Scrubbing ${staleEntries.size} stale kind:7375 event(s) at $mintUrl" + "Scrubbing ${staleEvents.size} stale kind:7375 event(s) at $mintUrl" } - val staleIds = staleEntries.map { it.event.id }.toSet() - runCatching { - val template = DeletionEvent.build(staleEntries.map { it.event }) - val signed = signer.sign(template) - publishEvent(signed) - }.onFailure { - Log.w("CashuWallet", "Failed to NIP-09 delete stale entries for $mintUrl", it) - } - // Drop from internal indexes regardless of publish success — even - // if the kind:5 didn't go out, we know these proofs are unusable - // and shouldn't be selected for the next swap. - removeEvents(staleIds) + // Drop from internal indexes — even if the kind:5 didn't reach a + // relay, these proofs are unusable and must not be re-selected. + removeEvents(staleEvents.map { it.id }.toSet()) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt index 48724428b3..d9d13813de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt @@ -35,7 +35,7 @@ import kotlin.coroutines.cancellation.CancellationException * via NUT-05 melt (`POST /v1/melt/quote/bolt11` + `POST /v1/melt/bolt11`). * * This is the "Redeem" button on a received cashu token preview — distinct - * from the NIP-60 wallet's own send-LN flow ([com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletOps.meltToLightning]), + * from the NIP-60 wallet's own send-LN flow ([com.vitorpamplona.amethyst.commons.cashu.ops.CashuWalletOps.meltToLightning]), * which spends the user's stored proofs and rolls change back into the wallet. * Here there is no wallet: the proofs come straight off the pasted token and * any leftover stays with the mint. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 43c295b649..966455a274 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle +import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel @@ -1009,8 +1010,7 @@ class AccountViewModel( } catch (e: Exception) { onError( stringRes(com.vitorpamplona.amethyst.Amethyst.instance.appContext, R.string.nutzap_failed_title), - com.vitorpamplona.amethyst.model.nip60Cashu - .describeMintError(e), + describeMintError(e), baseNote.author, ) } @@ -1042,8 +1042,7 @@ class AccountViewModel( if (e is CancellationException) throw e onError( stringRes(com.vitorpamplona.amethyst.Amethyst.instance.appContext, R.string.nutzap_failed_title), - com.vitorpamplona.amethyst.model.nip60Cashu - .describeMintError(e), + describeMintError(e), getUserIfExists(recipientPubKey), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt index 15403e6a07..a3fd3f8bfe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt @@ -75,6 +75,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindDisplayName import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.kinds.KindNames import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import kotlinx.coroutines.Dispatchers @@ -427,7 +428,7 @@ private fun supportedKindsLabel(kinds: List): String { val names = kinds.take(VISIBLE_KIND_NAMES).map { kind -> val nameRes = kindDisplayName(kind) - if (nameRes != -1) stringRes(nameRes) else "k$kind" + if (nameRes != -1) stringRes(nameRes) else (KindNames.nameFor(kind) ?: "k$kind") } val overflow = kinds.size - VISIBLE_KIND_NAMES val suffix = if (overflow > 0) " +$overflow" else "" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index f521fa90d1..ea9ed897bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -133,6 +133,7 @@ import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent +import com.vitorpamplona.quartz.kinds.KindNames import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.stats.ErrorDebugMessage @@ -694,7 +695,7 @@ val reports = setOf(ReportEvent.KIND, MuteListEvent.KIND, DeletionEvent.KIND, Re @Composable fun KindChip(kind: Int) { val nameResId = kindDisplayName(kind) - val name = if (nameResId != -1) stringResource(nameResId) else "k$kind" + val name = if (nameResId != -1) stringResource(nameResId) else (KindNames.nameFor(kind) ?: "k$kind") val (bg, fg) = when (kind) { in posts -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt index 79a87fffbf..7a7a063cc1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt @@ -78,9 +78,9 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State +import com.vitorpamplona.amethyst.commons.relayManagement.Nip86Retriever import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.model.nip86RelayManagement.Nip86Retriever import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -102,6 +102,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.SmallBorder import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.nip05 +import com.vitorpamplona.quartz.kinds.KindNames import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @@ -897,7 +898,7 @@ private fun KindEntryCard( verticalAlignment = Alignment.CenterVertically, ) { val nameResId = kindDisplayName(kind) - val name = if (nameResId != -1) stringResource(nameResId) else "" + val name = if (nameResId != -1) stringResource(nameResId) else (KindNames.nameFor(kind) ?: "") Text( "Kind $kind: $name", diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt index cf570b2e77..2b5c79e79b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt @@ -23,10 +23,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip86 import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.relayManagement.Nip86Retriever import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.model.nip86RelayManagement.Nip86Retriever import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt index d04c5bf64f..c799ce232e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt @@ -21,12 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.commons.cashu.ops.CashuWalletOps +import com.vitorpamplona.amethyst.commons.cashu.ops.MintQuoteStarted +import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry +import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletOps import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState -import com.vitorpamplona.amethyst.model.nip60Cashu.MintQuoteStarted -import com.vitorpamplona.amethyst.model.nip60Cashu.TokenEntry -import com.vitorpamplona.amethyst.model.nip60Cashu.describeMintError import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.nip60Cashu.mintApi.MeltQuoteBolt11ResponseDto diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt index 3fcb017ec3..51db1320fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt @@ -23,9 +23,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState -import com.vitorpamplona.amethyst.model.nip60Cashu.describeMintError import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt index 5a73201124..cd6e902cfa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt @@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState -import com.vitorpamplona.amethyst.model.nip60Cashu.describeMintError import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod @@ -47,7 +47,7 @@ import kotlinx.coroutines.launch * * Reuses the same funding primitives as [ReloadMintViewModel] — * [CashuWalletState.rebalance] for a mint-to-mint move and - * [com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletOps.startMintFromLightning] / + * [com.vitorpamplona.amethyst.commons.cashu.ops.CashuWalletOps.startMintFromLightning] / * `completeMintFromLightning` for an LN top-up — but without any of the * zap-specific machinery (recipient, shared-mint intersection, fixed send * amount + shortfall, terminal nutzap, fund-then-send atomicity). The user diff --git a/cli/DEVELOPMENT.md b/cli/DEVELOPMENT.md index b9f37b73b8..6b9eb42376 100644 --- a/cli/DEVELOPMENT.md +++ b/cli/DEVELOPMENT.md @@ -227,6 +227,22 @@ contract. - Errors via `Output.error("code","detail")` — single lower_snake code, free-form detail. +**Command-family schemas:** + +- **Cashu** (`amy cashu …`, NIP-60/61): the full per-verb `--json` key table + and the `cashu.json` NUT-13 counter layout are pinned in + [`plans/2026-05-28-cashu-cli.md`](./plans/2026-05-28-cashu-cli.md). Common + keys: `wallet_event_id`, `mint_url`, `amount_sats` (Long), `proofs_count`, + `token_event_id`, `history_event_id`, `quote_id`, `p2pk_pubkey`. Error codes + include `no_wallet`, `no_mint`, `insufficient_funds`, `mint_unreachable`, + `mint_http_`, `mint_proofs_spent`, `mint_quote_gone`. +- **Admin** (`amy admin …`, NIP-86): `{relay, method, result}` where `result` + is the relay's raw JSON-RPC result (list/boolean/null). Relay-side failures + surface as `error: relay_error`. +- **Serve** (`amy serve`): a single startup object `{listening, host, port, + path, persistent, admin_pubkeys[]}`, then the process blocks (it embeds + geode; teardown is on SIGINT). + --- ## Testing @@ -360,6 +376,7 @@ events. │ ├── identity.json # nsec/npub/hex — the account │ ├── state.json # sync cursors (giftWrapSince, groupSince) │ ├── aliases.json # local name → npub map (init writes a self-entry) +│ ├── cashu.json # NIP-60 NUT-13 counters: {"keyset_counters":{"":}} │ └── marmot/ │ ├── keypackages.bundle # MLS KeyPackage bundles (NostrSignerInternal) │ └── groups/ diff --git a/cli/README.md b/cli/README.md index d5cf211887..548b1b94c7 100644 --- a/cli/README.md +++ b/cli/README.md @@ -197,6 +197,168 @@ $ amy relay publish-lists # broadcast updated kind:10002/10050/10051 ## Commands +### Primitives (stateless — no account or network) + +Army-knife verbs that operate purely on their arguments. They never touch +`~/.amy/`, so they run with zero state — handy for scripting and piping +(`amy decode … | jq`, `… | amy verify`). + +| Command | What it does | +|---|---| +| `amy decode ENTITY` | Decode a NIP-19/21 entity (`npub`/`nsec`/`note`/`nevent`/`nprofile`/`naddr`/`nrelay`/`nembed`) to JSON. Accepts an optional `nostr:` prefix. | +| `amy encode npub HEX` / `nsec HEX` / `note ID` | Encode a single 32-byte hex value into the matching NIP-19 entity. | +| `amy encode nevent ID [--author HEX] [--kind N] [--relay URL[,URL…]]` | Encode an event pointer with optional author/kind/relay hints. | +| `amy encode nprofile HEX [--relay URL[,URL…]]` | Encode a profile pointer with optional relay hints. | +| `amy encode naddr --kind N --pubkey HEX --identifier D [--relay URL[,URL…]]` | Encode an addressable-event (`a` tag) pointer. | +| `amy verify [EVENT-JSON]` | Check an event's id hash and signature. Reads stdin when the argument is omitted or `-`. Reports `id_ok` + `signature_ok` separately. | +| `amy key generate` | Mint a fresh keypair (`nsec` + `npub` + hex). Does not persist — use `init`/`login` for that. | +| `amy key public NSEC\|HEX` | Derive the public key from a secret key. | +| `amy key encrypt NSEC\|HEX --password X` | NIP-49 encrypt a secret key to an `ncryptsec1…`. | +| `amy key decrypt NCRYPTSEC --password X` | NIP-49 decrypt back to nsec/hex/npub. | +| `amy key validate NPUB\|HEX` | Parse-check a public key. Prints `{valid, pubkey, npub}` or `{valid:false}` — never errors, so scripts branch on the field. | +| `amy filter [filter flags]` | Assemble and print a NIP-01 filter JSON from the same flags `fetch`/`subscribe` use — no query is sent. | +| `amy nip N` / `amy nip list` | Look up a NIP — the `nostr-protocol/nips` repo first, then a Nostr wiki/long-form fallback. `list` fetches the index. | +| `amy kind N` / `amy kind NAME` | Look up an event kind's label + defining NIP (number), or search labels by name. Backed by quartz's `KindNames` registry. | +| `amy relay info URL` | Fetch and print a relay's NIP-11 information document. | + +### Remote signing (NIP-46 bunker) + +Two amy processes can talk: one **hosts** a bunker with its local key; the other **logs in** through it and signs remotely (events come out authored by the host's key). + +| Command | What it does | +|---|---| +| `amy bunker [--relay URL[,URL…]] [--secret S] [--timeout SECS]` | Run a NIP-46 remote signer for the active local-key account. Prints a `bunker://…` URI, then services sign / nip04 / nip44 / get_public_key / ping requests until interrupted (or `--timeout`). | +| `amy login bunker://PUBKEY?relay=…&secret=…` | Log in through a bunker (signer advertises). Mints a local transport keypair; the account then acts as PUBKEY and every signing/encryption call is delegated to the remote signer. Percent-encoded relay params are decoded. | +| `amy bunker connect nostrconnect://…` | Client-initiated (NostrConnect) flow, signer side: ack a client's offer (echo its secret) and service its requests. | +| `amy login --nostrconnect [--relay URL[,URL…]] [--name N] [--timeout SECS]` | Client-initiated flow, client side: print a `nostrconnect://` offer, wait for a signer to connect, then persist a bunker account that acts as the signer's key. | + +Interop-tested against the real [`nak`](https://github.com/fiatjaf/nak) binary: +- **bunker:// both directions** — `amy login bunker://` ⇄ `nak bunker`, and `nak event --sec bunker://` ⇄ `amy bunker`. +- **nostrconnect:// client** — `amy login --nostrconnect` ⇄ `nak bunker connect` (amy signs, event authored by nak's key). + +Supports `connect` (secret-checked), `get_public_key`, `get_relays`, `sign_event`, `nip04_encrypt/decrypt`, `nip44_encrypt/decrypt`, `ping`. When a bunker answers with an `auth_url` challenge, amy prints the authorization URL to stderr and keeps waiting for the real response (open the URL in a browser to authorize). + +Example (two terminals, shared `$HOME`): + +```bash +# terminal 1 — host alice's key as a bunker +amy --account alice bunker --relay wss://relay.example --secret s3cret +# → bunker://?relay=wss://relay.example/&secret=s3cret + +# terminal 2 — bob signs through it +amy --account bob login 'bunker://?relay=wss://relay.example/&secret=s3cret' +amy --account bob event --kind 1 --content "signed remotely" # authored by alice +``` + +### Raw events + +| Command | What it does | +|---|---| +| `amy event --kind N [--content TEXT] [--tags JSON] [--created-at TS]` | Build + sign an arbitrary event with the active account. Prints the signed event. `--tags` is a JSON array-of-arrays, e.g. `'[["t","nostr"],["e",""]]'`. | +| `amy event … --publish` / `--relay URL[,URL…]` | As above, then broadcast (to the outbox, or to the given relays). | +| `amy publish [EVENT-JSON] [--relay URL[,URL…]]` | Broadcast a pre-made signed event (verified first). Reads stdin when the argument is omitted or `-`. | + +### Queries + +Filter flags are shared by `fetch` and `subscribe`: `--kind K[,K]`, `--author U[,U]` (npub/nprofile/hex), `--id ID[,ID]` (note/nevent/naddr/hex), `--tag e=ID,p=PK,t=hashtag`, `--since TS`, `--until TS`, `--limit N`, `--search TEXT`, `--relay URL[,URL…]`. Relays default to your outbox, then the bootstrap set. + +| Command | What it does | +|---|---| +| `amy fetch [filter flags] [--timeout SECS]` | One-shot query — collect until every relay sends EOSE (or `--timeout`, default 8s), dedupe, sort newest-first, print and exit. `--limit` defaults to 100. | +| `amy fetch CODE [--timeout SECS]` | Code mode — pass a single `nevent`/`naddr`/`nprofile`/`npub`/`note` or `name@domain`. Resolves relays the outbox way: the hints embedded in the code **plus** the author's NIP-65 write relays (draining their kind:10002 on a cache miss), exactly how the app opens a shared link. | +| `amy subscribe [filter flags] [--timeout SECS]` | Live stream — print each matching event as it arrives (NDJSON under `--json`). Runs until `--timeout` SECS or until interrupted. | +| `amy count [filter flags] [--timeout SECS]` | NIP-45 COUNT — per-relay match counts, no event download. | +| `amy outbox USER [--refresh] [--timeout SECS]` | Show USER's NIP-65 read/write relays (outbox model). Cache-first; `--refresh` forces a relay drain. | +| `amy sync --relay URL [filter flags] [--down] [--up]` | NIP-77 Negentropy reconcile between the local store and a relay. `--down` (default) pulls events we lack; `--up` pushes events the relay lacks; both for bidirectional. | + +### Encryption + +| Command | What it does | +|---|---| +| `amy encrypt --to USER [TEXT] [--nip04]` | NIP-44 (default) or NIP-04 encrypt with the active account's key. Reads stdin when TEXT is omitted or `-`. USER accepts npub/nprofile/hex/NIP-05. | +| `amy decrypt --from USER [CIPHERTEXT] [--nip04]` | Inverse of `encrypt`. | +| `amy gift wrap --to USER [EVENT-JSON] [--relay …]` | NIP-59: seal a signed inner event for USER and wrap it in a kind:1059 gift wrap. Prints the wrap; `--relay` also broadcasts it. | +| `amy gift unwrap [GIFTWRAP-JSON]` | Decrypt + unseal a kind:1059 wrap addressed to the active account; prints the inner event. | + +### Git (NIP-34) + +nak's `clone`/`push`/`pull` (git-packfile transport over relays/GRASP) are out of scope — these are the metadata + collaboration events. + +| Command | What it does | +|---|---| +| `amy git announce --name N [--description D] [--clone URL[,URL]] [--web URL[,URL]] [--relay URL[,URL]] [--maintainer HEX[,HEX]] [--hashtag T[,T]] [--earliest-commit C] [--d ID]` | Publish a kind:30617 repository announcement. | +| `amy git list [USER]` | List a user's repo announcements (defaults to self). | +| `amy git show NADDR\|kind:pubkey:id` | Print one repo announcement (cache-first). | +| `amy git issue NADDR\|coords --subject S [BODY] [--hashtag T[,T]]` | Publish a kind:1621 issue against a repo. BODY from arg or stdin. | + +### Podcasts (NIP-F4) + +| Command | What it does | +|---|---| +| `amy podcast metadata --title T --image URL --description D [--website URL[,URL]]` | Publish kind:10154 show metadata (replaceable). | +| `amy podcast publish --title T --description D --audio URL[,URL] [--audio-type MIME] [--image URL] [--content MD]` | Publish a kind:54 episode. | +| `amy podcast list [USER] [--limit N]` | List a user's show metadata + episodes. | + +### Blossom blobs (NIP-B7) + +| Command | What it does | +|---|---| +| `amy blossom upload --server URL FILE [--mime-type M]` | Upload a file (BUD-01, authed). Prints the blob URL + sha256. | +| `amy blossom download URL [--out FILE]` | Download a blob (public). Accepts a full URL, or a `HASH` plus `--server URL`. | +| `amy blossom list --server URL [USER]` | List a user's blobs (BUD-04). USER defaults to the active account. | +| `amy blossom delete HASH --server URL` | Delete a blob you own (BUD-02). | +| `amy blossom check --server URL HASH[,HASH]` | HEAD-check the server has each blob; exit 1 if any is missing. | +| `amy blossom mirror --server URL SOURCE-URL` | Ask the server to mirror a blob from SOURCE-URL (BUD-04). | + +### Cashu wallet (NIP-60 / NIP-61) + +A NIP-60 ecash wallet + NIP-61 nutzaps, driven by the **same** shared +`commons` `CashuWalletOps` / `CashuWalletReader` the Android wallet runs — so +amy's on-relay events match the app's. NUT-13 counters persist in +`~/.amy//cashu.json`. `mint ping`/`info` are stateless (no account). + +| Command | What it does | +|---|---| +| `amy cashu wallet create [--mint URL] [--mints a,b] [--privkey HEX] [--relay r1,r2]` | Publish a kind:17375 wallet + kind:10019 nutzap info. Advertises your outbox relays for nutzaps unless `--relay` overrides. | +| `amy cashu wallet show` | P2PK pubkey, mints, balance, per-mint balances, proof/history/pending counts. | +| `amy cashu wallet export-key` | Decrypt and print the wallet's P2PK private key. | +| `amy cashu wallet destroy` | Withdraw the nutzap advertisement and NIP-09 delete the wallet (leaves token events — the ecash still lives at the mint). | +| `amy cashu balance [--mint URL]` | Spendable balance from the local store (optionally one mint). | +| `amy cashu mint ping URL` / `info URL` | Stateless `/v1/info` probe (name/pubkey/version) / full DTO. | +| `amy cashu receive ln SATS [--mint URL]` | Request a mint quote; prints the bolt11 + kind:7374 quote. | +| `amy cashu receive complete QUOTE_ID` / `resume QUOTE_ID` | Poll the quote; once the invoice is settled, mint proofs (kind:7375 + kind:7376). | +| `amy cashu receive token TOKEN` | Redeem a `cashuB…` token into the wallet. | +| `amy cashu receive nutzap-sweep [--mint URL]` | Redeem inbound NIP-61 nutzaps locked to your wallet key. | +| `amy cashu send ln INVOICE [--mint URL]` | Melt proofs to pay a bolt11 (scrubs stale proofs first). | +| `amy cashu send token SATS [--mint URL] [--memo S]` | Export a `cashuB…` token of SATS. | +| `amy cashu send nutzap USER SATS [--zapped EVENT_ID] [--message S]` | Send a P2PK-locked nutzap to USER (resolves their kind:10019). | +| `amy cashu maintenance scrub [--mint URL]` | NUT-07 + NIP-09 prune of spent proofs. | +| `amy cashu maintenance restore MINT_URL` | NUT-09 restore unspent proofs from the wallet seed. | +| `amy cashu maintenance migrate-keysets [--mint URL]` | Consolidate proofs onto each mint's active keyset. | +| `amy cashu mint-rec show [--author NPUB]` / `add URL [--dtag X] [--review T]` / `remove EVENT_ID` | NIP-87 mint recommendations (kind:38000). | + +### Relay management — admin (NIP-86) + +Signs a NIP-98 request with the active account and POSTs it to the relay's +HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever` +(the same path Amethyst's relay-management screen runs). + +| Command | What it does | +|---|---| +| `amy admin RELAY supported-methods` | List the NIP-86 methods the relay implements. | +| `amy admin RELAY ban-pubkey HEX [--reason R]` / `unban-pubkey HEX` / `list-banned-pubkeys` | Pubkey ban list. | +| `amy admin RELAY allow-pubkey HEX [--reason R]` / `unallow-pubkey HEX` / `list-allowed-pubkeys` | Pubkey allow list. | +| `amy admin RELAY ban-event ID [--reason R]` / `allow-event ID` / `list-banned-events` / `list-needing-moderation` | Event moderation. | +| `amy admin RELAY allow-kind N` / `disallow-kind N` / `list-allowed-kinds` | Kind allow list. | +| `amy admin RELAY block-ip IP [--reason R]` / `unblock-ip IP` / `list-blocked-ips` | IP block list. | +| `amy admin RELAY change-name S` / `change-description S` / `change-icon URL` | Relay metadata. | + +### Run a relay — serve + +| Command | What it does | +|---|---| +| `amy serve [--host H] [--port N] [--path P] [--db FILE] [--admin NPUBS]` | Run a Nostr relay by embedding **geode** (the standalone Ktor relay on quartz's relay-server code). In-memory by default; `--db FILE` for SQLite. The active account is always an admin, so `amy admin ws://host:port …` works against it. Blocks until interrupted. | + ### Identity | Command | What it does | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 4dd633c015..9be2ba2f3b 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -63,6 +63,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · | Long-form (NIP-23) publish / read | 🆕 | | | Live activities / chess (NIP-53 / NIP-64) | 🆕 | | | Blossom uploads (NIP-B7) | 🆕 | | +| NIP-60 / 61 Cashu wallet + nutzaps | ✅ | Full surface: `cashu wallet {create,show,export-key,destroy}`, `mint {ping,info}`, `balance`, `receive {ln,complete,resume,token,nutzap-sweep}`, `send {ln,token,nutzap}`, `maintenance {scrub,restore,migrate-keysets}`, `mint-rec {show,add,remove}` — all on shared `commons` `CashuWalletOps` + `CashuWalletReader` (the exact path the Android wallet runs). Interop harness pending. Plan: [`cli/plans/2026-05-28-cashu-cli.md`](./plans/2026-05-28-cashu-cli.md). | | NIP-47 Wallet Connect | 🆕 | | | NIP-46 bunker signer | 🆕 | Needs a signers abstraction in Amy. | | Profile view (`amy profile show NPUB`) + edit | ✅ | `ProfileCommands`. Cache-first; `--refresh` forces a relay drain. | @@ -70,6 +71,69 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · | Notifications feed | 🆕 | | | Search (NIP-50) | 🆕 | | +### `nak` parity — army-knife primitives + +Tracking [`fiatjaf/nak`](https://github.com/fiatjaf/nak)'s command surface. amy +adapts the verbiage where its own conventions differ (`req` → one-shot `fetch` +vs streaming `subscribe`). Stateless verbs run with no account or network. + +| nak command | amy verb | Status | Notes | +|---|---|---|---| +| `decode` | `amy decode` | ✅ | NIP-19/21 → JSON. Quartz `Nip19Parser`. | +| `encode` | `amy encode` | ✅ | npub/nsec/note/nevent/nprofile/naddr. | +| `verify` / `validate` | `amy verify` | ✅ | id-hash + signature, reported separately. | +| `key` | `amy key generate\|public\|encrypt\|decrypt\|validate` | ✅ | generate/derive + NIP-49 encrypt/decrypt (bidirectionally nak-verified) + `validate` (npub/hex parse check). `expand`/`combine`(MuSig2)/`default` still 🆕. | +| `event` | `amy event` | ✅ | build/sign an arbitrary event, optional `--publish`/`--relay`. | +| `publish` | `amy publish` | ✅ | broadcast a pre-made event JSON (verified first). | +| `req` (one-shot) | `amy fetch` | ✅ | filter → collect-until-EOSE, dedupe, sort, cap. Also accepts a nip19/nip05 code and resolves relays via the outbox model (code hints + author's NIP-65 write relays), like nak's `fetch`. | +| `req` (stream) | `amy subscribe` | ✅ | filter → live NDJSON stream to stdout. | +| `count` | `amy count` | ✅ | NIP-45, per-relay counts. | +| `encrypt` / `decrypt` | `amy encrypt\|decrypt` | ✅ | raw NIP-44 (default) / NIP-04. | +| `gift` | `amy gift wrap\|unwrap` | ✅ | NIP-59 seal+wrap / unwrap+unseal. | +| `relay` (NIP-11) | `amy relay info` | ✅ | stateless NIP-11 doc fetch. | +| `outbox` | `amy outbox` | ✅ | NIP-65 read/write relays, cache-first. | +| `filter` | `amy filter` | ✅ | stateless — assemble + print a filter JSON. | +| `blossom` | `amy blossom` | ✅ | upload/download/list/delete/check/mirror (reuses commons `BlossomClient`). | +| `nip` | `amy nip` | ✅ | repo-first lookup + Nostr fallback (NipText kind:30817, wiki:30818, long-form:30023); `nip list`. | +| `kind` | `amy kind` | ✅ | quartz `KindNames` registry (kind → English label + NIP) covering **every** event kind quartz defines (280 entries); number lookup + name search. | +| `sync` | `amy sync` | ✅ | NIP-77 Negentropy reconcile with the local store (down/up/both). | +| `git` | `amy git` | ✅ in part | NIP-34 repo announce/list/show/issue. clone/push (packfile transport) out of scope. | +| `podcast` | `amy podcast` | ✅ | NIP-F4 show metadata (10154) + episode publish (54) + list. | +| `bunker` | `amy bunker[ connect]` + `amy login bunker://`/`--nostrconnect` | ✅ | NIP-46 remote signer + login, both the `bunker://` and `nostrconnect://` flows, each direction, plus `auth_url` challenge handling (client surfaces the URL + keeps waiting). Interop-verified vs real `nak`. | +| `admin` | `amy admin RELAY METHOD` | ✅ | NIP-86 Relay Management over NIP-98 HTTP auth — full method set (ban/allow pubkey + event, kinds, IP block, change name/desc/icon, list-*). Reuses quartz `Nip86Client` + shared `commons` `Nip86Retriever`. Interop-verified against `amy serve`. | +| `serve` | `amy serve` | ✅ | Embeds **geode** (the standalone Ktor relay on quartz's relay-server code) — in-memory by default, `--db FILE` for SQLite, account is admin so `amy admin` works against it. NIP-86 + NIP-77 included. | +| `wallet` (NIP-60 Cashu) | `amy cashu` | ✅ | See the Cashu row above — full NIP-60/61 wallet + nutzaps. | +| `mcp` / `fs` / `spell` | — | 🆕 (niche) | MCP server, FUSE mount, MuSig2/FROST; some pull new deps. | + +### Full nak comparison (introspected both binaries) + +nak has 34 functional commands (introspected from `nak --help`). Coverage: + +- **Full / equivalent (24):** `event`, `req`(→`fetch`+`subscribe`), `fetch` + (nip19/nip05-hint resolution), `filter`, `count`, `decode`, `encode`, + `verify`, `relay`, `bunker`(+nostrconnect+auth_url), `encrypt`, `decrypt`, + `gift`, `publish`, `sync`, `profile`, `podcast`, `nip`, `kind`, `blossom`, + `nsite`(NIP-5A), `admin`(NIP-86), `serve`(geode), `wallet`(NIP-60/61 Cashu). + Protocol-sensitive ones (`bunker`, `sync`, `key` NIP-49, `encode`/`decode`, + `admin`) are interop-verified against the real `nak` binary or `amy serve`. +- **Partial / adapted (3):** `key` (no `expand`/`combine`(MuSig2)/`default`), + `git` (NIP-34 events only — no packfile transport), `outbox` (NIP-65 model vs + nak's local hints DB). +- **Missing (7):** `dekey` (NIP-4E), `mcp`, `curl` (NIP-98), `fs` (FUSE), + `spell` (MuSig2/FROST), `validate` (event-schema validation), and + `group`/`nip29` (NIP-29 — amy ships MLS/Marmot instead, an intentional + divergence rather than a gap). + +**Design differences (not gaps):** amy is a *stateful client* (accounts, +`~/.amy/`, shared event store) with a stable JSON contract; nak is a *stateless* +per-invocation tool that prints bare values for shell substitution. amy also has +a large surface nak lacks: Marmot/MLS, NIP-17 DMs, zaps, CLINK offer/debit, +NIP-02 follow, NIP-50 search, napplets, profile edit, store management, account +management. + +**Cheap remaining wins:** the `key` `expand` (hex left-pad) and `default` +(print the active account's key) sub-verbs. `key combine` needs MuSig2. + --- ## Order of operations diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index 5fdf00d2fd..489e604740 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -22,8 +22,12 @@ sourceSets { dependencies { implementation(project(":quartz")) implementation(project(":commons")) + // `amy serve` embeds geode (the standalone Ktor relay built on quartz's + // relay-server code). geode depends only on :quartz, never on :amethyst. + implementation(project(":geode")) implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) implementation(libs.okhttp) implementation(libs.okhttpCoroutines) implementation(libs.jackson.module.kotlin) diff --git a/cli/plans/2026-05-28-cashu-cli.md b/cli/plans/2026-05-28-cashu-cli.md index 9afe7bee8d..12886f8244 100644 --- a/cli/plans/2026-05-28-cashu-cli.md +++ b/cli/plans/2026-05-28-cashu-cli.md @@ -1,8 +1,26 @@ # Cashu (NIP-60 / NIP-61 / NIP-87) in `amy` -**Status:** plan · **Date:** 2026-05-28 · **Roadmap row:** new (no -row today). To be added at the end of the parity matrix in -`cli/ROADMAP.md` once PR 1 lands. +**Status:** in progress · **Date:** 2026-05-28 · **Roadmap row:** added to +the parity matrix in `cli/ROADMAP.md`. + +**Landed so far:** Extraction B (`CashuWalletOps` → `commons/jvmAndroid`), +Extraction C (`CashuWalletReader` → `commons/jvmAndroid`), Extraction D (the +`CashuKeysetCounterStore` contract in `commons` + `FileCashuKeysetCounterStore` +in `cli`), and the offline command tier: `cashu wallet {create, show, +export-key, destroy}`, `cashu mint {ping, info}`, `cashu balance`. Extraction A +(token parsers) turned out to be unnecessary — `V4Encoder`, +`CashuTokenB64Parser`, and friends already live in `quartz`. **Note:** +`CashuWalletOps`/`CashuWalletReader` land in the `jvmAndroid` source set, not +`commonMain` as originally sketched, because they compose quartz's jvmAndroid +`CashuMintOperations`/`MintHttpClient`. The full command surface now ships — +`receive {ln, complete, resume, token, nutzap-sweep}`, `send {ln, token, +nutzap}`, `maintenance {scrub, restore, migrate-keysets}`, `mint-rec {show, add, +remove}` — each a thin wrapper over a shared `CashuWalletOps` method. +`scrubStaleProofs` was extracted into `CashuWalletOps` so Android and amy prune +identically; `Context.cashuRestore` mirrors `CashuWalletState.restoreFromMint` +(seed + NUT-13 counter bump). **Still pending:** the interop harness (PR 9) — +the happy-path mint/melt/send completions need a payable bolt11 to exercise +end-to-end. ## Why diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index f8b10c6c22..dd7c0fa984 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.cli import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.cli.secrets.BunkerFile import com.vitorpamplona.amethyst.cli.secrets.IdentityFile import com.vitorpamplona.amethyst.cli.secrets.IdentitySecret import com.vitorpamplona.amethyst.cli.secrets.SecretStore @@ -48,10 +49,15 @@ data class Identity( val pubKeyHex: String, val nsec: String?, val npub: String, + val bunker: Bunker? = null, ) { @get:com.fasterxml.jackson.annotation.JsonIgnore val hasPrivateKey: Boolean get() = privKeyHex != null + /** Whether this identity can sign — directly (local key) or via a bunker. */ + @get:com.fasterxml.jackson.annotation.JsonIgnore + val canSign: Boolean get() = privKeyHex != null || bunker != null + fun keyPair(): KeyPair = if (privKeyHex != null) { KeyPair(privKey = privKeyHex.hexToByteArray(), pubKey = pubKeyHex.hexToByteArray()) @@ -59,11 +65,65 @@ data class Identity( KeyPair(pubKey = pubKeyHex.hexToByteArray()) } + /** The local transport keypair for a NIP-46 bunker account. */ + fun clientKeyPair(): KeyPair = KeyPair(privKey = bunker!!.clientPrivKeyHex.hexToByteArray()) + companion object { fun create(): Identity = fromPrivateKey(KeyPair().privKey!!) fun fromNsec(nsec: String): Identity = fromPrivateKey(nsec.bechToBytes()) + /** + * Parse a `bunker://?relay=…&secret=…` URI into a + * remote-signer identity. The account acts as `remote-pubkey` (the + * key the bunker signs with); a fresh local keypair is minted for the + * NIP-46 transport. Mirrors the parsing in Quartz's + * `NostrSignerRemote.fromBunkerUri`. + */ + fun fromBunkerUri(uri: String): Identity { + require(uri.startsWith("bunker://")) { "not a bunker:// uri" } + val parts = uri.removePrefix("bunker://").split("?", limit = 2) + val remotePubkey = parts[0].lowercase() + require(remotePubkey.length == 64 && remotePubkey.all { it in "0123456789abcdef" }) { + "bunker uri must carry a 64-hex remote pubkey" + } + val relays = mutableListOf() + var secret: String? = null + parts.getOrNull(1)?.split("&")?.forEach { param -> + val kv = param.split("=", limit = 2) + if (kv.size < 2) return@forEach + // Relay/secret params are percent-encoded by spec-compliant + // emitters (nak does: `relay=wss%3A%2F%2F…`), so decode them. + val value = java.net.URLDecoder.decode(kv[1], "UTF-8") + when (kv[0]) { + "relay" -> relays.add(value) + "secret" -> secret = value + } + } + require(relays.isNotEmpty()) { "bunker uri must carry at least one relay" } + return bunkerIdentity(remotePubkey, relays, secret, KeyPair().privKey!!.toHexKey()) + } + + /** + * Build a remote-signer identity from already-resolved parts (used by + * the NostrConnect flow once the signer pubkey is discovered). The + * account acts as [signerPubkey]; [clientPrivKeyHex] is the local + * transport key. + */ + fun bunkerIdentity( + signerPubkey: String, + relays: List, + connectSecret: String?, + clientPrivKeyHex: String, + ): Identity = + Identity( + privKeyHex = null, + pubKeyHex = signerPubkey, + nsec = null, + npub = signerPubkey.hexToByteArray().toNpub(), + bunker = Bunker(signerPubkey, relays, connectSecret, clientPrivKeyHex), + ) + fun fromPrivateKey(priv: ByteArray): Identity { val pub = KeyPair(privKey = priv).pubKey return Identity( @@ -93,16 +153,31 @@ data class Identity( pubKeyHex: String, npub: String, privKeyHex: String?, + bunker: Bunker? = null, ): Identity = Identity( privKeyHex = privKeyHex, pubKeyHex = pubKeyHex, nsec = privKeyHex?.hexToByteArray()?.toNsec(), npub = npub, + bunker = bunker, ) } } +/** + * In-memory NIP-46 bunker connection. [clientPrivKeyHex] is the local + * transport key (persisted via the [SecretStore], not in the clear); + * [remotePubkey] is the user key the bunker signs as. + */ +data class Bunker( + val remotePubkey: String, + val relays: List, + val connectSecret: String?, + @get:com.fasterxml.jackson.annotation.JsonIgnore + val clientPrivKeyHex: String, +) + /** Opaque per-run state (subscription cursors, etc). Stored alongside identity. */ data class RunState( var giftWrapSince: Long? = null, @@ -133,6 +208,7 @@ class DataDir( val identityFile = File(root, "identity.json") val stateFile = File(root, "state.json") val aliasesFile = File(root, "aliases.json") + val cashuFile = File(root, "cashu.json") val marmotDir = File(root, "marmot") val groupsDir = File(marmotDir, "groups") val keyPackageBundleFile = File(marmotDir, "keypackages.bundle") @@ -165,6 +241,18 @@ class DataDir( */ fun loadIdentityOrNull(): Identity? { val file = loadIdentityFileOrNull() ?: return null + // Bunker (NIP-46) account: `secret` holds the local transport key, and + // `bunker` records the remote signer. The account has no user privkey. + file.bunker?.let { b -> + val clientPriv = file.secret?.let { secrets.resolve(it) } ?: file.privKeyHex + requireNotNull(clientPriv) { "bunker identity is missing its transport key" } + return Identity.fromDisk( + pubKeyHex = file.pubKeyHex, + npub = file.npub, + privKeyHex = null, + bunker = Bunker(b.remotePubkey, b.relays, b.connectSecret, clientPriv), + ) + } val privHex: String? = when { file.secret != null -> secrets.resolve(file.secret) @@ -182,6 +270,21 @@ class DataDir( * to disk. Read-only identities persist `secret: null`. */ fun saveIdentity(id: Identity) { + if (id.bunker != null) { + // Persist the local transport key under its own pubkey; record the + // remote signer connection alongside it. + val clientPub = id.clientKeyPair().pubKey.toHexKey() + val secret = secrets.store(clientPub, id.bunker.clientPrivKeyHex) + val file = + IdentityFile( + pubKeyHex = id.pubKeyHex, + npub = id.npub, + secret = secret, + bunker = BunkerFile(id.bunker.remotePubkey, id.bunker.relays, id.bunker.connectSecret), + ) + SecureFileIO.writeTextAtomic(identityFile, Output.mapper.writeValueAsString(file)) + return + } val secret: IdentitySecret? = id.privKeyHex?.let { secrets.store(id.pubKeyHex, it) } val file = IdentityFile(pubKeyHex = id.pubKeyHex, npub = id.npub, secret = secret) SecureFileIO.writeTextAtomic(identityFile, Output.mapper.writeValueAsString(file)) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index 6ab7d37abb..36cc92a817 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -20,9 +20,13 @@ */ package com.vitorpamplona.amethyst.cli +import com.vitorpamplona.amethyst.cli.stores.FileCashuKeysetCounterStore import com.vitorpamplona.amethyst.cli.stores.FileKeyPackageBundleStore import com.vitorpamplona.amethyst.cli.stores.FileMarmotMessageStore import com.vitorpamplona.amethyst.cli.stores.FileMlsGroupStateStore +import com.vitorpamplona.amethyst.commons.cashu.CashuWalletReader +import com.vitorpamplona.amethyst.commons.cashu.ops.CashuWalletOps +import com.vitorpamplona.amethyst.commons.cashu.ops.RestoreOutcome import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet import com.vitorpamplona.amethyst.commons.marmot.MarmotManager @@ -33,6 +37,7 @@ import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -42,14 +47,26 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent +import com.vitorpamplona.quartz.nip60Cashu.mintApi.DeterministicSecretFactory +import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent +import com.vitorpamplona.quartz.nip60Cashu.seed.CashuDeterministic +import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent +import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent +import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent +import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED @@ -93,8 +110,6 @@ class Context( val identity: Identity, val state: RunState, ) : AutoCloseable { - val signer = NostrSignerInternal(identity.keyPair()) - private val okhttp = OkHttpClient.Builder().build() val client: NostrClient = @@ -102,6 +117,26 @@ class Context( websocketBuilder = BasicOkHttpWebSocket.Builder { okhttp }, ) + /** + * The account's signer. For a local account this is a [NostrSignerInternal] + * over the stored key; for a NIP-46 bunker account it is a + * [NostrSignerRemote] that delegates signing/encryption to the remote + * signer over [client]. The remote signer's subscription + connect + * handshake are driven from [prepare]. + */ + val signer: NostrSigner = + identity.bunker?.let { b -> + NostrSignerRemote( + signer = NostrSignerInternal(identity.clientKeyPair()), + remotePubkey = b.remotePubkey, + relays = b.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet(), + client = client, + secret = b.connectSecret, + // Bunker requires web authorization: surface the URL; the request keeps waiting. + onAuthUrl = { url -> System.err.println("[nip46] authorize this request in a browser, then it will continue:\n $url") }, + ) + } ?: NostrSignerInternal(identity.keyPair()) + /** * NIP-05 resolver for turning `alice@damus.io`-style identifiers into pubkeys. * Uses the same OkHttp instance as the WebSocket client so we share connection @@ -141,6 +176,114 @@ class Context( /** Fully-wired manager. Call [prepare] once before use to load persisted state. */ val marmot: MarmotManager = MarmotManager(signer, mlsStore, messageStore, keyPackageStore) + // ------------------------------------------------------------------ + // Cashu (NIP-60 / NIP-61) — shared wallet code from commons + // ------------------------------------------------------------------ + + /** Durable NUT-13 counter store at `/cashu.json`. */ + private val cashuCounters by lazy { FileCashuKeysetCounterStore(dataDir.cashuFile) } + + @Volatile private var cachedCashuSeed: ByteArray? = null + + /** + * Decrypt the wallet's NUT-13 seed once per run and cache it. The + * [DeterministicSecretFactory] thunk reads this synchronously, so any + * mint/swap op must warm it first (CashuWalletOps' seedWarmer does). + */ + private suspend fun warmCashuSeed() { + if (cachedCashuSeed != null) return + val priv = cashuSnapshot().walletEvent?.let { runCatching { it.privkey(signer) }.getOrNull() } ?: return + cachedCashuSeed = CashuDeterministic.deriveWalletSeed(priv.hexToByteArray()) + } + + /** + * Wallet operations driven by the exact same `commons` [CashuWalletOps] + * the Android app uses. Wired to publish on the account's outbox relays, + * the shared OkHttp instance for mint HTTP, and the file-backed NUT-13 + * counter store. + */ + fun cashuOps(): CashuWalletOps = + CashuWalletOps( + signer = signer, + // Amethyst publishes cashu events via sendLiterallyEverywhere + // (all the user's relays). anyRelays() — outbox + inbox + + // keypackage — is the CLI's closest analog, so the wallet lands + // on the same broad relay set the app would use, not just outbox. + publish = { event -> publish(event, anyRelays()) }, + okHttpClient = { okhttp }, + secretFactory = + DeterministicSecretFactory( + seedProvider = { cachedCashuSeed }, + reserveCounters = { keysetId, count -> cashuCounters.reserve(keysetId, count) }, + ), + seedWarmer = { warmCashuSeed() }, + seedForRestore = { + warmCashuSeed() + cachedCashuSeed + }, + peekCashuCounter = { keysetId -> cashuCounters.peek(keysetId) }, + reserveCashuCounters = { keysetId, count -> cashuCounters.reserve(keysetId, count) }, + ) + + /** + * Project this account's locally-stored NIP-60/61/87 events into a wallet + * snapshot via the shared [CashuWalletReader] — the same decrypt + + * del-rollover + pending-quote logic the Android holder runs. Reads the + * cache only; commands that need fresh state should [drain] first. + */ + suspend fun cashuSnapshot(): CashuWalletReader.WalletSnapshot { + val pk = identity.pubKeyHex + // Mirror commons' CashuWalletFilterAssembler exactly: authored wallet + // kinds by authors=[pk], inbound nutzaps by #p — so amy projects the + // same event set the Android app subscribes to. + val authored = + store.query( + Filter( + authors = listOf(pk), + kinds = + listOf( + CashuWalletEvent.KIND, + CashuTokenEvent.KIND, + CashuSpendingHistoryEvent.KIND, + CashuMintQuoteEvent.KIND, + NutzapInfoEvent.KIND, + MintRecommendationEvent.KIND, + ), + ), + ) + val inboundNutzaps = + store.query( + Filter(kinds = listOf(NutzapEvent.KIND), tags = mapOf("p" to listOf(pk))), + ) + return CashuWalletReader(signer).project(authored + inboundNutzaps) + } + + /** Warm and return the wallet's NUT-13 seed, or null if no wallet. */ + suspend fun cashuSeed(): ByteArray? { + warmCashuSeed() + return cachedCashuSeed + } + + /** + * NUT-09 restore for one mint, mirroring Android's + * `CashuWalletState.restoreFromMint`: re-derive proofs from the seed + * (skipping secrets we already hold), then bump the persisted NUT-13 + * counter past every slot the scan confirmed in use so later mints can't + * reuse one. Returns null when the wallet has no seed yet. + */ + suspend fun cashuRestore(mintUrl: String): RestoreOutcome? { + val seed = cashuSeed() ?: return null + val existingSecrets = + cashuSnapshot() + .tokenEntries + .flatMap { it.content.proofs } + .mapTo(HashSet()) { it.secret } + val outcome = cashuOps().restoreFromMint(mintUrl = mintUrl, seed = seed, startCounter = 0L, existingSecrets = existingSecrets) + val delta = (outcome.nextCounterAfterScan - cashuCounters.peek(outcome.keysetId)).coerceAtLeast(0L) + if (delta > 0) cashuCounters.reserve(outcome.keysetId, delta.toInt()) + return outcome + } + private var prepared = false /** @@ -152,6 +295,12 @@ class Context( if (prepared) return marmot.restoreAll() client.connect() + // A bunker account must open its NIP-46 response subscription and run + // the connect handshake before any signing/encryption call. + (signer as? NostrSignerRemote)?.let { + it.openSubscription() + it.connect() + } prepared = true } @@ -181,6 +330,17 @@ class Context( relaysOf(identity.pubKeyHex)?.writeRelaysNorm()?.takeIf { it.isNotEmpty() }?.toSet() ?: DefaultNIP65RelaySet + /** + * NIP-65 *read* (inbox) relays for this account — "where others reach me". + * Mirrors the Android app's NIP-65 inbox set (the `notificationRelays` + * flow). Used as the default `relay` tags advertised in a kind:10019 + * nutzap-info event. Falls back to [outboxRelays] when no read relays are + * marked. + */ + suspend fun nip65ReadRelays(): Set = + relaysOf(identity.pubKeyHex)?.readRelaysNorm()?.takeIf { it.isNotEmpty() }?.toSet() + ?: outboxRelays() + /** * DM inbox relays (NIP-17 kind:10050) for this account. Falls back * to [DefaultDMRelayList] when no kind:10050 has been seen. @@ -608,6 +768,12 @@ class Context( override fun close() { dataDir.saveRunState(state) + (signer as? NostrSignerRemote)?.let { + try { + it.closeSubscription() + } catch (_: Exception) { + } + } try { client.close() } catch (_: Exception) { diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index e5f8cde7d2..ff0e032bb0 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -20,27 +20,52 @@ */ package com.vitorpamplona.amethyst.cli +import com.vitorpamplona.amethyst.cli.commands.AdminCommand import com.vitorpamplona.amethyst.cli.commands.AwaitCommands +import com.vitorpamplona.amethyst.cli.commands.BlossomCommands +import com.vitorpamplona.amethyst.cli.commands.BunkerCommand +import com.vitorpamplona.amethyst.cli.commands.CountCommand import com.vitorpamplona.amethyst.cli.commands.CreateCommand import com.vitorpamplona.amethyst.cli.commands.DebitCommands +import com.vitorpamplona.amethyst.cli.commands.DecodeCommand +import com.vitorpamplona.amethyst.cli.commands.DecryptCommand import com.vitorpamplona.amethyst.cli.commands.DmCommands +import com.vitorpamplona.amethyst.cli.commands.EncodeCommand +import com.vitorpamplona.amethyst.cli.commands.EncryptCommand +import com.vitorpamplona.amethyst.cli.commands.EventCommand +import com.vitorpamplona.amethyst.cli.commands.FetchCommand +import com.vitorpamplona.amethyst.cli.commands.FilterCommand import com.vitorpamplona.amethyst.cli.commands.FollowCommand +import com.vitorpamplona.amethyst.cli.commands.GiftCommands +import com.vitorpamplona.amethyst.cli.commands.GitCommands import com.vitorpamplona.amethyst.cli.commands.GroupCommands import com.vitorpamplona.amethyst.cli.commands.InitCommands +import com.vitorpamplona.amethyst.cli.commands.KeyCommands import com.vitorpamplona.amethyst.cli.commands.KeyPackageCommands +import com.vitorpamplona.amethyst.cli.commands.KindCommand import com.vitorpamplona.amethyst.cli.commands.LoginCommand import com.vitorpamplona.amethyst.cli.commands.MarmotResetCommand import com.vitorpamplona.amethyst.cli.commands.MessageCommands import com.vitorpamplona.amethyst.cli.commands.NappletCommands +import com.vitorpamplona.amethyst.cli.commands.NipCommand import com.vitorpamplona.amethyst.cli.commands.NotesCommands import com.vitorpamplona.amethyst.cli.commands.NsiteCommands import com.vitorpamplona.amethyst.cli.commands.OfferCommands +import com.vitorpamplona.amethyst.cli.commands.OutboxCommand +import com.vitorpamplona.amethyst.cli.commands.PodcastCommands import com.vitorpamplona.amethyst.cli.commands.ProfileCommands +import com.vitorpamplona.amethyst.cli.commands.PublishCommand import com.vitorpamplona.amethyst.cli.commands.RelayCommands import com.vitorpamplona.amethyst.cli.commands.SearchCommand +import com.vitorpamplona.amethyst.cli.commands.ServeCommand import com.vitorpamplona.amethyst.cli.commands.StoreCommands +import com.vitorpamplona.amethyst.cli.commands.SubscribeCommand +import com.vitorpamplona.amethyst.cli.commands.SyncCommand import com.vitorpamplona.amethyst.cli.commands.UseCommand +import com.vitorpamplona.amethyst.cli.commands.VerifyCommand import com.vitorpamplona.amethyst.cli.commands.ZapCommand +import com.vitorpamplona.amethyst.cli.commands.cashu.CashuCommands +import com.vitorpamplona.amethyst.cli.commands.cashu.CashuMintCommands import com.vitorpamplona.amethyst.cli.commands.route import com.vitorpamplona.amethyst.cli.secrets.SecretStore import kotlinx.coroutines.runBlocking @@ -49,10 +74,10 @@ import kotlin.system.exitProcess /** * amy — non-interactive command-line interface to Amethyst. * - * Today this covers the Marmot/MLS surface (`amy marmot …`) plus identity - * and relay configuration at the root level. The layout is intentionally - * extensible — future verbs (`amy dm`, `amy feed`, `amy profile`) slot in - * as new top-level subcommands. + * Covers Amethyst's account/social/Marmot surface plus a set of + * nak-style army-knife primitives (`decode`, `encode`, `event`, `fetch`, + * `subscribe`, …). The layout is intentionally extensible — new verbs + * slot in as top-level subcommands. * * Usage: amy --data-dir PATH SUBCOMMAND ARGS * @@ -142,6 +167,32 @@ private suspend fun dispatch(argv: Array): Int { return UseCommand.run(tail) } + // Stateless local primitives (nak-style army-knife verbs). They operate + // purely on their arguments — no identity, no relays, no `~/.amy/` — so + // they dispatch before account resolution and work with zero state. + when (head) { + "decode" -> return DecodeCommand.run(tail) + "encode" -> return EncodeCommand.run(tail) + "verify" -> return VerifyCommand.run(tail) + "key" -> return KeyCommands.dispatch(tail) + "filter" -> return FilterCommand.run(tail) + "nip" -> return NipCommand.run(tail) + "kind" -> return KindCommand.run(tail) + } + + // `relay info URL` is a stateless NIP-11 fetch — no account needed. The + // rest of `relay …` (add/list/publish-lists) operates on the account and + // falls through to the normal path below. + if (head == "relay" && tail.firstOrNull() == "info") { + return RelayCommands.info(tail.drop(1).toTypedArray()) + } + + // `cashu mint ping|info URL` is a stateless NIP-60 /v1/info probe — no + // account, no relays. The rest of `cashu …` operates on the account. + if (head == "cashu" && tail.firstOrNull() == "mint") { + return CashuMintCommands.dispatch(tail.drop(1).toTypedArray()) + } + val secrets = SecretStore.from(backendFlag = secretBackendFlag, passphraseFile = passphraseFileFlag) val dataDir = DataDir.resolve(accountFlag = accountFlag, secrets = secrets) @@ -164,6 +215,23 @@ private suspend fun dispatch(argv: Array): Int { "zap" -> ZapCommand.dispatch(dataDir, tail) "offer" -> OfferCommands.dispatch(dataDir, tail) "debit" -> DebitCommands.dispatch(dataDir, tail) + "event" -> EventCommand.run(dataDir, tail) + "publish" -> PublishCommand.run(dataDir, tail) + "fetch" -> FetchCommand.run(dataDir, tail) + "subscribe" -> SubscribeCommand.run(dataDir, tail) + "count" -> CountCommand.run(dataDir, tail) + "encrypt" -> EncryptCommand.run(dataDir, tail) + "decrypt" -> DecryptCommand.run(dataDir, tail) + "gift" -> GiftCommands.dispatch(dataDir, tail) + "outbox" -> OutboxCommand.run(dataDir, tail) + "blossom" -> BlossomCommands.dispatch(dataDir, tail) + "sync" -> SyncCommand.run(dataDir, tail) + "git" -> GitCommands.dispatch(dataDir, tail) + "admin" -> AdminCommand.run(dataDir, tail) + "serve" -> ServeCommand.run(dataDir, tail) + "cashu" -> CashuCommands.dispatch(dataDir, tail) + "podcast" -> PodcastCommands.dispatch(dataDir, tail) + "bunker" -> BunkerCommand.run(dataDir, tail) else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -278,16 +346,50 @@ private fun printUsage() { | then ${'$'}AMY_PASSPHRASE, then a TTY prompt. `plaintext` writes the | private key directly into identity.json (still 0600) — dev only. | + |Primitives (stateless — no account or network needed): + | decode ENTITY decode a NIP-19/21 entity (npub|nsec|note|nevent| + | nprofile|naddr|nrelay|nembed) to JSON + | encode npub HEX encode raw parts into a NIP-19 entity: + | encode nsec HEX nevent/nprofile/naddr accept --relay URL[,URL…]; + | encode note ID nevent accepts --author HEX --kind N; + | encode nevent ID [...] naddr needs --kind N --pubkey HEX --identifier D + | encode nprofile HEX [...] + | encode naddr --kind N --pubkey HEX --identifier D [--relay URL[,URL…]] + | verify [EVENT-JSON] check an event's id hash + signature + | (reads stdin when the arg is omitted or `-`) + | key generate mint a fresh keypair (nsec + npub + hex) + | key public NSEC|HEX derive the public key from a secret key + | key encrypt NSEC|HEX --password X NIP-49 encrypt to ncryptsec1… + | key decrypt NCRYPTSEC --password X NIP-49 decrypt back to a secret key + | filter [--kind …] [--author …] assemble + print a NIP-01 filter JSON from the + | [--id …] [--tag …] … same flags fetch/subscribe use (no query sent) + | nip N show a NIP (repo first, then a Nostr wiki/long-form fallback) + | nip list fetch the NIP index (README) from the repo + | kind N|NAME look up an event kind's label + NIP (number, or search by name) + | |Identity: | init [--nsec NSEC] create or import a bare identity (no defaults published) | create [--name NAME] provision a full Amethyst-style account + publish bootstrap events - | login KEY [--password X] import (nsec|ncryptsec|mnemonic|npub|nprofile|hex|nip05) + | login KEY [--password X] import (nsec|ncryptsec|mnemonic|npub|nprofile|hex|nip05|bunker://) | whoami print current identity | + |Remote signing (NIP-46): + | bunker [--relay URL[,URL…]] run a remote signer for this (local-key) account; prints a + | [--secret S] [--timeout SECS] bunker:// uri and signs requests until interrupt/timeout + | bunker connect NOSTRCONNECT-URI act as signer for a client's nostrconnect:// + | [--timeout SECS] offer (acks + services its requests) + | login bunker://PUBKEY?relay=…&secret=… sign through a remote bunker (mints a local + | transport key; the account acts as PUBKEY) + | login --nostrconnect [--relay URL[,URL…]] client-initiated: print a nostrconnect:// offer, + | [--name N] [--timeout SECS] wait for a signer to connect, then persist it + | |Relays: | relay add URL [--type T] T=nip65|inbox|key_package|all (default all) | relay list print configured relays | relay publish-lists publish kind:10002 + kind:10050 + | relay info URL fetch + print a relay's NIP-11 info document + | outbox USER [--refresh] show USER's NIP-65 read/write relays (outbox model) + | [--timeout SECS] (USER: npub|nprofile|hex|name@domain) | |Profile (NIP-01 kind:0): | profile show [USER] [--timeout SECS] fetch latest kind:0 metadata @@ -311,6 +413,65 @@ private fun printUsage() { | [--since TS] [--until TS] | [--timeout SECS] | + |Raw events (build / sign / broadcast): + | event --kind N [--content TEXT] build + sign an arbitrary event with the active + | [--tags JSON] [--created-at TS] account. Prints the signed event; add --publish + | [--publish] [--relay URL[,URL…]] (or --relay) to broadcast. --tags takes a JSON + | array-of-arrays, e.g. '[["t","nostr"]]'. + | publish [EVENT-JSON] [--relay URL[,URL…]] broadcast a pre-made signed event (verified + | first; reads stdin when the arg is omitted/`-`) + | + |Queries (filter flags shared by fetch/subscribe): + | fetch [--kind K[,K]] [--author U[,U]] one-shot query: collect until EOSE, print, exit. + | [--id ID[,ID]] [--tag e=ID,p=PK,…] --author/--id accept npub/nevent/note/hex. + | [--since TS] [--until TS] [--limit N] default --limit 100, --timeout 8s. + | [--search TEXT] [--relay URL[,URL…]] + | [--timeout SECS] + | subscribe [] live stream: print each event as it arrives + | [--relay URL[,URL…]] [--timeout SECS] (NDJSON). Runs until --timeout or interrupt. + | count [] NIP-45 COUNT: per-relay match counts, no + | [--relay URL[,URL…]] [--timeout SECS] event download. + | sync --relay URL [] NIP-77 Negentropy reconcile with the local + | [--down] [--up] [--timeout SECS] store (--down default; --up to push ours; + | both for bidirectional). + | + |Encryption (active account's key): + | encrypt --to USER [TEXT] [--nip04] NIP-44 (default) or NIP-04 encrypt. Reads + | stdin when TEXT is omitted or `-`. + | decrypt --from USER [CIPHERTEXT] [--nip04] inverse of encrypt. + | gift wrap --to USER [EVENT-JSON] NIP-59: seal + wrap a signed inner event for + | [--relay URL[,URL…]] USER (add --relay to broadcast the wrap). + | gift unwrap [GIFTWRAP-JSON] decrypt + unseal a kind:1059 wrap addressed + | to the active account. + | + |Blossom blobs (NIP-B7 / BUD-01/02/04): + | blossom upload --server URL FILE upload a file (authed); prints the blob URL. + | [--mime-type M] + | blossom download URL [--out FILE] download a blob (public). Accepts a full URL, + | blossom download HASH --server URL or a HASH plus --server. + | blossom list --server URL [USER] list a user's blobs (defaults to self) + | blossom delete HASH --server URL delete a blob you own + | blossom check --server URL HASH[,HASH] HEAD-check blobs exist (fails if any missing) + | blossom mirror --server URL SOURCE-URL ask the server to mirror a blob (BUD-04) + | + |Git (NIP-34): + | git announce --name N [--description D] publish a kind:30617 repo announcement + | [--clone URL[,URL]] [--web URL[,URL]] (--d sets the identifier; defaults to name) + | [--relay URL[,URL]] [--maintainer HEX[,]] + | [--hashtag T[,T]] [--earliest-commit C] [--d ID] + | git list [USER] list a user's repo announcements (default self) + | git show NADDR|kind:pubkey:id print one repo announcement + | git issue NADDR|coords --subject S [BODY] publish a kind:1621 issue against a repo + | [--hashtag T[,T]] [--relay URL[,URL]] (BODY from arg or stdin) + | + |Podcasts (NIP-F4): + | podcast metadata --title T --image URL publish kind:10154 show metadata + | --description D [--website URL[,URL]] + | podcast publish --title T --description D publish a kind:54 episode + | --audio URL[,URL] [--audio-type MIME] + | [--image URL] [--content MARKDOWN] + | podcast list [USER] [--limit N] list a user's metadata + episodes + | |Static websites (NIP-5A kind:15128/35128): | nsite fetch AUTHOR [--d ID] [--path P] resolve one path over Nostr + Blossom and | [--server URL[,URL]] [--relay URL[,URL]] VERIFY it against the manifest's sha256 pin diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AdminCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AdminCommand.kt new file mode 100644 index 0000000000..d377861911 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AdminCommand.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.relayManagement.Nip86Retriever +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client +import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request +import okhttp3.OkHttpClient + +/** + * `amy admin RELAY METHOD [args]` — NIP-86 Relay Management API (nak's + * `relay`/admin). Signs a NIP-98 request with the account key and POSTs it to + * the relay's HTTP endpoint. Reuses quartz's `Nip86Client` (request build + + * NIP-98 auth + response parse) and the shared `commons` `Nip86Retriever` + * (the exact HTTP path Amethyst's relay-management screen runs). + * + * admin wss://relay supported-methods + * admin wss://relay ban-pubkey HEX [--reason R] / unban-pubkey HEX + * admin wss://relay allow-pubkey HEX [--reason R] / list-allowed-pubkeys + * admin wss://relay list-banned-pubkeys + * admin wss://relay ban-event ID [--reason R] / allow-event ID / list-banned-events + * admin wss://relay list-needing-moderation + * admin wss://relay change-name S / change-description S / change-icon URL + * admin wss://relay allow-kind N / disallow-kind N / list-allowed-kinds + * admin wss://relay block-ip IP [--reason R] / unblock-ip IP / list-blocked-ips + */ +object AdminCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val relayArg = args.positionalOrNull(0) ?: return Output.error("bad_args", "usage: admin RELAY METHOD [args]") + val method = args.positionalOrNull(1) ?: return Output.error("bad_args", "missing method; e.g. supported-methods") + val relay = RelayUrlNormalizer.normalizeOrNull(relayArg) ?: return Output.error("bad_args", "invalid relay url: $relayArg") + val p2 = args.positionalOrNull(2) + val reason = args.flag("reason") + + fun needArg(name: String): String? = + p2 ?: run { + Output.error("bad_args", "$method requires a $name argument") + null + } + + val request: Nip86Request = + when (method) { + "supported-methods" -> Nip86Request.supportedMethods() + "ban-pubkey" -> Nip86Request.banPubkey(needArg("pubkey") ?: return 2, reason) + "unban-pubkey" -> Nip86Request.unbanPubkey(needArg("pubkey") ?: return 2, reason) + "list-banned-pubkeys" -> Nip86Request.listBannedPubkeys() + "allow-pubkey" -> Nip86Request.allowPubkey(needArg("pubkey") ?: return 2, reason) + "unallow-pubkey" -> Nip86Request.unallowPubkey(needArg("pubkey") ?: return 2, reason) + "list-allowed-pubkeys" -> Nip86Request.listAllowedPubkeys() + "ban-event" -> Nip86Request.banEvent(needArg("event-id") ?: return 2, reason) + "allow-event" -> Nip86Request.allowEvent(needArg("event-id") ?: return 2, reason) + "list-banned-events" -> Nip86Request.listBannedEvents() + "list-needing-moderation" -> Nip86Request.listEventsNeedingModeration() + "change-name" -> Nip86Request.changeRelayName(needArg("name") ?: return 2) + "change-description" -> Nip86Request.changeRelayDescription(needArg("description") ?: return 2) + "change-icon" -> Nip86Request.changeRelayIcon(needArg("icon-url") ?: return 2) + "allow-kind" -> Nip86Request.allowKind((needArg("kind") ?: return 2).toIntOrNull() ?: return Output.error("bad_args", "kind must be an integer")) + "disallow-kind" -> Nip86Request.disallowKind((needArg("kind") ?: return 2).toIntOrNull() ?: return Output.error("bad_args", "kind must be an integer")) + "list-allowed-kinds" -> Nip86Request.listAllowedKinds() + "block-ip" -> Nip86Request.blockIp(needArg("ip") ?: return 2, reason) + "unblock-ip" -> Nip86Request.unblockIp(needArg("ip") ?: return 2) + "list-blocked-ips" -> Nip86Request.listBlockedIps() + else -> return Output.error("bad_args", "unknown method: $method") + } + + Context.open(dataDir).use { ctx -> + val client = Nip86Client(relay, ctx.signer) + val http = OkHttpClient.Builder().build() + val retriever = Nip86Retriever { _ -> http } + val response = retriever.execute(client, request) + if (response.error != null) return Output.error("relay_error", response.error) + // Reparse the kotlinx JSON result through Jackson so it renders as + // structured JSON (text + --json) instead of a toString blob. + val resultNode = response.result?.toString()?.let { Output.mapper.readTree(it) } + Output.emit(mapOf("relay" to relay.url, "method" to method, "result" to resultNode)) + } + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt new file mode 100644 index 0000000000..f7ed5a8288 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.service.upload.BlossomAuth +import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl +import com.vitorpamplona.quartz.utils.sha256.sha256 +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.File +import java.nio.file.Files + +/** + * `amy blossom ` — Blossom blob storage + * (nak's `blossom`). Uploads/lists/deletes are authed with the active + * account (BUD-01/02/04 kind:24242 events); downloads are public. + * + * upload --server URL FILE [--mime-type M] + * download URL [--out FILE] (or: download HASH --server URL) + * list --server URL [USER] (USER defaults to the active account) + * delete HASH --server URL + * + * Thin assembly only: HTTP + auth live in commons `BlossomClient` / + * `BlossomAuth` and quartz `BlossomAuthorizationEvent`; this file wires + * flags and shapes output. List/delete use OkHttp directly (no client + * method exists) with the quartz-built auth header. + */ +object BlossomCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "blossom", + tail, + "blossom ", + mapOf( + "upload" to { rest -> upload(dataDir, rest) }, + "download" to { rest -> download(dataDir, rest) }, + "list" to { rest -> list(dataDir, rest) }, + "delete" to { rest -> delete(dataDir, rest) }, + "check" to { rest -> check(dataDir, rest) }, + "mirror" to { rest -> mirror(dataDir, rest) }, + ), + ) + + /** `blossom check --server URL HASH[,HASH]` — HEAD each blob; fail if any is missing (BUD-01). */ + private suspend fun check( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val server = args.flag("server") ?: return Output.error("bad_args", "blossom check requires --server URL") + val hashes = + args + .positional(0, "sha256") + .split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + + Context.open(dataDir).use { _ -> + val http = OkHttpClient() + val results = + hashes.map { hash -> + val req = + Request + .Builder() + .url(BlossomServerUrl.blob(server, hash)) + .head() + .build() + val (found, status) = + try { + http.newCall(req).execute().use { it.isSuccessful to it.code } + } catch (e: Exception) { + false to -1 + } + mapOf("sha256" to hash, "found" to found, "status" to status) + } + val allFound = results.all { it["found"] == true } + Output.emit(mapOf("server" to server, "all_found" to allFound, "results" to results)) + return if (allFound) 0 else 1 + } + } + + /** + * `blossom mirror --server URL SOURCE-URL` — ask the server to mirror the + * blob at SOURCE-URL (BUD-04). The sha256 (last path segment of the source + * URL) is signed into the upload auth. + */ + private suspend fun mirror( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val server = args.flag("server") ?: return Output.error("bad_args", "blossom mirror requires --server URL") + val sourceUrl = args.positional(0, "source-url") + val hash = sourceUrl.substringAfterLast('/').substringBefore('.') + if (hash.length != 64 || hash.any { it !in "0123456789abcdef" }) { + return Output.error("bad_args", "could not extract a sha256 from the source url '$sourceUrl'") + } + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val auth = BlossomAuthorizationEvent.createUploadAuth(hash, 0, "Mirror $hash", ctx.signer).toAuthorizationHeader() + val body = """{"url":${Output.mapper.writeValueAsString(sourceUrl)}}""".toRequestBody("application/json".toMediaType()) + val req = + Request + .Builder() + .url(server.removeSuffix("/") + "/mirror") + .header("Authorization", auth) + .put(body) + .build() + OkHttpClient().newCall(req).execute().use { response -> + if (!response.isSuccessful) { + return Output.error("http_error", "mirror failed: HTTP ${response.code} ${response.headers[BlossomServerUrl.REASON_HEADER] ?: ""}") + } + val node = Output.mapper.readTree(response.body.string()) + Output.emit(mapOf("server" to server, "sha256" to hash, "blob" to node)) + } + return 0 + } + } + + private suspend fun upload( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val server = args.flag("server") ?: return Output.error("bad_args", "blossom upload requires --server URL") + val path = args.positional(0, "file") + val file = File(path) + if (!file.isFile) return Output.error("bad_args", "no such file: $path") + + val bytes = file.readBytes() + val hash = sha256(bytes).toHexKey() + val mime = args.flag("mime-type") ?: runCatching { Files.probeContentType(file.toPath()) }.getOrNull() ?: "application/octet-stream" + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val auth = BlossomAuth.createUploadAuth(hash, file.length(), "Upload ${file.name}", ctx.signer) + val result = BlossomClient().upload(file, mime, server, auth) + Output.emit( + mapOf( + "url" to result.url, + "sha256" to (result.sha256 ?: hash), + "size" to (result.size ?: file.length()), + "type" to (result.type ?: mime), + "server" to server, + ), + ) + return 0 + } + } + + private suspend fun download( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val target = args.positional(0, "url-or-hash") + val server = args.flag("server") + val url = if (server != null && !target.startsWith("http")) BlossomServerUrl.blob(server, target) else target + + Context.open(dataDir).use { ctx -> + val bytes = + BlossomClient().download(url) + ?: return Output.error("not_found", "server returned no blob for $url") + val out = args.flag("out") + if (out != null) { + File(out).writeBytes(bytes) + } + Output.emit( + mapOf( + "url" to url, + "size" to bytes.size, + "sha256" to sha256(bytes).toHexKey(), + "saved_to" to out, + ), + ) + return 0 + } + } + + private suspend fun list( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val server = args.flag("server") ?: return Output.error("bad_args", "blossom list requires --server URL") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val pubkey = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex + val auth = BlossomAuthorizationEvent.createListAuth(ctx.signer, "List blobs").toAuthorizationHeader() + val listUrl = server.removeSuffix("/") + "/list/" + pubkey + + val request = + Request + .Builder() + .url(listUrl) + .header("Authorization", auth) + .get() + .build() + OkHttpClient().newCall(request).execute().use { response -> + if (!response.isSuccessful) return Output.error("http_error", "server returned HTTP ${response.code} for $listUrl") + val node = Output.mapper.readTree(response.body.string()) + Output.emit(mapOf("server" to server, "pubkey" to pubkey, "count" to node.size(), "blobs" to node)) + } + return 0 + } + } + + private suspend fun delete( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val server = args.flag("server") ?: return Output.error("bad_args", "blossom delete requires --server URL") + val hash = args.positional(0, "sha256") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val auth = BlossomAuthorizationEvent.createDeleteAuth(hash, "Delete blob", ctx.signer).toAuthorizationHeader() + val blobUrl = BlossomServerUrl.blob(server, hash) + val request = + Request + .Builder() + .url(blobUrl) + .header("Authorization", auth) + .delete() + .build() + OkHttpClient().newCall(request).execute().use { response -> + Output.emit( + mapOf( + "sha256" to hash, + "server" to server, + "deleted" to response.isSuccessful, + "status" to response.code, + ), + ) + return if (response.isSuccessful) 0 else 1 + } + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt new file mode 100644 index 0000000000..12f68685d7 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseDecrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEncrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseGetRelays +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.ReadWrite +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.withTimeoutOrNull + +/** + * `amy bunker [--relay URL[,URL…]] [--secret S] [--timeout SECS]` + * + * Run a NIP-46 remote signer (a "bunker") for the active LOCAL account + * (nak's `bunker`). Prints a `bunker://…` connection string, then listens on + * the relays for kind:24133 requests, decrypts each one, performs it with the + * account's key (sign / nip04 / nip44 / get_public_key / ping), and publishes + * the encrypted reply. + * + * Pair it with `amy login bunker://…` in another amy: that account then signs + * remotely through this bunker. Long-running — stops at `--timeout` SECS or on + * interrupt. + * + * Thin assembly only: every request/response type + the encrypted wrapper + * live in quartz (`BunkerRequest*`, `BunkerResponse*`, `NostrConnectEvent`); + * this file dispatches to `ctx.signer`. + */ +object BunkerCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int = + if (rest.firstOrNull() == "connect") { + connect(dataDir, rest.drop(1).toTypedArray()) + } else { + advertise(dataDir, rest) + } + + /** `amy bunker …` — advertise a bunker:// uri and service requests. */ + private suspend fun advertise( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val timeoutMs = args.flag("timeout")?.toLongOrNull()?.let { it * 1000 } + val accountError = checkHostable(dataDir) + if (accountError != null) return accountError + + Context.open(dataDir).use { ctx -> + if (!ctx.identity.hasPrivateKey) { + return Output.error("read_only", "bunker host needs a local private key (this account is read-only)") + } + ctx.prepare() + + val relays = RawEventSupport.relayFlag(args).ifEmpty { ctx.outboxRelays() } + if (relays.isEmpty()) return Output.error("no_relays", "no relays available; pass --relay or run `amy relay add`") + val secret = args.flag("secret") ?: KeyPair().privKey!!.toHexKey().take(32) + val self = ctx.identity.pubKeyHex + + // Percent-encode params (spec/nak convention: relay=wss%3A%2F%2F…). + val enc = { s: String -> java.net.URLEncoder.encode(s, "UTF-8") } + val uri = + buildString { + append("bunker://").append(self) + append("?").append(relays.joinToString("&") { "relay=${enc(it.url)}" }) + append("&secret=").append(enc(secret)) + } + Output.emit( + mapOf( + "bunker_uri" to uri, + "pubkey" to self, + "relays" to relays.map { it.url }, + "secret" to secret, + ), + ) + System.err.println("[bunker] listening as ${self.take(8)}… on ${relays.size} relay(s); paste the bunker:// uri into `amy login`") + + serve(ctx, relays, secret, timeoutMs) + return 0 + } + } + + /** + * `amy bunker connect ` — the client-initiated + * (NostrConnect) flow: parse a client's offer, send the connect ACK that + * echoes the offer's secret back (so the client learns our signer pubkey), + * then service that client's requests on the offer's relays. + */ + private suspend fun connect( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val timeoutMs = args.flag("timeout")?.toLongOrNull()?.let { it * 1000 } + val uri = args.positional(0, "nostrconnect-uri") + val offer = NostrConnect.parseOffer(uri) ?: return Output.error("bad_args", "not a valid nostrconnect:// uri") + val accountError = checkHostable(dataDir) + if (accountError != null) return accountError + + Context.open(dataDir).use { ctx -> + if (!ctx.identity.hasPrivateKey) { + return Output.error("read_only", "bunker host needs a local private key (this account is read-only)") + } + ctx.prepare() + if (offer.relays.isEmpty()) return Output.error("bad_args", "nostrconnect uri carries no relay") + + // Send the connect ACK (result == secret) to the client. + val ack = BunkerResponse(newSubId(), offer.secret, null) + val reply = NostrConnectEvent.create(ack, offer.clientPubkey, ctx.signer) + ctx.client.publish(reply, offer.relays) + Output.emit( + mapOf( + "connected_to" to offer.clientPubkey, + "pubkey" to ctx.identity.pubKeyHex, + "relays" to offer.relays.map { it.url }, + ), + ) + System.err.println("[bunker] acked nostrconnect from ${offer.clientPubkey.take(8)}…; now servicing requests") + + serve(ctx, offer.relays, offer.secret, timeoutMs) + return 0 + } + } + + /** A remote-signer (bunker) account cannot itself host a bunker. */ + private fun checkHostable(dataDir: DataDir): Int? = + if (dataDir.loadIdentityFileOrNull()?.bunker != null) { + Output.error("bad_account", "this account signs through a remote bunker — host a bunker from a local-key account") + } else { + null + } + + /** Subscribe for kind:24133 requests addressed to us and service them until timeout/interrupt. */ + private suspend fun serve( + ctx: Context, + relays: Set, + secret: String, + timeoutMs: Long?, + ) { + val self = ctx.identity.pubKeyHex + val events = Channel(UNLIMITED) + val seen = mutableSetOf() + val subId = newSubId() + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is NostrConnectEvent && seen.add(event.id)) events.trySend(event) + } + } + + val filter = Filter(kinds = listOf(NostrConnectEvent.KIND), tags = mapOf("p" to listOf(self))) + ctx.client.subscribe(subId, relays.associateWith { listOf(filter) }, listener) + try { + val loop: suspend () -> Unit = { + while (true) handle(ctx, events.receive(), secret, relays) + } + if (timeoutMs != null) withTimeoutOrNull(timeoutMs) { loop() } else loop() + } finally { + ctx.client.unsubscribe(subId) + events.close() + } + } + + private suspend fun handle( + ctx: Context, + event: NostrConnectEvent, + secret: String, + relays: Set, + ) { + val signer = ctx.signer + val client = event.talkingWith(signer.pubKey) + val request = + try { + event.decryptMessage(signer) as? BunkerRequest ?: return + } catch (e: Exception) { + System.err.println("[bunker] could not decrypt request ${event.id.take(8)}: ${e.message}") + return + } + + val response: BunkerResponse = + try { + when (request) { + is BunkerRequestConnect -> + if (request.secret == secret) { + BunkerResponseAck(request.id) + } else { + BunkerResponseError(request.id, "invalid secret") + } + is BunkerRequestGetPublicKey -> BunkerResponsePublicKey(request.id, signer.pubKey) + is BunkerRequestGetRelays -> BunkerResponseGetRelays(request.id, relays.associate { it.url to ReadWrite(read = true, write = true) }) + is BunkerRequestPing -> BunkerResponsePong(request.id) + is BunkerRequestSign -> { + val signed = signer.sign(request.event.createdAt, request.event.kind, request.event.tags, request.event.content) + BunkerResponseEvent(request.id, signed) + } + is BunkerRequestNip04Encrypt -> BunkerResponseEncrypt(request.id, signer.nip04Encrypt(request.message, request.pubKey)) + is BunkerRequestNip04Decrypt -> BunkerResponseDecrypt(request.id, signer.nip04Decrypt(request.ciphertext, request.pubKey)) + is BunkerRequestNip44Encrypt -> BunkerResponseEncrypt(request.id, signer.nip44Encrypt(request.message, request.pubKey)) + is BunkerRequestNip44Decrypt -> BunkerResponseDecrypt(request.id, signer.nip44Decrypt(request.ciphertext, request.pubKey)) + else -> BunkerResponseError(request.id, "unsupported method: ${request.method}") + } + } catch (e: Exception) { + BunkerResponseError(request.id, "${e::class.simpleName}: ${e.message}") + } + + System.err.println("[bunker] ${request.method} from ${client.take(8)}… → ${if (response is BunkerResponseError) "error: ${response.error}" else "ok"}") + + try { + val reply = NostrConnectEvent.create(response, client, signer) + ctx.client.publish(reply, relays) + } catch (e: Exception) { + System.err.println("[bunker] failed to send reply for ${request.method}: ${e.message}") + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CountCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CountCommand.kt new file mode 100644 index 0000000000..3d2c4e004e --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CountCommand.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count + +/** + * `amy count [] [--relay URL[,URL…]] [--timeout SECS]` + * + * NIP-45 COUNT: ask each relay how many events match the filter, without + * downloading them (nak's `count`). Reports each relay's reply plus a + * per-relay max as `total` (counts can't be deduplicated across relays, so + * summing would over-count overlapping stores — the max is the safer single + * number). + * + * Thin assembly only: the COUNT round-trip lives in quartz + * (`INostrClient.count`); this file builds the filter and shapes output. + */ +object CountCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 15L) * 1000 + val filter = RawEventSupport.buildFilter(args) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val relays = RawEventSupport.queryTargets(ctx, args) + if (relays.isEmpty()) return Output.error("no_relays", "no relays available; pass --relay or run `amy relay add`") + + val results = ctx.client.count(relays.associateWith { listOf(filter) }, timeoutMs) + + Output.emit( + mapOf( + "queried_relays" to relays.map { it.url }, + "responded" to results.size, + "total" to (results.values.maxOfOrNull { it.count } ?: 0), + "per_relay" to + results.map { (relay, res) -> + mapOf( + "relay" to relay.url, + "count" to res.count, + "approximate" to res.approximate, + ) + }, + ), + ) + return 0 + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CryptoCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CryptoCommands.kt new file mode 100644 index 0000000000..2e44e64a44 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CryptoCommands.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output + +/** + * `amy encrypt --to USER [TEXT] [--nip04]` and + * `amy decrypt --from USER [CIPHERTEXT] [--nip04]` — raw NIP-44 (default) or + * NIP-04 message encryption with the active account's key (nak's + * `encrypt`/`decrypt`). + * + * The plaintext/ciphertext is taken from the positional argument or stdin + * (when omitted or `-`). USER accepts npub/nprofile/hex/NIP-05. + * + * Thin assembly only: the crypto lives in quartz (`NostrSigner.nip44*` / + * `nip04*`); this file resolves the peer and shuttles strings. + */ +object EncryptCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val to = args.flag("to") ?: return Output.error("bad_args", "encrypt requires --to USER") + val nip04 = args.bool("nip04") + val text = RawEventSupport.readArgOrStdin(args) + if (text.isEmpty()) return Output.error("bad_args", "no plaintext on the argument or stdin") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val peer = ctx.requireUserHex(to) + val ciphertext = + if (nip04) ctx.signer.nip04Encrypt(text, peer) else ctx.signer.nip44Encrypt(text, peer) + Output.emit(mapOf("algorithm" to if (nip04) "nip04" else "nip44", "ciphertext" to ciphertext)) + return 0 + } + } +} + +object DecryptCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val from = args.flag("from") ?: return Output.error("bad_args", "decrypt requires --from USER") + val nip04 = args.bool("nip04") + val ciphertext = RawEventSupport.readArgOrStdin(args) + if (ciphertext.isEmpty()) return Output.error("bad_args", "no ciphertext on the argument or stdin") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val peer = ctx.requireUserHex(from) + val plaintext = + if (nip04) ctx.signer.nip04Decrypt(ciphertext, peer) else ctx.signer.nip44Decrypt(ciphertext, peer) + Output.emit(mapOf("algorithm" to if (nip04) "nip04" else "nip44", "plaintext" to plaintext)) + return 0 + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DecodeCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DecodeCommand.kt new file mode 100644 index 0000000000..b6a1585d1e --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DecodeCommand.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed +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 +import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay +import com.vitorpamplona.quartz.nip19Bech32.entities.NSec + +/** + * `amy decode ` — turn a NIP-19 / NIP-21 entity into its structured + * parts (nak's `decode`). Local, no network, no account. Accepts an optional + * `nostr:` prefix. + * + * Thin assembly only: all parsing lives in quartz's [Nip19Parser]; this file + * just maps the parsed entity onto amy's result-map output contract. + */ +object DecodeCommand { + fun run(rest: Array): Int { + val args = Args(rest) + val input = args.positional(0, "entity").trim() + + val entity = + Nip19Parser.uriToRoute(input)?.entity + ?: return Output.error("bad_args", "not a recognized NIP-19 entity (npub, nsec, note, nevent, nprofile, naddr, nrelay, nembed)") + + val result: Map = + when (entity) { + is NPub -> mapOf("type" to "npub", "pubkey" to entity.hex) + is NSec -> mapOf("type" to "nsec", "private_key" to entity.hex, "pubkey" to entity.toPubKeyHex()) + is NNote -> mapOf("type" to "note", "id" to entity.hex) + is NProfile -> + mapOf( + "type" to "nprofile", + "pubkey" to entity.hex, + "relays" to entity.relay.map { it.url }, + ) + is NEvent -> + mapOf( + "type" to "nevent", + "id" to entity.hex, + "author" to entity.author, + "kind" to entity.kind, + "relays" to entity.relay.map { it.url }, + ) + is NAddress -> + mapOf( + "type" to "naddr", + "kind" to entity.kind, + "pubkey" to entity.author, + "identifier" to entity.dTag, + "relays" to entity.relay.map { it.url }, + ) + is NRelay -> mapOf("type" to "nrelay", "relays" to entity.relay) + is NEmbed -> mapOf("type" to "nembed", "event" to Output.mapper.readTree(entity.event.toJson())) + else -> return Output.error("bad_args", "unsupported entity type") + } + + Output.emit(result) + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EncodeCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EncodeCommand.kt new file mode 100644 index 0000000000..fc5322586d --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EncodeCommand.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +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 +import com.vitorpamplona.quartz.nip19Bech32.toNsec + +/** + * `amy encode …` — build a NIP-19 entity from raw parts (nak's + * `encode`). Local, no network, no account. + * + * encode npub + * encode nsec + * encode note + * encode nevent [--author HEX] [--kind N] [--relay URL[,URL…]] + * encode nprofile [--relay URL[,URL…]] + * encode naddr --kind N --pubkey HEX --identifier D [--relay URL[,URL…]] + * + * Thin assembly only: every encoder lives in quartz's NIP-19 entities; this + * file parses flags and calls them. + */ +object EncodeCommand { + fun run(rest: Array): Int { + if (rest.isEmpty()) return Output.error("bad_args", "encode …") + val type = rest[0] + val args = Args(rest.drop(1).toTypedArray()) + + return when (type) { + "npub" -> emit("npub", NPub.create(hex32(args.positional(0, "pubkey-hex")))) + "nsec" -> emit("nsec", hex32(args.positional(0, "private-key-hex")).hexToByteArray().toNsec()) + "note" -> emit("note", NNote.create(hex32(args.positional(0, "event-id-hex")))) + "nevent" -> + emit( + "nevent", + NEvent.create( + idHex = hex32(args.positional(0, "event-id-hex")), + author = args.flag("author"), + kind = args.flag("kind")?.toIntOrNull(), + relays = relays(args), + ), + ) + "nprofile" -> + emit( + "nprofile", + NProfile.create( + authorPubKeyHex = hex32(args.positional(0, "pubkey-hex")), + relays = relays(args), + ), + ) + "naddr" -> + emit( + "naddr", + NAddress.create( + kind = args.requireFlag("kind").toIntOrNull() ?: return Output.error("bad_args", "--kind must be an integer"), + pubKeyHex = hex32(args.requireFlag("pubkey")), + dTag = args.flag("identifier", "") ?: "", + relays = relays(args), + ), + ) + else -> Output.error("bad_args", "encode $type (expected npub|nsec|note|nevent|nprofile|naddr)") + } + } + + /** Validate a 64-char hex key/id; bare-hex only — bech32 inputs go through `decode` first. */ + private fun hex32(value: String): String { + val v = value.trim().lowercase() + require(v.length == 64 && v.all { it in "0123456789abcdef" }) { + "expected 64-char hex, got '$value'" + } + return v + } + + private fun relays(args: Args): List = + args + .flag("relay") + ?.split(',') + ?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) } + .orEmpty() + + private fun emit( + key: String, + value: String, + ): Int { + Output.emit(mapOf(key to value)) + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EventCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EventCommand.kt new file mode 100644 index 0000000000..d5a7bd5fdf --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/EventCommand.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * `amy event --kind N [--content TEXT] [--tags JSON] [--created-at TS] + * [--publish] [--relay URL[,URL…]]` — build and sign an arbitrary + * Nostr event (nak's `event`). + * + * Signs with the active account. By default it only prints the signed event + * (no relay traffic). Pass `--publish` to broadcast to the account's outbox, + * or `--relay` to broadcast to a specific set (implies publish). + * + * `--tags` takes a JSON array-of-arrays, e.g. + * --tags '[["p",""],["t","nostr"],["e","","","root"]]' + * + * Thin assembly only: event construction + signing live in quartz + * (`EventTemplate` / `NostrSigner.sign`); this file parses flags. + */ +object EventCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val kind = + args.flag("kind")?.toIntOrNull() + ?: return Output.error("bad_args", "event requires --kind N") + val content = args.flag("content", "") ?: "" + val createdAt = args.flag("created-at")?.toLongOrNull() ?: TimeUtils.now() + + val tags: Array> = + try { + args + .flag("tags") + ?.let { Output.mapper.readValue>>(it) } + ?.map { it.toTypedArray() } + ?.toTypedArray() + ?: emptyArray() + } catch (e: Exception) { + return Output.error("bad_args", "--tags must be a JSON array of string arrays: ${e.message}") + } + + // Publish when explicitly asked (--publish) or when a relay set is given. + val wantPublish = args.bool("publish") || args.flag("relay") != null + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val signed: Event = ctx.signer.sign(createdAt, kind, tags, content) + val eventNode = Output.mapper.readTree(signed.toJson()) + + if (!wantPublish) { + Output.emit(mapOf("event" to eventNode, "published" to false)) + return 0 + } + + val targets = RawEventSupport.publishTargets(ctx, args) + if (targets.isEmpty()) { + return Output.error("no_relays", "no outbox relays configured; pass --relay or run `amy relay add`") + } + val ack = ctx.publish(signed, targets) + Output.emit( + mapOf( + "event" to eventNode, + "published" to true, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + "rejected_by" to ack.filterValues { !it }.keys.map { it.url }, + ), + ) + return 0 + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt new file mode 100644 index 0000000000..2adffc664c --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +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 +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent + +/** + * `amy fetch [--kind …] [--author …] [--id …] [--tag …] [--since/--until TS] + * [--limit N] [--search TEXT] [--relay URL[,URL…]] [--timeout SECS]` + * + * amy fetch + * + * One-shot query: open a subscription, collect everything until every relay + * sends EOSE (or the timeout fires), then print and exit. This is the bounded + * half of nak's `req`; the live-streaming half is `amy subscribe`. + * + * Two modes: + * - **filter mode** (flags) — assemble a filter and query `--relay`/outbox. + * - **code mode** (a nip19/nip05 positional) — Amethyst's outbox-model + * resolution: query the relay hints embedded in the code PLUS the author's + * NIP-65 write relays, exactly how the app downloads an event/profile from + * a shared link. This is nak's `fetch` (nip19-hint resolution). + * + * Results are deduplicated by id, sorted newest-first, capped at `--limit` + * (default 100), and emitted as full event JSON under an `events` array. + */ +object FetchCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val limit = args.flag("limit")?.toIntOrNull() ?: 100 + if (limit <= 0) return Output.error("bad_args", "--limit must be > 0") + val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 8L) * 1000 + + // Code mode: a nip19/nip05 positional resolves its own relays via the + // outbox model rather than using a hand-built filter. + args.positionalOrNull(0)?.takeIf { looksLikeCode(it) }?.let { + return fetchByCode(dataDir, it, limit, timeoutMs) + } + + val filter = RawEventSupport.buildFilter(args) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val relays = RawEventSupport.queryTargets(ctx, args) + if (relays.isEmpty()) return Output.error("no_relays", "no relays available; pass --relay or run `amy relay add`") + + val received = ctx.drain(relays.associateWith { listOf(filter) }, timeoutMs) + val events = + received + .asSequence() + .map { it.second } + .distinctBy { it.id } + .sortedByDescending { it.createdAt } + .take(limit) + .map { Output.mapper.readTree(it.toJson()) } + .toList() + + Output.emit( + mapOf( + "queried_relays" to relays.map { it.url }, + "count" to events.size, + "events" to events, + ), + ) + return 0 + } + } + + private fun looksLikeCode(s: String): Boolean { + val t = s.removePrefix("nostr:") + return t.startsWith("npub1") || t.startsWith("nprofile1") || t.startsWith("nevent1") || + t.startsWith("naddr1") || t.startsWith("note1") || ('@' in t && '.' in t) + } + + /** + * Outbox-model fetch from a nip19/nip05 code: decode it into a filter + + * relay hints + author, then query the hint relays UNION the author's + * NIP-65 write relays — the same resolution the Android app uses to open a + * shared `nevent`/`naddr`/profile link. + */ + private suspend fun fetchByCode( + dataDir: DataDir, + codeArg: String, + limit: Int, + timeoutMs: Long, + ): Int { + val code = codeArg.removePrefix("nostr:") + Context.open(dataDir).use { ctx -> + ctx.prepare() + + var filter: Filter + val hintRelays = mutableSetOf() + var author: String? = null + + if ('@' in code) { + // nip05 → pubkey, then fetch that author's profile metadata. + author = ctx.requireUserHex(code) + filter = Filter(authors = listOf(author), kinds = listOf(0), limit = 1) + } else { + val entity = Nip19Parser.uriToRoute(code)?.entity ?: return Output.error("bad_args", "could not decode: $codeArg") + filter = + when (entity) { + is NEvent -> { + hintRelays.addAll(entity.relay) + author = entity.author + Filter(ids = listOf(entity.hex), limit = 1) + } + is NNote -> Filter(ids = listOf(entity.hex), limit = 1) + is NAddress -> { + hintRelays.addAll(entity.relay) + author = entity.author + Filter(kinds = listOf(entity.kind), authors = listOf(entity.author), tags = mapOf("d" to listOf(entity.dTag)), limit = 1) + } + is NProfile -> { + hintRelays.addAll(entity.relay) + author = entity.hex + Filter(authors = listOf(entity.hex), kinds = listOf(0), limit = 1) + } + is NPub -> { + author = entity.hex + Filter(authors = listOf(entity.hex), kinds = listOf(0), limit = 1) + } + else -> return Output.error("bad_args", "unsupported code for fetch: $codeArg") + } + } + + // Outbox model: hint relays + the author's advertised write relays. + val relays = (hintRelays + (author?.let { authorWriteRelays(ctx, it) } ?: emptySet())).ifEmpty { ctx.bootstrapRelays() } + + val received = ctx.drain(relays.associateWith { listOf(filter) }, timeoutMs) + val events = + received + .asSequence() + .map { it.second } + .distinctBy { it.id } + .sortedByDescending { it.createdAt } + .take(limit) + .map { Output.mapper.readTree(it.toJson()) } + .toList() + + Output.emit( + mapOf( + "code" to codeArg, + "resolved_author" to author, + "queried_relays" to relays.map { it.url }, + "count" to events.size, + "events" to events, + ), + ) + return 0 + } + } + + /** The author's NIP-65 write (outbox) relays, draining their kind:10002 on a cache miss. */ + private suspend fun authorWriteRelays( + ctx: Context, + author: String, + ): Set { + if (ctx.relaysOf(author) == null) { + val f = Filter(authors = listOf(author), kinds = listOf(AdvertisedRelayListEvent.KIND), limit = 1) + ctx.drain(ctx.bootstrapRelays().associateWith { listOf(f) }) + } + return ctx.relaysOf(author)?.writeRelaysNorm()?.toSet() ?: emptySet() + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FilterCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FilterCommand.kt new file mode 100644 index 0000000000..2f4af8fd37 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FilterCommand.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Output + +/** + * `amy filter [--kind …] [--author …] [--id …] [--tag …] [--since/--until TS] + * [--limit N] [--search TEXT]` — assemble a NIP-01 filter JSON + * from flags and print it (nak's `filter`). Local, no network, no account — + * handy for piping into another tool or eyeballing what `fetch`/`subscribe` + * would send. + * + * Same flag grammar as `fetch`/`subscribe`; `--author`/`--id` accept + * npub/nevent/note/naddr or hex (local decode only). + */ +object FilterCommand { + fun run(rest: Array): Int { + val filter = RawEventSupport.buildFilter(Args(rest)) + Output.emit(Output.mapper.readTree(filter.toJson())) + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GiftCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GiftCommands.kt new file mode 100644 index 0000000000..dfd06917ca --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GiftCommands.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent + +/** + * `amy gift wrap --to USER [EVENT-JSON]` and `amy gift unwrap [GIFTWRAP-JSON]` + * — the NIP-59 gift-wrap primitive (nak's `gift`). + * + * wrap seals a signed inner event for USER and wraps it in a kind:1059 + * gift wrap (random ephemeral sender). Prints the wrap; pass + * `--relay URL[,URL…]` to also broadcast it. + * unwrap decrypts a kind:1059 gift wrap with the active account's key, + * unseals it, and prints the inner event. + * + * Inner/wrap JSON comes from the positional argument or stdin (`-`). + * + * Thin assembly only: seal/wrap/unwrap all live in quartz + * (`SealedRumorEvent`, `GiftWrapEvent`). + */ +object GiftCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "gift", + tail, + "gift ", + mapOf( + "wrap" to { rest -> wrap(dataDir, rest) }, + "unwrap" to { rest -> unwrap(dataDir, rest) }, + ), + ) + + private suspend fun wrap( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val to = args.flag("to") ?: return Output.error("bad_args", "gift wrap requires --to USER") + val json = RawEventSupport.readArgOrStdin(args) + if (json.isEmpty()) return Output.error("bad_args", "no inner event JSON on the argument or stdin") + val inner = + try { + Event.fromJson(json) + } catch (e: Exception) { + return Output.error("bad_args", "could not parse inner event JSON: ${e.message}") + } + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val peer = ctx.requireUserHex(to) + val seal = SealedRumorEvent.create(event = inner, encryptTo = peer, signer = ctx.signer) + val giftWrap = GiftWrapEvent.create(event = seal, recipientPubKey = peer) + val wrapNode = Output.mapper.readTree(giftWrap.toJson()) + + val targets = RawEventSupport.relayFlag(args) + if (targets.isEmpty()) { + Output.emit(mapOf("event" to wrapNode, "published" to false)) + return 0 + } + val ack = ctx.publish(giftWrap, targets) + Output.emit( + mapOf( + "event" to wrapNode, + "published" to true, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + "rejected_by" to ack.filterValues { !it }.keys.map { it.url }, + ), + ) + return 0 + } + } + + private suspend fun unwrap( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val json = RawEventSupport.readArgOrStdin(args) + if (json.isEmpty()) return Output.error("bad_args", "no gift wrap JSON on the argument or stdin") + val giftWrap = + try { + Event.fromJson(json) as? GiftWrapEvent + ?: return Output.error("bad_args", "not a kind:1059 gift wrap event") + } catch (e: Exception) { + return Output.error("bad_args", "could not parse gift wrap JSON: ${e.message}") + } + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val unwrapped = + try { + giftWrap.unwrapThrowing(ctx.signer) + } catch (e: Exception) { + return Output.error("decrypt_failed", "could not unwrap (is this gift addressed to the active account?): ${e.message}") + } + // The wrap holds a seal (kind:13); unseal it to recover the rumor. + val inner = if (unwrapped is SealedRumorEvent) unwrapped.unsealThrowing(ctx.signer) else unwrapped + Output.emit(mapOf("event" to Output.mapper.readTree(inner.toJson()))) + return 0 + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt new file mode 100644 index 0000000000..9b14eeac03 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent + +/** + * `amy git ` — NIP-34 Nostr-native git + * repositories (nak's `git`, adapted to amy's event-publish model). + * + * announce publish a kind:30617 repository announcement + * list list a user's repository announcements + * show print one repository announcement (naddr or coordinates) + * issue publish a kind:1621 issue against a repository + * + * nak's clone/push/pull (git-packfile transport over relays/GRASP) are out + * of scope — they need a real git plumbing layer. These are the metadata / + * collaboration events. Thin assembly only: every event lives in quartz + * (`GitRepositoryEvent`, `GitIssueEvent`). + */ +object GitCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "git", + tail, + "git ", + mapOf( + "announce" to { rest -> announce(dataDir, rest) }, + "list" to { rest -> list(dataDir, rest) }, + "show" to { rest -> show(dataDir, rest) }, + "issue" to { rest -> issue(dataDir, rest) }, + ), + ) + + private suspend fun announce( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val name = args.flag("name") ?: return Output.error("bad_args", "git announce requires --name") + val csv = { key: String -> + args + .flag(key) + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + } + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val template = + GitRepositoryEvent.build( + name = name, + description = args.flag("description"), + webUrls = csv("web"), + cloneUrls = csv("clone"), + relays = csv("relay"), + maintainers = csv("maintainer"), + hashtags = csv("hashtag"), + earliestUniqueCommit = args.flag("earliest-commit"), + personalFork = args.bool("personal-fork"), + dTag = args.flag("d") ?: name, + ) + val signed = ctx.signer.sign(template) + val targets = RawEventSupport.publishTargets(ctx, args) + val ack = ctx.publish(signed, targets) + Output.emit( + mapOf( + "event_id" to signed.id, + "address" to Address.assemble(signed.kind, signed.pubKey, args.flag("d") ?: name), + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } + } + + private suspend fun list( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + Context.open(dataDir).use { ctx -> + ctx.prepare() + val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex + val relays = RawEventSupport.queryTargets(ctx, args) + val received = ctx.drain(relays.associateWith { listOf(Filter(kinds = listOf(GitRepositoryEvent.KIND), authors = listOf(author))) }) + val repos = + received + .asSequence() + .map { it.second } + .filterIsInstance() + .distinctBy { it.dTag() } + .sortedByDescending { it.createdAt } + .map { repoSummary(it) } + .toList() + Output.emit(mapOf("pubkey" to author, "count" to repos.size, "repositories" to repos)) + return 0 + } + } + + private suspend fun show( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val coord = args.positional(0, "naddr-or-coordinates") + val addr = resolveAddress(coord) ?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier (or pubkey:identifier)") + if (addr.kind != GitRepositoryEvent.KIND) { + return Output.error("bad_args", "not a git repository address (expected kind ${GitRepositoryEvent.KIND}, got ${addr.kind})") + } + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val repo = fetchRepo(ctx, addr, args) ?: return Output.error("not_found", "no repository announcement found for $coord") + Output.emit(repoSummary(repo) + mapOf("event_id" to repo.id, "content" to repo.content)) + return 0 + } + } + + private suspend fun issue( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val coord = args.positional(0, "repo-naddr-or-coordinates") + val subject = args.flag("subject") ?: return Output.error("bad_args", "git issue requires --subject") + val addr = resolveAddress(coord) ?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier") + val body = args.positionalOrNull(1) ?: "" + val topics = + args + .flag("hashtag") + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val repo = fetchRepo(ctx, addr, args) ?: return Output.error("not_found", "no repository announcement found for $coord") + val template = + GitIssueEvent.build( + subject = subject, + content = body, + repository = EventHintBundle(repo), + notify = emptyList(), + topics = topics, + ) + val signed = ctx.signer.sign(template) + // Deliver to the repo's advertised relays when present, else our targets. + val repoRelays = repo.relays().mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() + val targets = RawEventSupport.relayFlag(args).ifEmpty { repoRelays }.ifEmpty { ctx.outboxRelays() } + val ack = ctx.publish(signed, targets) + Output.emit( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "repository" to Address.assemble(addr.kind, addr.pubKeyHex, addr.dTag), + "subject" to subject, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } + } + + // ------------------------------------------------------------------ + + private suspend fun fetchRepo( + ctx: Context, + addr: Address, + args: Args, + ): GitRepositoryEvent? { + // Cache-first, then drain the query relays. + val filter = + Filter( + kinds = listOf(GitRepositoryEvent.KIND), + authors = listOf(addr.pubKeyHex), + tags = mapOf("d" to listOf(addr.dTag)), + limit = 1, + ) + ctx.store + .query(filter) + .firstOrNull() + ?.let { return it as? GitRepositoryEvent } + val relays = RawEventSupport.queryTargets(ctx, args) + ctx.drain(relays.associateWith { listOf(filter) }) + return ctx.store.query(filter).firstOrNull() as? GitRepositoryEvent + } + + /** Accept `naddr1…`, `kind:pubkey:dtag`, or `pubkey:dtag` (kind defaults to 30617). */ + private fun resolveAddress(input: String): Address? { + val trimmed = input.trim().removePrefix("nostr:") + if (trimmed.startsWith("naddr")) { + val n = NAddress.parse(trimmed) ?: return null + return Address(n.kind, n.author, n.dTag) + } + Address.parse(trimmed)?.let { return it } + val parts = trimmed.split(":") + return if (parts.size == 2 && parts[0].length == 64) Address(GitRepositoryEvent.KIND, parts[0], parts[1]) else null + } + + private fun repoSummary(repo: GitRepositoryEvent): Map = + mapOf( + "identifier" to repo.dTag(), + "name" to repo.name(), + "description" to repo.description(), + "clone" to repo.clones(), + "web" to repo.webs(), + "relays" to repo.relays(), + "maintainers" to repo.maintainers(), + "hashtags" to repo.hashtags(), + "address" to Address.assemble(repo.kind, repo.pubKey, repo.dTag()), + ) +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt new file mode 100644 index 0000000000..22079d8634 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Identity +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 + +/** + * `amy key …` — standalone key utilities (nak's `key`). Local, no network, + * no account; these never touch `~/.amy/` — they operate purely on the keys + * passed in. Use `amy init` / `amy login` to persist an identity. + * + * key generate mint a fresh keypair (prints nsec + npub + hex) + * key public derive the public key from a secret key + * key encrypt --password X NIP-49 encrypt to an ncryptsec1… + * key decrypt --password X NIP-49 decrypt back to a secret key + * + * Thin assembly only: key generation + bech32 derivation live in quartz + * (reused here via [Identity] / [Nip49]). + */ +object KeyCommands { + suspend fun dispatch(rest: Array): Int = + route( + "key", + rest, + "key ", + mapOf( + "generate" to { _ -> generate() }, + "public" to { tail -> public(tail) }, + "encrypt" to { tail -> encrypt(tail) }, + "decrypt" to { tail -> decrypt(tail) }, + "validate" to { tail -> validate(tail) }, + ), + ) + + /** + * `key validate PUBKEY` — nak's `key validate`. Parses an npub or 64-hex + * public key and reports whether it is a structurally valid x-only pubkey. + * Never errors on a bad key — it reports `{"valid": false}` so scripts can + * branch on the field rather than the exit code. + */ + private fun validate(rest: Array): Int { + val input = Args(rest).positional(0, "pubkey").trim() + val hex = + when { + input.startsWith("npub") -> runCatching { input.bechToBytes().toHexKey() }.getOrNull() + input.length == 64 && input.lowercase().all { it in "0123456789abcdef" } -> input.lowercase() + else -> null + } + // A Nostr pubkey is a 32-byte x-only key. Confirm length after decode. + val valid = hex != null && hex.length == 64 + if (!valid) { + Output.emit(mapOf("valid" to false)) + return 0 + } + val npub = hex!!.hexToByteArray().toNpub() + Output.emit(mapOf("valid" to true, "pubkey" to hex, "npub" to npub)) + return 0 + } + + /** Read the private key (nsec or 64-hex) into hex, or null if unparseable. */ + private fun privHexOrNull(input: String): String? = + when { + input.startsWith("nsec") -> runCatching { input.bechToBytes().toHexKey() }.getOrNull() + input.length == 64 && input.lowercase().all { it in "0123456789abcdef" } -> input.lowercase() + else -> null + } + + private fun encrypt(rest: Array): Int { + val args = Args(rest) + val priv = privHexOrNull(args.positional(0, "secret-key").trim()) ?: return Output.error("bad_args", "expected an nsec or 64-char hex secret key") + val password = args.flag("password") ?: args.flag("pw") ?: return Output.error("bad_args", "key encrypt requires --password") + val ncryptsec = Nip49().encrypt(priv, password) + Output.emit(mapOf("ncryptsec" to ncryptsec)) + return 0 + } + + private fun decrypt(rest: Array): Int { + val args = Args(rest) + val ncryptsec = args.positional(0, "ncryptsec").trim() + if (!ncryptsec.startsWith("ncryptsec")) return Output.error("bad_args", "expected an ncryptsec1… string") + val password = args.flag("password") ?: args.flag("pw") ?: return Output.error("bad_args", "key decrypt requires --password") + val privHex = + try { + Nip49().decrypt(ncryptsec, password) + } catch (e: Exception) { + return Output.error("decrypt_failed", e.message ?: "could not decrypt (wrong password?)") + } + val id = Identity.fromPrivateKey(privHex.hexToByteArray()) + Output.emit(mapOf("nsec" to id.nsec, "npub" to id.npub, "private_key" to id.privKeyHex, "pubkey" to id.pubKeyHex)) + return 0 + } + + private fun generate(): Int { + val id = Identity.create() + Output.emit( + mapOf( + "nsec" to id.nsec, + "npub" to id.npub, + "private_key" to id.privKeyHex, + "pubkey" to id.pubKeyHex, + ), + ) + return 0 + } + + private fun public(rest: Array): Int { + val args = Args(rest) + val input = args.positional(0, "secret-key").trim() + val id = + try { + when { + input.startsWith("nsec") -> Identity.fromNsec(input) + input.length == 64 && input.lowercase().all { it in "0123456789abcdef" } -> + Identity.fromPrivateKey(input.hexToByteArray()) + else -> return Output.error("bad_args", "expected an nsec or a 64-char hex private key") + } + } catch (e: Exception) { + return Output.error("bad_args", "could not parse secret key: ${e.message}") + } + Output.emit(mapOf("npub" to id.npub, "pubkey" to id.pubKeyHex)) + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KindCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KindCommand.kt new file mode 100644 index 0000000000..e2654428d6 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KindCommand.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.kinds.KindNames + +/** + * `amy kind ` — look up a Nostr event kind (nak's `kind`). Local, + * accountless. A number prints that kind's label + defining NIP; a text query + * searches labels. + * + * kind 1 -> {"kind":1,"name":"Short Text Note","nip":"10",...} + * kind "text note" -> {"query":…,"matches":[…]} + * + * Thin assembly only: the canonical kind registry lives in quartz + * (`KindNames`) — the same data the Android relay view localizes on top of. + */ +object KindCommand { + fun run(rest: Array): Int { + val args = Args(rest) + val arg = args.positional(0, "kind-number-or-name").trim() + val n = arg.toIntOrNull() + if (n != null) { + val info = KindNames.infoFor(n) + Output.emit( + mapOf( + "kind" to n, + "name" to info?.name, + "nip" to info?.nip, + "known" to (info != null), + ), + ) + return 0 + } + val matches = KindNames.search(arg) + Output.emit( + mapOf( + "query" to arg, + "count" to matches.size, + "matches" to matches.map { (kind, info) -> mapOf("kind" to kind, "name" to info.name, "nip" to info.nip) }, + ), + ) + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt index c7aa12e330..7e2f3bce1b 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt @@ -49,8 +49,14 @@ object LoginCommand { dataDir: DataDir, rest: Array, ): Int { + // NIP-46 NostrConnect (client-initiated) login: no key positional — + // amy mints a transport key, prints an offer, and waits for a signer. + val preArgs = Args(rest) + if (preArgs.bool("nostrconnect")) { + return NostrConnect.login(dataDir, preArgs) + } if (rest.isEmpty()) { - return Output.error("bad_args", "login [--password X]") + return Output.error("bad_args", "login [--password X]") } if (dataDir.identityExists()) { return Output.error("exists", "identity already exists at ${dataDir.identityFile}; use a fresh --data-dir or delete it first") @@ -71,7 +77,9 @@ object LoginCommand { mapOf( "npub" to identity.npub, "hex" to identity.pubKeyHex, - "read_only" to !identity.hasPrivateKey, + "read_only" to !identity.canSign, + "signer" to if (identity.bunker != null) "bunker" else "local", + "bunker_relays" to identity.bunker?.relays, "data_dir" to dataDir.root.absolutePath, ), ) @@ -82,6 +90,8 @@ object LoginCommand { key: String, args: Args, ): Identity? { + // 0. NIP-46 bunker connection string. + if (key.startsWith("bunker://")) return Identity.fromBunkerUri(key) // 1. ncryptsec — password mandatory. if (key.startsWith("ncryptsec")) { val pw = diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NipCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NipCommand.kt new file mode 100644 index 0000000000..515a2c17a2 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NipCommand.kt @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.selects.select +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.OkHttpClient +import okhttp3.Request + +/** + * `amy nip ` / `amy nip list` — look up a NIP (nak's `nip`). Local-ish and + * accountless. + * + * Resolution order, per request: the canonical `nostr-protocol/nips` git repo + * **first**, then a fallback search on Nostr (NIP-50 over the NipText kind:30817, + * wiki kind:30818 and long-form kind:30023) for relays/clients that publish NIPs + * natively. + * + * nip 46 fetch NIP-46 from the repo (falls back to Nostr on a miss) + * nip 7D hex-suffixed NIPs work too (case-insensitive) + * nip list fetch the repo's NIP index (README) + */ +object NipCommand { + private const val RAW_BASE = "https://raw.githubusercontent.com/nostr-protocol/nips/master" + + // NIP-50-capable relays for the Nostr fallback (search isn't universal). + private val SEARCH_RELAYS = + listOf("wss://relay.nostr.band", "wss://relay.damus.io") + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + .toSet() + + // Quartz's canonical "NIP published on Nostr" kind (NipTextEvent), plus the + // wiki + long-form kinds clients also use to mirror NIP text. + private const val NIPTEXT_KIND = NipTextEvent.KIND + private const val WIKI_KIND = 30818 + private const val LONGFORM_KIND = 30023 + + suspend fun run(rest: Array): Int { + if (rest.firstOrNull() == "list") return list() + val args = Args(rest) + val raw = args.positional(0, "nip-number").trim() + val slug = normalizeSlug(raw) + val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 8L) * 1000 + + // 1. Canonical repo first. + val url = "$RAW_BASE/$slug.md" + fetchText(url)?.let { text -> + Output.emit(mapOf("nip" to slug, "source" to "repo", "url" to url, "title" to titleOf(text), "text" to text)) + return 0 + } + + // 2. Fall back to Nostr. + val candidates = searchNostr(slug, timeoutMs) + if (candidates.isEmpty()) { + return Output.error("not_found", "NIP-$slug not in the repo and no matching wiki/long-form event on Nostr") + } + Output.emit(mapOf("nip" to slug, "source" to "nostr", "count" to candidates.size, "events" to candidates)) + return 0 + } + + private fun list(): Int { + val url = "$RAW_BASE/README.md" + val text = fetchText(url) ?: return Output.error("fetch_failed", "could not fetch the NIP index from $url") + Output.emit(mapOf("source" to "repo", "url" to url, "text" to text)) + return 0 + } + + /** `1` -> `01`, `7d` -> `7D`, `46` -> `46`. The repo names files in 2-char upper-case. */ + private fun normalizeSlug(input: String): String { + val cleaned = input.removePrefix("NIP-").removePrefix("nip-").uppercase() + return if (cleaned.length == 1) "0$cleaned" else cleaned + } + + /** + * NIP docs use setext headings — `NIP-46\n======` (H1) then a descriptive + * `Nostr Remote Signing\n--------` (H2). Prefer the descriptive H2, then + * the H1, then any ATX `#` heading. + */ + private fun titleOf(markdown: String): String? { + val lines = markdown.lines() + + fun underlinedBy( + ch: Char, + i: Int, + ): Boolean { + val text = lines.getOrNull(i)?.trim().orEmpty() + val rule = lines.getOrNull(i + 1)?.trim().orEmpty() + return text.isNotEmpty() && !text.startsWith("#") && rule.length >= 3 && rule.all { it == ch } + } + for (i in lines.indices) if (underlinedBy('-', i)) return lines[i].trim() + for (i in lines.indices) if (underlinedBy('=', i)) return lines[i].trim() + return lines.firstOrNull { it.startsWith("# ") || it.startsWith("## ") }?.trimStart('#', ' ')?.trim() + } + + private fun fetchText(url: String): String? = + try { + OkHttpClient() + .newCall( + Request + .Builder() + .url(url) + .get() + .build(), + ).execute() + .use { response -> + if (response.isSuccessful) response.body.string() else null + } + } catch (e: Exception) { + null + } + + /** NIP-50 search across wiki + long-form for events that document the NIP. */ + private suspend fun searchNostr( + slug: String, + timeoutMs: Long, + ): List> { + if (SEARCH_RELAYS.isEmpty()) return emptyList() + val okhttp = OkHttpClient.Builder().build() + val client = NostrClient(websocketBuilder = BasicOkHttpWebSocket.Builder { okhttp }) + val filter = Filter(kinds = listOf(NIPTEXT_KIND, WIKI_KIND, LONGFORM_KIND), search = "NIP-$slug", limit = 10) + val subId = newSubId() + val events = Channel(UNLIMITED) + val done = Channel(UNLIMITED) + val remaining = SEARCH_RELAYS.toMutableSet() + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + events.trySend(event) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + done.trySend(relay) + } + } + val collected = mutableListOf() + try { + client.connect() + client.subscribe(subId, SEARCH_RELAYS.associateWith { listOf(filter) }, listener) + withTimeoutOrNull(timeoutMs) { + while (remaining.isNotEmpty()) { + select { + events.onReceive { collected.add(it) } + done.onReceive { remaining.remove(it) } + } + } + } + } finally { + client.unsubscribe(subId) + client.close() + events.close() + done.close() + } + return collected + .distinctBy { it.id } + .take(10) + .map { ev -> + mapOf( + "id" to ev.id, + "kind" to ev.kind, + "pubkey" to ev.pubKey, + "title" to (ev.tags.firstOrNull { it.size > 1 && (it[0] == "title" || it[0] == "d") }?.get(1)), + ) + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt new file mode 100644 index 0000000000..29810dffaa --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Identity +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.OkHttpClient + +/** + * NIP-46 NostrConnect (client-initiated) flow helpers. + * + * Parsing/encoding the `nostrconnect://` offer is shared between the client + * (`amy login --nostrconnect`, [login]) and the signer (`amy bunker connect`, + * which only parses). The signer side lives in [BunkerCommand]. + */ +object NostrConnect { + data class Offer( + val clientPubkey: String, + val relays: Set, + val secret: String, + val name: String?, + ) + + /** Parse `nostrconnect://?relay=…&secret=…&name=…` (percent-decoded). */ + fun parseOffer(uri: String): Offer? { + if (!uri.startsWith("nostrconnect://")) return null + val parts = uri.removePrefix("nostrconnect://").split("?", limit = 2) + val clientPubkey = parts[0].lowercase() + if (clientPubkey.length != 64 || clientPubkey.any { it !in "0123456789abcdef" }) return null + val relays = mutableSetOf() + var secret: String? = null + var name: String? = null + parts.getOrNull(1)?.split("&")?.forEach { param -> + val kv = param.split("=", limit = 2) + if (kv.size < 2) return@forEach + val value = java.net.URLDecoder.decode(kv[1], "UTF-8") + when (kv[0]) { + "relay" -> RelayUrlNormalizer.normalizeOrNull(value)?.let { relays.add(it) } + "secret" -> secret = value + "name" -> name = value + } + } + if (secret == null) return null + return Offer(clientPubkey, relays, secret!!, name) + } + + private fun buildOffer( + clientPubkey: String, + relays: Set, + secret: String, + name: String?, + ): String { + val enc = { s: String -> java.net.URLEncoder.encode(s, "UTF-8") } + return buildString { + append("nostrconnect://").append(clientPubkey) + append("?").append(relays.joinToString("&") { "relay=${enc(it.url)}" }) + append("&secret=").append(enc(secret)) + if (name != null) append("&name=").append(enc(name)) + } + } + + /** + * `amy login --nostrconnect [--relay URL[,URL…]] [--name N] [--timeout SECS]` + * + * Mint a local transport keypair, print a `nostrconnect://` offer for the + * user to paste into a signer, then wait for the signer's connect ACK + * (a kind:24133 whose decrypted result echoes our secret). The ACK's author + * is the remote signer; persist a bunker account that acts as that key. + */ + suspend fun login( + dataDir: DataDir, + args: Args, + ): Int { + if (dataDir.identityExists()) { + return Output.error("exists", "identity already exists at ${dataDir.identityFile}; use a fresh --data-dir or delete it first") + } + val relays = RawEventSupport.relayFlag(args).ifEmpty { DefaultNIP65RelaySet } + if (relays.isEmpty()) return Output.error("bad_args", "no relays; pass --relay URL[,URL…]") + val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 120L) * 1000 + val name = args.flag("name") + + val clientKey = KeyPair() + val clientSigner = NostrSignerInternal(clientKey) + val clientPub = clientKey.pubKey.toHexKey() + val secret = KeyPair().privKey!!.toHexKey().take(32) + val offer = buildOffer(clientPub, relays, secret, name) + + // Surface the offer immediately so the human/harness can paste it. + System.err.println("[nostrconnect] paste this into your signer within ${timeoutMs / 1000}s:") + System.err.println(offer) + + val okhttp = OkHttpClient.Builder().build() + val client = NostrClient(websocketBuilder = BasicOkHttpWebSocket.Builder { okhttp }) + val incoming = Channel(UNLIMITED) + val subId = newSubId() + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is NostrConnectEvent) incoming.trySend(event) + } + } + + try { + client.connect() + val filter = Filter(kinds = listOf(NostrConnectEvent.KIND), tags = mapOf("p" to listOf(clientPub))) + client.subscribe(subId, relays.associateWith { listOf(filter) }, listener) + + val signerPub = + withTimeoutOrNull(timeoutMs) { + // Drain kind:24133 until one decrypts to a BunkerResponse echoing our secret. + var found: String? = null + while (found == null) { + found = verifyAck(incoming.receive(), clientSigner, secret) + } + found + } + + if (signerPub == null) { + return Output.error("timeout", "no signer connected within ${timeoutMs / 1000}s") + } + + val identity = Identity.bunkerIdentity(signerPub, relays.map { it.url }, secret, clientKey.privKey!!.toHexKey()) + dataDir.saveIdentity(identity) + Output.emit( + mapOf( + "npub" to identity.npub, + "hex" to identity.pubKeyHex, + "read_only" to false, + "signer" to "bunker", + "bunker_relays" to relays.map { it.url }, + "data_dir" to dataDir.root.absolutePath, + ), + ) + return 0 + } finally { + client.unsubscribe(subId) + incoming.close() + client.close() + } + } + + /** Returns the signer pubkey if [event] is the connect ACK echoing [secret], else null. */ + private suspend fun verifyAck( + event: NostrConnectEvent, + clientSigner: NostrSignerInternal, + secret: String, + ): String? = + try { + val msg = event.decryptMessage(clientSigner) + if (msg is BunkerResponse && msg.result == secret) event.pubKey else null + } catch (e: Exception) { + null + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OutboxCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OutboxCommand.kt new file mode 100644 index 0000000000..8545803da8 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OutboxCommand.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent + +/** + * `amy outbox USER [--refresh] [--timeout SECS]` — show a user's NIP-65 + * (kind:10002) read/write relays — the outbox-model relay hints (nak's + * `outbox`). USER accepts npub/nprofile/hex/NIP-05. + * + * Cache-first: reads the local store, falling back to a relay drain on a + * miss (or always with `--refresh`). + */ +object OutboxCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val user = args.positional(0, "user") + val refresh = args.bool("refresh") + val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 8L) * 1000 + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val pubkey = ctx.requireUserHex(user) + + var event = if (refresh) null else ctx.relaysOf(pubkey) + if (event == null) { + ctx.drain( + ctx.bootstrapRelays().associateWith { + listOf( + com.vitorpamplona.quartz.nip01Core.relay.filters + .Filter(authors = listOf(pubkey), kinds = listOf(AdvertisedRelayListEvent.KIND), limit = 1), + ) + }, + timeoutMs, + ) + event = ctx.relaysOf(pubkey) + } + + if (event == null) { + Output.emit(mapOf("pubkey" to pubkey, "found" to false)) + return 0 + } + + Output.emit( + mapOf( + "pubkey" to pubkey, + "found" to true, + "created_at" to event.createdAt, + "read" to (event.readRelaysNorm()?.map { it.url } ?: emptyList()), + "write" to (event.writeRelaysNorm()?.map { it.url } ?: emptyList()), + "all" to event.relaysNorm().map { it.url }, + ), + ) + return 0 + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PodcastCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PodcastCommands.kt new file mode 100644 index 0000000000..098b83194d --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PodcastCommands.kt @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent + +/** + * `amy podcast ` — NIP-F4 podcasts (nak's `podcast`). + * + * metadata publish a kind:10154 show metadata (replaceable) + * publish publish a kind:54 episode + * list list a user's podcast metadata + episodes + * + * Thin assembly only: events live in quartz (`PodcastMetadataEvent`, + * `PodcastEpisodeEvent`). + */ +object PodcastCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "podcast", + tail, + "podcast ", + mapOf( + "metadata" to { rest -> metadata(dataDir, rest) }, + "publish" to { rest -> publish(dataDir, rest) }, + "list" to { rest -> list(dataDir, rest) }, + ), + ) + + private suspend fun metadata( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val title = args.flag("title") ?: return Output.error("bad_args", "podcast metadata requires --title") + val image = args.flag("image") ?: return Output.error("bad_args", "podcast metadata requires --image") + val description = args.flag("description") ?: return Output.error("bad_args", "podcast metadata requires --description") + val websites = + args + .flag("website") + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val signed = ctx.signer.sign(PodcastMetadataEvent.build(title, image, description, websites)) + val ack = ctx.publish(signed, RawEventSupport.publishTargets(ctx, args)) + Output.emit( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "title" to title, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } + } + + private suspend fun publish( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val title = args.flag("title") ?: return Output.error("bad_args", "podcast publish requires --title") + val description = args.flag("description") ?: return Output.error("bad_args", "podcast publish requires --description") + val audioType = args.flag("audio-type") + val audios = + args + .flag("audio") + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?.map { AudioTag(it, audioType) } + .orEmpty() + if (audios.isEmpty()) return Output.error("bad_args", "podcast publish requires --audio URL[,URL…]") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val template = + PodcastEpisodeEvent.build( + title = title, + description = description, + audios = audios, + markdownContent = args.flag("content", "") ?: "", + image = args.flag("image"), + ) + val signed = ctx.signer.sign(template) + val ack = ctx.publish(signed, RawEventSupport.publishTargets(ctx, args)) + Output.emit( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "title" to title, + "audios" to audios.map { it.url }, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } + } + + private suspend fun list( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val limit = args.intFlag("limit", 50) + Context.open(dataDir).use { ctx -> + ctx.prepare() + val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex + val relays = RawEventSupport.queryTargets(ctx, args) + val received = + ctx.drain( + relays.associateWith { + listOf(Filter(kinds = listOf(PodcastMetadataEvent.KIND, PodcastEpisodeEvent.KIND), authors = listOf(author), limit = limit)) + }, + ) + val events = received.map { it.second }.distinctBy { it.id } + val show = events.filterIsInstance().maxByOrNull { it.createdAt } + val episodes = + events + .filterIsInstance() + .sortedByDescending { it.createdAt } + .map { + mapOf( + "event_id" to it.id, + "title" to it.title(), + "description" to it.description(), + "audios" to it.audios().map { a -> a.url }, + "created_at" to it.createdAt, + ) + } + Output.emit( + mapOf( + "pubkey" to author, + "metadata" to + show?.let { + mapOf("title" to it.title(), "description" to it.description(), "image" to it.image(), "websites" to it.websites()) + }, + "episode_count" to episodes.size, + "episodes" to episodes, + ), + ) + return 0 + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PublishCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PublishCommand.kt new file mode 100644 index 0000000000..37da518132 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PublishCommand.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.verify + +/** + * `amy publish [EVENT-JSON] [--relay URL[,URL…]]` — broadcast a pre-made, + * already-signed event (nak's `publish`). The event JSON comes from the + * positional argument or from stdin when omitted or `-`. + * + * The event is verified before broadcast — a bad id/signature is rejected + * rather than published. Targets default to the account's outbox when + * `--relay` is not given. + */ +object PublishCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val json = RawEventSupport.readArgOrStdin(args) + if (json.isEmpty()) return Output.error("bad_args", "no event JSON on the argument or stdin") + + val event = + try { + Event.fromJson(json) + } catch (e: Exception) { + return Output.error("bad_args", "could not parse event JSON: ${e.message}") + } + if (!event.verify()) { + return Output.error("invalid_event", "event id/signature does not verify — refusing to publish") + } + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val targets = RawEventSupport.publishTargets(ctx, args) + if (targets.isEmpty()) { + return Output.error("no_relays", "no outbox relays configured; pass --relay or run `amy relay add`") + } + val ack = ctx.publish(event, targets) + Output.emit( + mapOf( + "event_id" to event.id, + "kind" to event.kind, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + "rejected_by" to ack.filterValues { !it }.keys.map { it.url }, + ), + ) + return 0 + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RawEventSupport.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RawEventSupport.kt new file mode 100644 index 0000000000..9a81788869 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RawEventSupport.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.decodeEventIdAsHexOrNull +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull + +/** + * Shared helpers for the nak-style raw-event verbs (`event`, `publish`, + * `fetch`, `subscribe`, `count`). Kept tiny — parsing/normalisation only, + * no protocol logic. + */ +object RawEventSupport { + /** + * Read a blob from the first positional argument, or from stdin when the + * argument is omitted or `-`. Used by verbs that take event/filter JSON. + */ + fun readArgOrStdin(args: Args): String { + val arg = args.positionalOrNull(0) + return if (arg == null || arg == "-") { + System.`in` + .readBytes() + .decodeToString() + .trim() + } else { + arg.trim() + } + } + + /** Parse a `--relay a,b,c` flag into normalized relay URLs (silently drops un-normalizable entries). */ + fun relayFlag(args: Args): Set = + args + .flag("relay") + ?.split(',') + ?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) } + ?.toSet() + .orEmpty() + + /** + * Resolve where to publish: the explicit `--relay` set when given, + * otherwise the account's NIP-65 outbox. Empty only when neither is + * available (caller turns that into a `no_relays` error). + */ + suspend fun publishTargets( + ctx: Context, + args: Args, + ): Set = relayFlag(args).ifEmpty { ctx.outboxRelays() } + + /** + * Where to read from for `fetch` / `subscribe` / `count`: the explicit + * `--relay` set, else the account's outbox, else the bootstrap union. + */ + suspend fun queryTargets( + ctx: Context, + args: Args, + ): Set = relayFlag(args).ifEmpty { ctx.outboxRelays().ifEmpty { ctx.bootstrapRelays() } } + + /** + * Assemble a NIP-01 [Filter] from the common query flags shared by + * `fetch` / `subscribe` / `count`: + * + * --kind 1,7 comma-separated kind ints + * --author a,b comma-separated npub / nprofile / 64-hex (local decode only) + * --id x,y comma-separated note / nevent / naddr / 64-hex + * --tag e=,p=,t=nostr generic single-letter tag filters + * --since / --until unix seconds + * --limit N + * --search TEXT NIP-50 + * + * Author/id decoding is local (no NIP-05 round-trip) — pass hex or a + * bech32 entity. Unparseable entries are dropped. + */ + fun buildFilter(args: Args): Filter { + val kinds = + args + .flag("kind") + ?.split(',') + ?.mapNotNull { it.trim().toIntOrNull() } + ?.takeIf { it.isNotEmpty() } + val authors = + args + .flag("author") + ?.split(',') + ?.mapNotNull { decodePublicKeyAsHexOrNull(it.trim()) } + ?.takeIf { it.isNotEmpty() } + val ids = + args + .flag("id") + ?.split(',') + ?.mapNotNull { decodeEventIdAsHexOrNull(it.trim()) } + ?.takeIf { it.isNotEmpty() } + val tags = + args + .flag("tag") + ?.split(',') + ?.mapNotNull { pair -> + val idx = pair.indexOf('=') + if (idx <= 0) null else pair.take(idx).trim() to pair.substring(idx + 1).trim() + }?.groupBy({ it.first }, { it.second }) + ?.takeIf { it.isNotEmpty() } + + return Filter( + ids = ids, + authors = authors, + kinds = kinds, + tags = tags, + since = args.flag("since")?.toLongOrNull(), + until = args.flag("until")?.toLongOrNull(), + limit = args.flag("limit")?.toIntOrNull(), + search = args.flag("search"), + ) + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt index 416d6a6c63..5f8b6f43e1 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt @@ -27,10 +27,14 @@ import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType +import okhttp3.OkHttpClient +import okhttp3.Request /** * `amy relay ` — manage this account's relay sets. @@ -55,14 +59,60 @@ object RelayCommands { route( "relay", tail, - "relay …", + "relay …", mapOf( "add" to { rest -> add(dataDir, Args(rest)) }, "list" to { _ -> list(dataDir) }, "publish-lists" to { _ -> publishLists(dataDir) }, + // `info` is also intercepted in Main before account resolution + // (it needs no account); routed here too for when one exists. + "info" to { rest -> info(rest) }, ), ) + /** + * `relay info URL` — fetch a relay's NIP-11 information document over + * HTTP (`Accept: application/nostr+json`) and print it. Local/stateless: + * no account, no websocket. Parsing lives in quartz + * ([Nip11RelayInformation.fromJson]); this only does the GET. + */ + fun info(rest: Array): Int { + val args = Args(rest) + val raw = args.positional(0, "relay-url") + val normalized = + raw.normalizeRelayUrlOrNull() + ?: return Output.error("bad_args", "invalid relay url: $raw") + val httpUrl = normalized.toHttp() + + val request = + Request + .Builder() + .url(httpUrl) + .header("Accept", Nip11RelayInformation.CONTENT_TYPE) + .get() + .build() + + return try { + OkHttpClient().newCall(request).execute().use { response -> + if (!response.isSuccessful) { + return Output.error("http_error", "relay returned HTTP ${response.code} for $httpUrl") + } + val body = response.body.string() + val info = Nip11RelayInformation.fromJson(body) + Output.emit( + mapOf( + "relay" to normalized.url, + "url" to httpUrl, + "info" to Output.mapper.readTree(info.toJson()), + ), + ) + 0 + } + } catch (e: Exception) { + Output.error("fetch_failed", "could not fetch NIP-11 from $httpUrl: ${e.message}") + } + } + private suspend fun add( dataDir: DataDir, args: Args, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ServeCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ServeCommand.kt new file mode 100644 index 0000000000..c2dc9eb8a5 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ServeCommand.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.geode.KtorRelay +import com.vitorpamplona.geode.RelayEngine +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import kotlinx.coroutines.awaitCancellation + +/** + * `amy serve [--host H] [--port N] [--path P] [--db FILE] [--admin NPUBS]` — + * run a Nostr relay (nak's `serve`). Embeds **geode** (the standalone Ktor + * relay built on quartz's relay-server code), so amy serves the exact same + * relay implementation, including NIP-86 admin and NIP-77 Negentropy. + * + * In-memory by default (ephemeral, like nak serve); pass `--db FILE` for a + * persistent SQLite store. The account's own pubkey is always an admin so + * `amy admin ws://host:port …` works against it out of the box; `--admin` + * adds more (comma-separated npub/hex). Blocks until interrupted. + */ +object ServeCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val host = args.flag("host") ?: "127.0.0.1" + val port = args.intFlag("port", 7447) + val path = args.flag("path") ?: "/" + val dbFile = args.flag("db") + val extraAdmins = + args + .flag("admin") + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + + // Resolve admin pubkeys (self + --admin) up front, then drop the + // Context — the embedded relay owns its own store and needs no account. + val adminPubkeys = + Context.open(dataDir).use { ctx -> + buildSet { + add(ctx.identity.pubKeyHex) + extraAdmins.forEach { add(ctx.requireUserHex(it)) } + } + } + + // 0.0.0.0 isn't routable in a NIP-42 challenge; advertise loopback. + val advertisedHost = if (host == "0.0.0.0") "127.0.0.1" else host + val url = "ws://$advertisedHost:$port$path".normalizeRelayUrl() + // In-memory is RelayEngine's default; only build a SQLite store for --db. + val relay = + if (dbFile != null) { + RelayEngine(url, store = EventStore(dbName = dbFile, relay = url), adminPubkeys = adminPubkeys) + } else { + RelayEngine(url, adminPubkeys = adminPubkeys) + } + val server = KtorRelay(relay, host = host, port = port, path = path).start() + + Runtime.getRuntime().addShutdownHook( + Thread { + runCatching { server.stop() } + runCatching { relay.close() } + }, + ) + + Output.emit( + mapOf( + "listening" to server.url, + "host" to host, + "port" to port, + "path" to path, + "persistent" to (dbFile != null), + "admin_pubkeys" to adminPubkeys.toList(), + ), + ) + System.err.println("[serve] relay up at ${server.url} — Ctrl-C to stop") + + // Block until the process is interrupted; the shutdown hook tears down. + awaitCancellation() + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SubscribeCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SubscribeCommand.kt new file mode 100644 index 0000000000..a00a9ce86a --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SubscribeCommand.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.withTimeoutOrNull + +/** + * `amy subscribe [] [--relay URL[,URL…]] + * [--timeout SECS]` + * + * The live-streaming half of nak's `req`: open a subscription and print each + * matching event as it arrives — one object per line (NDJSON under `--json`). + * Unlike `fetch` it does not stop at EOSE; it streams until `--timeout` SECS + * elapse, or until the process is interrupted when no timeout is given. + * + * Each event is signature-verified before printing; bad ones are dropped. + */ +object SubscribeCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val timeoutMs = args.flag("timeout")?.toLongOrNull()?.let { it * 1000 } + val filter = RawEventSupport.buildFilter(args) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val relays = RawEventSupport.queryTargets(ctx, args) + if (relays.isEmpty()) return Output.error("no_relays", "no relays available; pass --relay or run `amy relay add`") + + val subId = newSubId() + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event.verify()) Output.emit(Output.mapper.readTree(event.toJson())) + } + } + + ctx.client.subscribe(subId, relays.associateWith { listOf(filter) }, listener) + try { + if (timeoutMs != null) withTimeoutOrNull(timeoutMs) { awaitCancellation() } else awaitCancellation() + } finally { + ctx.client.unsubscribe(subId) + } + return 0 + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt new file mode 100644 index 0000000000..51d7d471d8 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp +import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage +import com.vitorpamplona.quartz.nip77Negentropy.NegMsgMessage +import com.vitorpamplona.quartz.nip77Negentropy.NegentropySession +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener + +/** + * `amy sync --relay URL [filter flags] [--down] [--up] [--timeout SECS]` + * + * NIP-77 Negentropy set-reconciliation between the local event store and a + * relay (nak's `sync`, adapted to amy's local-store model). First it + * negotiates the symmetric difference under [filter], then closes the loop: + * + * --down (default) download events the relay has and we lack (REQ by id) + * --up upload events we have and the relay lacks (EVENT) + * + * Pass both for a full bidirectional sync. The filter flags are the same as + * `fetch`/`subscribe`; an empty filter reconciles the whole store. + * + * Thin assembly only: the negentropy protocol lives in quartz + * (`NegentropySession`); this file drives the WebSocket round-trips and + * reuses `Context.drain` / `Context.publish` for the NIP-01 follow-up. + */ +object SyncCommand { + private const val ID_CHUNK = 500 + + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val relayUrl = + args.flag("relay") + ?: return Output.error("bad_args", "sync requires --relay URL") + val relay = + RelayUrlNormalizer.normalizeOrNull(relayUrl) + ?: return Output.error("bad_args", "invalid relay url: $relayUrl") + val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 30L) * 1000 + // Default direction is download; --up adds upload. + val up = args.bool("up") + val down = args.bool("down") || !up + val filter = RawEventSupport.buildFilter(args) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val localEvents = ctx.store.query(filter) + val localById = localEvents.associateBy { it.id } + + val diff = + negotiate(relay.toHttp(), filter, localEvents, timeoutMs) + ?: return Output.error("timeout", "no negentropy response from ${relay.url} within ${timeoutMs}ms") + if (diff.error != null) { + return Output.error("sync_error", diff.error) + } + + // needIds = relay has, we lack; haveIds = we have, relay lacks. + var downloaded = 0 + if (down && diff.needIds.isNotEmpty()) { + diff.needIds.chunked(ID_CHUNK).forEach { chunk -> + val got = ctx.drain(mapOf(relay to listOf(Filter(ids = chunk))), timeoutMs) + downloaded += got.size + } + } + + var uploaded = 0 + if (up && diff.haveIds.isNotEmpty()) { + diff.haveIds.forEach { id -> + val ev = localById[id] ?: return@forEach + val ack = ctx.publish(ev, setOf(relay)) + if (ack.values.any { it }) uploaded++ + } + } + + Output.emit( + mapOf( + "relay" to relay.url, + "local_events" to localEvents.size, + "rounds" to diff.rounds, + "need" to diff.needIds.size, + "have" to diff.haveIds.size, + "downloaded" to downloaded, + "uploaded" to uploaded, + ), + ) + return 0 + } + } + + private data class Diff( + val haveIds: List, + val needIds: List, + val rounds: Int, + val error: String?, + ) + + /** + * Drive one NIP-77 reconciliation over a raw WebSocket and return the + * symmetric difference. Mirrors geode's interop sync driver: the protocol + * state machine is [NegentropySession]; this only shuttles frames. + */ + private suspend fun negotiate( + httpUrl: String, + filter: Filter, + localEvents: List, + timeoutMs: Long, + subId: String = "amy-sync", + maxRounds: Int = 64, + ): Diff? { + val incoming = Channel(UNLIMITED) + val client = OkHttpClient.Builder().build() + val ws = + client.newWebSocket( + Request.Builder().url(httpUrl).build(), + object : WebSocketListener() { + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + incoming.trySend(text) + } + + override fun onClosing( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + incoming.close() + } + + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: Response?, + ) { + incoming.close(t) + } + }, + ) + + return try { + withTimeoutOrNull(timeoutMs) { + val session = NegentropySession(subId, filter, localEvents, frameSizeLimit = 0) + ws.send(OptimizedJsonMapper.toJson(session.open())) + + val have = mutableSetOf() + val need = mutableSetOf() + var rounds = 0 + while (rounds < maxRounds) { + val raw = incoming.receive() + rounds++ + when (val msg = OptimizedJsonMapper.fromJsonToMessage(raw)) { + is NegErrMessage -> return@withTimeoutOrNull Diff(have.toList(), need.toList(), rounds, "${msg.subId}: ${msg.reason}") + is NegMsgMessage -> { + val r = session.processMessage(msg.message) + have += r.haveIds + need += r.needIds + if (r.isComplete()) { + return@withTimeoutOrNull Diff(have.toList(), need.toList(), rounds, null) + } + ws.send(OptimizedJsonMapper.toJson(r.nextCmd!!)) + } + else -> {} // ignore NOTICE/etc. and keep waiting + } + } + Diff(have.toList(), need.toList(), rounds, "did not converge in $maxRounds rounds") + } + } finally { + ws.close(1000, "amy-sync-done") + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/VerifyCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/VerifyCommand.kt new file mode 100644 index 0000000000..0733a00e71 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/VerifyCommand.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.verifyId +import com.vitorpamplona.quartz.nip01Core.crypto.verifySignature + +/** + * `amy verify [EVENT-JSON]` — check a Nostr event's id hash and signature + * (nak's `verify`). Local, no network, no account. The event JSON is read + * from the positional argument, or from stdin when the argument is omitted + * or `-`. + * + * Reports `id_ok` (computed id matches the `id` field) and `signature_ok` + * separately so a caller can tell a tampered id apart from a bad sig. + * `valid` is the conjunction. Exit code stays 0 — the result is data, not + * a runtime failure (parse errors are runtime/bad_args). + */ +object VerifyCommand { + fun run(rest: Array): Int { + val args = Args(rest) + val arg = args.positionalOrNull(0) + val json = + if (arg == null || arg == "-") { + System.`in` + .readBytes() + .decodeToString() + .trim() + } else { + arg.trim() + } + if (json.isEmpty()) return Output.error("bad_args", "no event JSON on the argument or stdin") + + val event = + try { + Event.fromJson(json) + } catch (e: Exception) { + return Output.error("bad_args", "could not parse event JSON: ${e.message}") + } + + val idOk = event.verifyId() + val sigOk = event.verifySignature() + + Output.emit( + mapOf( + "valid" to (idOk && sigOk), + "id" to event.id, + "id_ok" to idOk, + "signature_ok" to sigOk, + ), + ) + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuBalanceCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuBalanceCommand.kt new file mode 100644 index 0000000000..881574d7ca --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuBalanceCommand.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands.cashu + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output + +/** + * `amy cashu balance [--mint URL]` — spendable balance from the local store, + * via the shared CashuWalletReader projection. Optionally filtered to one mint. + */ +object CashuBalanceCommand { + suspend fun run( + dataDir: DataDir, + rest: Array, + ): Int { + val mintFilter = Args(rest).flag("mint")?.trimEnd('/') + Context.open(dataDir).use { ctx -> + val snap = ctx.cashuSnapshot() + val byMint = + snap.balancesByMint.let { all -> + if (mintFilter == null) all else all.filterKeys { it.trimEnd('/') == mintFilter } + } + Output.emit( + mapOf( + "balance_sats" to byMint.values.sum(), + "balances_by_mint" to byMint, + "proofs_count" to + snap.tokenEntries + .filter { mintFilter == null || it.content.mint.trimEnd('/') == mintFilter } + .sumOf { it.content.proofs.size }, + ), + ) + } + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuCommands.kt new file mode 100644 index 0000000000..877e99c222 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuCommands.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands.cashu + +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.commands.route + +/** + * `amy cashu …` — NIP-60 Cashu wallet + NIP-61 nutzaps, driven entirely + * through the shared `commons` wallet code (CashuWalletOps + CashuWalletReader) + * so amy exercises the exact path the Android app runs. + * + * See `cli/plans/2026-05-28-cashu-cli.md` for the full command surface and the + * stable `--json` contract. + */ +object CashuCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + name = "cashu", + tail = tail, + usage = "cashu ", + routes = + mapOf( + "wallet" to { rest -> CashuWalletCommands.dispatch(dataDir, rest) }, + "mint" to { rest -> CashuMintCommands.dispatch(rest) }, + "balance" to { rest -> CashuBalanceCommand.run(dataDir, rest) }, + "receive" to { rest -> CashuReceiveCommands.dispatch(dataDir, rest) }, + "send" to { rest -> CashuSendCommands.dispatch(dataDir, rest) }, + "maintenance" to { rest -> CashuMaintenanceCommands.dispatch(dataDir, rest) }, + "mint-rec" to { rest -> CashuMintRecCommands.dispatch(dataDir, rest) }, + ), + ) +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMaintenanceCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMaintenanceCommands.kt new file mode 100644 index 0000000000..274e83d60a --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMaintenanceCommands.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands.cashu + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.commands.route + +/** + * `amy cashu maintenance ` — wallet hygiene, + * all on the shared commons CashuWalletOps. + */ +object CashuMaintenanceCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + name = "cashu maintenance", + tail = tail, + usage = "cashu maintenance ", + routes = + mapOf( + "scrub" to { rest -> scrub(dataDir, rest) }, + "restore" to { rest -> restore(dataDir, rest) }, + "migrate-keysets" to { rest -> migrate(dataDir, rest) }, + ), + ) + + private suspend fun scrub( + dataDir: DataDir, + rest: Array, + ): Int { + val mintFilter = Args(rest).flag("mint")?.trimEnd('/') + Context.open(dataDir).use { ctx -> + ctx.prepare() + val byMint = + ctx + .cashuSnapshot() + .tokenEntries + .groupBy { it.content.mint } + .filterKeys { mintFilter == null || it.trimEnd('/') == mintFilter } + val scrubbed = mutableListOf>() + var keptCount = 0 + for ((mint, entries) in byMint) { + val staleEvents = runCatching { ctx.cashuOps().scrubStaleProofs(mint, entries) }.getOrDefault(emptyList()) + val staleIds = staleEvents.map { it.id }.toSet() + entries.filter { it.event.id in staleIds }.forEach { + scrubbed.add(mapOf("event_id" to it.event.id, "amount_sats" to it.content.totalAmount(), "mint_url" to mint)) + } + keptCount += entries.count { it.event.id !in staleIds } + } + Output.emit(mapOf("scrubbed" to scrubbed, "kept_count" to keptCount)) + } + return 0 + } + + private suspend fun restore( + dataDir: DataDir, + rest: Array, + ): Int { + val mint = Args(rest).positional(0, "mint-url").trimEnd('/') + Context.open(dataDir).use { ctx -> + ctx.prepare() + return try { + val outcome = ctx.cashuRestore(mint) ?: return Output.error("no_wallet", "no wallet seed to restore from") + Output.emit( + mapOf( + "mint_url" to mint, + "sats_recovered" to outcome.amountRecoveredSats, + "proofs_recovered" to outcome.proofsRecovered, + "token_event_id" to outcome.tokenEvent?.id, + ), + ) + 0 + } catch (e: Exception) { + Output.error("mint_unreachable", e.message ?: "restore failed") + } + } + } + + private suspend fun migrate( + dataDir: DataDir, + rest: Array, + ): Int { + val mintFilter = Args(rest).flag("mint")?.trimEnd('/') + Context.open(dataDir).use { ctx -> + ctx.prepare() + val byMint = + ctx + .cashuSnapshot() + .tokenEntries + .groupBy { it.content.mint } + .filterKeys { mintFilter == null || it.trimEnd('/') == mintFilter } + val migrated = mutableListOf>() + for ((mint, entries) in byMint) { + val activeId = runCatching { ctx.cashuOps().fetchActiveKeysetId(mint) }.getOrNull() ?: continue + val stale = entries.filter { entry -> entry.content.proofs.any { it.id != activeId } } + if (stale.isEmpty()) continue + val result = runCatching { ctx.cashuOps().migrateToActiveKeyset(mint, stale, activeId) }.getOrNull() ?: continue + migrated.add( + mapOf( + "mint_url" to mint, + "old_event_ids" to stale.map { it.event.id }, + "amount_sats" to result.amountMigrated, + "proofs_migrated" to result.proofsMigrated, + ), + ) + } + Output.emit(mapOf("migrated" to migrated)) + } + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintCommands.kt new file mode 100644 index 0000000000..687e1fee50 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintCommands.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands.cashu + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.commands.route +import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpClient +import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpException +import okhttp3.OkHttpClient + +/** + * `amy cashu mint URL` — stateless NIP-60 mint /v1/info probes. + * No account or relays; talks straight to the mint over HTTP. + */ +object CashuMintCommands { + suspend fun dispatch(tail: Array): Int = + route( + name = "cashu mint", + tail = tail, + usage = "cashu mint URL", + routes = + mapOf( + "ping" to { rest -> ping(rest) }, + "info" to { rest -> info(rest) }, + ), + ) + + private val okhttp = OkHttpClient.Builder().build() + + private suspend fun ping(rest: Array): Int { + val url = Args(rest).positional(0, "mint-url") + return try { + val dto = MintHttpClient(url) { okhttp }.info() + Output.emit( + mapOf( + "mint_url" to url, + "name" to dto.name, + "pubkey" to dto.pubkey, + "version" to dto.version, + "description" to dto.description, + ), + ) + 0 + } catch (e: MintHttpException) { + Output.error("mint_http_${e.code}", e.message) + } catch (e: Exception) { + Output.error("mint_unreachable", e.message) + } + } + + private suspend fun info(rest: Array): Int { + val url = Args(rest).positional(0, "mint-url") + return try { + val dto = MintHttpClient(url) { okhttp }.info(force = true) + Output.emit(mapOf("mint_url" to url, "mint_info" to dto)) + 0 + } catch (e: MintHttpException) { + Output.error("mint_http_${e.code}", e.message) + } catch (e: Exception) { + Output.error("mint_unreachable", e.message) + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintRecCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintRecCommands.kt new file mode 100644 index 0000000000..3f7a7bb6bc --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuMintRecCommands.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands.cashu + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.commands.route +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent + +/** + * `amy cashu mint-rec ` — NIP-87 mint recommendations + * (kind:38000), on the shared commons CashuWalletOps. + */ +object CashuMintRecCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + name = "cashu mint-rec", + tail = tail, + usage = "cashu mint-rec ", + routes = + mapOf( + "show" to { rest -> show(dataDir, rest) }, + "add" to { rest -> add(dataDir, rest) }, + "remove" to { rest -> remove(dataDir, rest) }, + ), + ) + + private fun render(e: MintRecommendationEvent) = + mapOf( + "event_id" to e.id, + "mint_url" to e.mintUrls().firstOrNull(), + "dtag" to e.dTag(), + "review" to e.content, + "pubkey_hex" to e.pubKey, + "created_at" to e.createdAt, + ) + + private suspend fun show( + dataDir: DataDir, + rest: Array, + ): Int { + val author = Args(rest).flag("author") + Context.open(dataDir).use { ctx -> + ctx.prepare() + val recs = + if (author == null) { + ctx.cashuSnapshot().recommendations + } else { + val pk = ctx.requireUserHex(author) + val filter = Filter(authors = listOf(pk), kinds = listOf(MintRecommendationEvent.KIND)) + if (ctx.store.query(filter).isEmpty()) ctx.drain(ctx.bootstrapRelays().associateWith { listOf(filter) }) + ctx.store + .query(filter) + .filterIsInstance() + .sortedByDescending { it.createdAt } + } + Output.emit(mapOf("recommendations" to recs.map { render(it) })) + } + return 0 + } + + private suspend fun add( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val url = args.positional(0, "mint-url").trimEnd('/') + Context.open(dataDir).use { ctx -> + ctx.prepare() + val event = ctx.cashuOps().recommendMint(mintUrl = url, mintAnnouncementDTag = args.flag("dtag"), review = args.flag("review") ?: "") + Output.emit(mapOf("event_id" to event.id, "mint_url" to url)) + } + return 0 + } + + private suspend fun remove( + dataDir: DataDir, + rest: Array, + ): Int { + val id = Args(rest).positional(0, "event-id") + Context.open(dataDir).use { ctx -> + ctx.prepare() + val event = + ctx.cashuSnapshot().recommendations.firstOrNull { it.id == id } + ?: (ctx.store.query(Filter(ids = listOf(id), limit = 1)).firstOrNull() as? MintRecommendationEvent) + ?: return Output.error("bad_args", "no recommendation event $id") + ctx.cashuOps().deleteRecommendation(event) + Output.emit(mapOf("deletion_event_id" to id, "deleted" to true)) + } + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt new file mode 100644 index 0000000000..f2c52589d2 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuReceiveCommands.kt @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands.cashu + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.commands.route +import com.vitorpamplona.quartz.lightning.LnInvoiceUtil +import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenB64Parser + +/** + * `amy cashu receive ` — inbound flows, + * all on the shared commons CashuWalletOps the Android wallet runs. + */ +object CashuReceiveCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + name = "cashu receive", + tail = tail, + usage = "cashu receive ", + routes = + mapOf( + "ln" to { rest -> ln(dataDir, rest) }, + "complete" to { rest -> complete(dataDir, rest) }, + "resume" to { rest -> complete(dataDir, rest) }, + "token" to { rest -> token(dataDir, rest) }, + "nutzap-sweep" to { rest -> nutzapSweep(dataDir, rest) }, + ), + ) + + /** Resolve the mint to use: --mint, else the wallet's first configured mint. */ + private fun pickMint( + flag: String?, + mints: List, + ): String? = flag?.trimEnd('/') ?: mints.firstOrNull() + + private suspend fun ln( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val sats = args.positional(0, "sats").toLongOrNull() ?: return Output.error("bad_args", "sats must be a positive integer") + if (sats <= 0) return Output.error("bad_args", "sats must be positive") + Context.open(dataDir).use { ctx -> + ctx.prepare() + val mint = pickMint(args.flag("mint"), ctx.cashuSnapshot().mints) ?: return Output.error("no_mint", "no mint configured; pass --mint URL") + return try { + val started = ctx.cashuOps().startMintFromLightning(mint, sats) + Output.emit( + mapOf( + "quote_id" to started.mintQuote.quote, + "invoice" to started.invoice, + "mint_url" to mint, + "amount_sats" to sats, + "kind_7374_event_id" to started.quoteEvent.id, + ), + ) + 0 + } catch (e: Exception) { + Output.error("mint_unreachable", describe(e)) + } + } + } + + private suspend fun complete( + dataDir: DataDir, + rest: Array, + ): Int { + val quoteId = Args(rest).positional(0, "quote-id") + Context.open(dataDir).use { ctx -> + ctx.prepare() + val snap = ctx.cashuSnapshot() + val quoteEvent = + snap.pendingQuotes.firstOrNull { runCatching { it.quoteId(ctx.signer) }.getOrNull() == quoteId } + ?: return Output.error("mint_quote_gone", "no pending kind:7374 quote with id $quoteId") + val mint = quoteEvent.mint() ?: snap.mints.firstOrNull() ?: return Output.error("no_mint", "quote has no mint") + return try { + // Poll the mint: a quote that isn't settled yet can't be minted. + val status = ctx.cashuOps().checkMintQuote(mint, quoteId) + if (!status.isSettled()) { + Output.emit(mapOf("status" to "pending", "quote_id" to quoteId, "mint_url" to mint)) + return 0 + } + // The kind:7374 stores only the quote id; recover the mint amount + // from the quote's bolt11 invoice (what the mint settled). + val amount = LnInvoiceUtil.getAmountInSats(status.request).toLong() + val done = ctx.cashuOps().completeMintFromLightning(mint, quoteEvent, amount) + Output.emit( + mapOf( + "status" to "paid", + "amount_sats" to done.mintedAmount, + "token_event_id" to done.tokenEvent.id, + "history_event_id" to done.historyEvent.id, + ), + ) + 0 + } catch (e: Exception) { + Output.error("mint_unreachable", describe(e)) + } + } + } + + private suspend fun token( + dataDir: DataDir, + rest: Array, + ): Int { + val raw = Args(rest).positional(0, "token").trim() + val parsed = CashuTokenB64Parser.parse(raw) ?: return Output.error("bad_args", "could not parse cashu token") + if (parsed.isEmpty()) return Output.error("bad_args", "token has no proofs") + Context.open(dataDir).use { ctx -> + ctx.prepare() + return try { + var total = 0L + var lastTokenEventId: String? = null + var lastHistoryEventId: String? = null + var mint = "" + for (t in parsed) { + val redeemed = ctx.cashuOps().redeemToken(raw, t.proofs, t.mint) + total += redeemed.amount + lastTokenEventId = redeemed.tokenEvent.id + lastHistoryEventId = redeemed.historyEvent.id + mint = t.mint + } + Output.emit( + mapOf( + "amount_sats" to total, + "mint_url" to mint, + "token_event_id" to lastTokenEventId, + "history_event_id" to lastHistoryEventId, + ), + ) + 0 + } catch (e: Exception) { + Output.error("mint_proofs_spent", describe(e)) + } + } + } + + private suspend fun nutzapSweep( + dataDir: DataDir, + rest: Array, + ): Int { + val mintFilter = Args(rest).flag("mint")?.trimEnd('/') + Context.open(dataDir).use { ctx -> + ctx.prepare() + val snap = ctx.cashuSnapshot() + val wallet = snap.walletEvent ?: return Output.error("no_wallet", "no wallet to redeem into") + val privkey = runCatching { wallet.privkey(ctx.signer) }.getOrNull() ?: return Output.error("signer_error", "could not decrypt wallet key") + val p2pkPubkey = snap.nutzapInfoEvent?.p2pkPubkey() ?: return Output.error("no_wallet", "no kind:10019 nutzap pubkey") + + val redeemed = mutableListOf>() + val skipped = mutableListOf>() + for (nutzap in snap.nutzapEvents) { + if (mintFilter != null && nutzap.mintUrl()?.trimEnd('/') != mintFilter) continue + try { + val r = ctx.cashuOps().redeemNutzap(nutzap, privkey, p2pkPubkey) + redeemed.add( + mapOf( + "nutzap_id" to nutzap.id, + "amount_sats" to r.amount, + "token_event_id" to r.tokenEvent.id, + "history_event_id" to r.historyEvent.id, + ), + ) + } catch (e: Exception) { + skipped.add(mapOf("nutzap_id" to nutzap.id, "reason" to describe(e))) + } + } + Output.emit(mapOf("redeemed" to redeemed, "skipped" to skipped)) + } + return 0 + } + + private fun describe(e: Throwable): String = e.message ?: e::class.simpleName ?: "error" +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuSendCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuSendCommands.kt new file mode 100644 index 0000000000..87efd169d4 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuSendCommands.kt @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands.cashu + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.commands.route +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent + +/** + * `amy cashu send ` — outbound flows on the shared + * commons CashuWalletOps. All spends scrub the source mint first (NUT-07 + + * NIP-09), exactly like the Android wallet, so a stale proof can't trip + * "proofs already spent". + */ +object CashuSendCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + name = "cashu send", + tail = tail, + usage = "cashu send ", + routes = + mapOf( + "ln" to { rest -> ln(dataDir, rest) }, + "token" to { rest -> token(dataDir, rest) }, + "nutzap" to { rest -> nutzap(dataDir, rest) }, + ), + ) + + private fun pickMint( + flag: String?, + mints: List, + ): String? = flag?.trimEnd('/') ?: mints.firstOrNull() + + private suspend fun ln( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val invoice = args.positional(0, "invoice").trim() + Context.open(dataDir).use { ctx -> + ctx.prepare() + val snap = ctx.cashuSnapshot() + val mint = pickMint(args.flag("mint"), snap.mints) ?: return Output.error("no_mint", "no mint configured; pass --mint URL") + return try { + val quote = ctx.cashuOps().requestMeltQuote(mint, invoice) + // Scrub stale proofs at this mint before melting (matches Amethyst). + val scrubbed = + ctx + .cashuOps() + .scrubStaleProofs(mint, snap.tokenEntries.filter { it.content.mint == mint }) + .map { it.id } + .toSet() + val available = snap.tokenEntries.filter { it.content.mint == mint && it.event.id !in scrubbed } + if (available.isEmpty()) return Output.error("insufficient_funds", "no proofs available at $mint") + val done = ctx.cashuOps().meltToLightning(mint, quote, available) + Output.emit( + mapOf( + "amount_sats" to done.paidAmount, + "fee_paid_sats" to done.fees, + "preimage" to done.preimage, + "history_event_id" to done.historyEvent.id, + ), + ) + 0 + } catch (e: Exception) { + Output.error("mint_unreachable", describe(e)) + } + } + } + + private suspend fun token( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val sats = args.positional(0, "sats").toLongOrNull() ?: return Output.error("bad_args", "sats must be a positive integer") + if (sats <= 0) return Output.error("bad_args", "sats must be positive") + val memo = args.flag("memo") + Context.open(dataDir).use { ctx -> + ctx.prepare() + val snap = ctx.cashuSnapshot() + val mint = pickMint(args.flag("mint"), snap.mints) ?: return Output.error("no_mint", "no mint configured; pass --mint URL") + return try { + val scrubbed = + ctx + .cashuOps() + .scrubStaleProofs(mint, snap.tokenEntries.filter { it.content.mint == mint }) + .map { it.id } + .toSet() + val available = snap.tokenEntries.filter { it.content.mint == mint && it.event.id !in scrubbed } + val balance = available.sumOf { it.content.totalAmount() } + if (balance < sats) return Output.error("insufficient_funds", "mint $mint has only $balance sat") + val done = ctx.cashuOps().sendAsToken(mint, sats, available, memo) + Output.emit( + mapOf( + "token" to done.cashuToken, + "amount_sats" to done.amount, + "mint_url" to mint, + "history_event_id" to done.historyEvent.id, + ), + ) + 0 + } catch (e: Exception) { + Output.error("mint_unreachable", describe(e)) + } + } + } + + private suspend fun nutzap( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val userArg = args.positional(0, "user") + val sats = args.positional(1, "sats").toLongOrNull() ?: return Output.error("bad_args", "sats must be a positive integer") + if (sats <= 0) return Output.error("bad_args", "sats must be positive") + val message = args.flag("message") ?: "" + val zappedId = args.flag("zapped") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val recipient = ctx.requireUserHex(userArg) + val snap = ctx.cashuSnapshot() + + // Resolve the recipient's kind:10019 (cache then relay). + val info = resolveNutzapInfo(ctx, recipient) ?: return Output.error("no_mint", "recipient has no kind:10019 nutzap info") + val recipientP2pk = info.p2pkPubkey() ?: return Output.error("nutzap_locked_to_wrong_key", "recipient kind:10019 has no P2PK pubkey") + val recipientMints = info.mints().map { it.mintUrl.trimEnd('/') }.toSet() + + // Pick a mint we both share AND hold a balance at. + val mint = + args.flag("mint")?.trimEnd('/')?.takeIf { it in recipientMints } + ?: snap.balancesByMint.keys.firstOrNull { it.trimEnd('/') in recipientMints } + ?: return Output.error("no_mint", "no shared mint with a balance; recipient mints=$recipientMints") + + val available = snap.tokenEntries.filter { it.content.mint.trimEnd('/') == mint.trimEnd('/') } + if (available.sumOf { it.content.totalAmount() } < sats) return Output.error("insufficient_funds", "mint $mint has insufficient balance") + + val zapped = + zappedId?.let { id -> + val ev = ctx.store.query(Filter(ids = listOf(id), limit = 1)).firstOrNull() + ev?.let { EventHintBundle(it) } + } + + return try { + val sent = ctx.cashuOps().sendNutzap(mint, sats, recipient, recipientP2pk, zapped, message, available) + Output.emit( + mapOf( + "nutzap_event_id" to sent.nutzapEvent.id, + "recipient_pubkey" to recipient, + "mint_url" to mint, + "amount_sats" to sent.amount, + "history_event_id" to sent.historyEvent.id, + ), + ) + 0 + } catch (e: Exception) { + Output.error("mint_unreachable", describe(e)) + } + } + } + + /** Recipient kind:10019 from the local store, falling back to a relay drain. */ + private suspend fun resolveNutzapInfo( + ctx: Context, + pubkey: String, + ): NutzapInfoEvent? { + val filter = Filter(authors = listOf(pubkey), kinds = listOf(NutzapInfoEvent.KIND), limit = 1) + (ctx.store.query(filter).firstOrNull() as? NutzapInfoEvent)?.let { return it } + ctx.drain(ctx.bootstrapRelays().associateWith { listOf(filter) }) + return ctx.store.query(filter).firstOrNull() as? NutzapInfoEvent + } + + private fun describe(e: Throwable): String = e.message ?: e::class.simpleName ?: "error" +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuWalletCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuWalletCommands.kt new file mode 100644 index 0000000000..88e2fb40ba --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/cashu/CashuWalletCommands.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands.cashu + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.commands.route +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +/** + * `amy cashu wallet `. + * + * create [--mint URL] [--mints a,b] [--privkey HEX] [--relay r1,r2] + * show + * export-key + * destroy + */ +object CashuWalletCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + name = "cashu wallet", + tail = tail, + usage = "cashu wallet ", + routes = + mapOf( + "create" to { rest -> create(dataDir, rest) }, + "show" to { rest -> show(dataDir, rest) }, + "export-key" to { rest -> exportKey(dataDir, rest) }, + "destroy" to { rest -> destroy(dataDir, rest) }, + ), + ) + + private fun Args.csv(key: String): List = + flag(key) + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + + private suspend fun create( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val mints = (args.csv("mint") + args.csv("mints") + args.positional.toList()).distinct() + if (mints.isEmpty()) return Output.error("bad_args", "at least one --mint URL is required") + val privkey = args.flag("privkey") + val explicitRelays = args.csv("relay").mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + + Context.open(dataDir).use { ctx -> + ctx.prepare() + // Match Amethyst: kind:10019 advertises the account's NIP-65 + // inbox (read) relays as nutzap-receiving relays unless the caller + // overrides with --relay, so senders publish kind:9321 where we + // read incoming events. + val nutzapRelays = explicitRelays.ifEmpty { ctx.nip65ReadRelays().toList() } + val created = + try { + ctx.cashuOps().publishWalletEvents(mints, privkey, nutzapRelays) + } catch (e: IllegalArgumentException) { + return Output.error("bad_args", e.message) + } + Output.emit( + mapOf( + "wallet_event_id" to created.walletEvent.id, + "nutzap_info_event_id" to created.nutzapInfo.id, + "p2pk_pubkey" to created.p2pkPubkeyHex, + "mints" to mints, + ), + ) + } + return 0 + } + + private suspend fun show( + dataDir: DataDir, + rest: Array, + ): Int { + Context.open(dataDir).use { ctx -> + val snap = ctx.cashuSnapshot() + if (snap.walletEvent == null) return Output.error("no_wallet", "no kind:17375 wallet in the local store — run `cashu wallet create`") + Output.emit( + mapOf( + "p2pk_pubkey" to snap.nutzapInfoEvent?.p2pkPubkey(), + "mints" to snap.mints, + "balance_sats" to snap.balanceSats, + "balances_by_mint" to snap.balancesByMint, + "proofs_count" to snap.tokenEntries.sumOf { it.content.proofs.size }, + "history_count" to snap.history.size, + "pending_quotes" to snap.pendingQuotes.size, + ), + ) + } + return 0 + } + + private suspend fun exportKey( + dataDir: DataDir, + rest: Array, + ): Int { + Context.open(dataDir).use { ctx -> + val wallet = ctx.cashuSnapshot().walletEvent ?: return Output.error("no_wallet", "no wallet to export a key from") + val priv = + runCatching { wallet.privkey(ctx.signer) }.getOrNull() + ?: return Output.error("signer_error", "could not decrypt the wallet P2PK key") + Output.emit(mapOf("privkey_hex" to priv)) + } + return 0 + } + + private suspend fun destroy( + dataDir: DataDir, + rest: Array, + ): Int { + Context.open(dataDir).use { ctx -> + ctx.prepare() + val wallet = ctx.cashuSnapshot().walletEvent ?: return Output.error("no_wallet", "no wallet to destroy") + // Withdraws the nutzap advertisement and NIP-09 deletes the + // kind:17375; leaves token events (the ecash still lives at the mint). + ctx.cashuOps().deleteWallet(wallet) + Output.emit(mapOf("destroyed_wallet_event_id" to wallet.id)) + } + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/secrets/IdentitySecret.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/secrets/IdentitySecret.kt index f91000fd04..ea1f670120 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/secrets/IdentitySecret.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/secrets/IdentitySecret.kt @@ -80,4 +80,20 @@ data class IdentityFile( // never written by current code. val privKeyHex: String? = null, val nsec: String? = null, + // Present for NIP-46 remote-signer (bunker) accounts. [pubKeyHex] is the + // user pubkey the bunker signs as; [secret] holds the LOCAL transport + // keypair used to encrypt NIP-46 traffic (not the user's key). + val bunker: BunkerFile? = null, +) + +/** + * NIP-46 bunker connection persisted in `identity.json`. The transport + * (client) private key lives in [IdentityFile.secret]; this records where + * to reach the remote signer and the optional connect secret. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +data class BunkerFile( + val remotePubkey: String, + val relays: List, + val connectSecret: String? = null, ) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileCashuKeysetCounterStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileCashuKeysetCounterStore.kt new file mode 100644 index 0000000000..c561cf2b10 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileCashuKeysetCounterStore.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.stores + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.cli.SecureFileIO +import com.vitorpamplona.amethyst.commons.cashu.CashuKeysetCounterStore +import java.io.File + +/** + * File-backed NUT-13 counter store for `amy`, persisted at + * `~/.amy//cashu.json` as `{ "keyset_counters": { "": } }`. + * + * [reserve] is the durability-critical path: it bumps the counter and + * rewrites the file **atomically** (tempfile + rename via [SecureFileIO]) + * before returning, so a crash after a swap can never replay a counter and + * trip the mint's `outputs already signed`. Access is serialized on the + * instance — amy is single-process, but a `synchronized` block keeps two + * concurrent mint ops within one run safe. + */ +class FileCashuKeysetCounterStore( + private val file: File, +) : CashuKeysetCounterStore { + private val mapper = jacksonObjectMapper() + private val lock = Any() + + private data class Persisted( + val keyset_counters: MutableMap = mutableMapOf(), + ) + + private fun load(): Persisted = + if (file.exists()) { + runCatching { mapper.readValue(file.readText()) }.getOrDefault(Persisted()) + } else { + Persisted() + } + + override fun peek(keysetId: String): Long = synchronized(lock) { load().keyset_counters[keysetId] ?: 0L } + + override fun reserve( + keysetId: String, + count: Int, + ): Long = + synchronized(lock) { + val state = load() + val first = state.keyset_counters[keysetId] ?: 0L + state.keyset_counters[keysetId] = first + count.coerceAtLeast(0) + SecureFileIO.writeTextAtomic(file, mapper.writeValueAsString(state)) + first + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuKeysetCounterStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuKeysetCounterStore.kt new file mode 100644 index 0000000000..1da6f2855e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuKeysetCounterStore.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cashu + +/** + * Durable NUT-13 deterministic-secret counter store, keyed by mint keyset id. + * + * Cashu NUT-13 derives blinded secrets from the wallet seed plus a per-keyset + * monotonically increasing counter. Reusing a counter makes the mint reply + * `outputs already signed`, so every reservation MUST be persisted **before** + * the blinded outputs hit the mint. Implementations therefore make + * [reserve] atomic and durable. + * + * - Android backs this with `AccountSettings` / `CashuPreferences`. + * - `amy` backs this with `~/.amy//cashu.json`. + * + * `CashuWalletOps` consumes the two operations as plain function references + * ([peek] / [reserve]); this interface gives both hosts one named contract. + */ +interface CashuKeysetCounterStore { + /** The next counter for [keysetId] without advancing it (0 if unseen). */ + fun peek(keysetId: String): Long + + /** + * Atomically reserve [count] consecutive counters for [keysetId] and + * return the first reserved index. Persists before returning. + */ + fun reserve( + keysetId: String, + count: Int, + ): Long +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuWalletReader.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuWalletReader.kt new file mode 100644 index 0000000000..1ea5966ad5 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuWalletReader.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cashu + +import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent +import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent +import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent +import com.vitorpamplona.quartz.nip60Cashu.token.TokenContent +import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent +import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent +import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent +import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Pure, stateless projection of a stream of NIP-60 / NIP-61 / NIP-87 events + * into a [WalletSnapshot]. This is the read half of the wallet, extracted out + * of the Android-only `CashuWalletState` so the headless CLI (`amy`) and the + * reactive Android holder run the **same** decrypt + del-rollover + + * pending-quote logic. + * + * - Android's `CashuWalletState` keeps its incremental dirty-tracking and + * StateFlow side effects but delegates the two tricky computations + * ([computeUnspent] and [computePending]) here. + * - `amy` calls [project] once per command over `store.allOfKinds(...)`. + * + * Decryption uses the supplied [signer]; failures are logged and the offending + * event is skipped, exactly as the Android holder does. + */ +class CashuWalletReader( + private val signer: NostrSigner, +) { + data class WalletSnapshot( + val walletEvent: CashuWalletEvent?, + val nutzapInfoEvent: NutzapInfoEvent?, + val mints: List, + val tokenEntries: List, + val history: List, + val pendingQuotes: List, + val nutzapEvents: List, + val recommendations: List, + ) { + /** Total spendable balance in the base unit (sats). */ + val balanceSats: Long get() = tokenEntries.sumOf { it.content.totalAmount() } + + /** Spendable balance grouped by mint URL. */ + val balancesByMint: Map + get() = + tokenEntries + .groupBy { it.content.mint } + .mapValues { (_, byMint) -> byMint.sumOf { it.content.totalAmount() } } + } + + suspend fun project(events: Iterable): WalletSnapshot { + var walletEvent: CashuWalletEvent? = null + var nutzapInfoEvent: NutzapInfoEvent? = null + val tokenEvents = LinkedHashMap() + val historyEvents = LinkedHashMap() + val quoteEvents = LinkedHashMap() + val nutzapEvents = LinkedHashMap() + val recommendationEvents = LinkedHashMap() + + for (event in events) { + when (event) { + is CashuWalletEvent -> + if (walletEvent == null || event.createdAt > walletEvent.createdAt) walletEvent = event + is NutzapInfoEvent -> + if (nutzapInfoEvent == null || event.createdAt > nutzapInfoEvent.createdAt) nutzapInfoEvent = event + is CashuTokenEvent -> tokenEvents.putIfAbsent(event.id, event) + is CashuSpendingHistoryEvent -> historyEvents.putIfAbsent(event.id, event) + is CashuMintQuoteEvent -> quoteEvents.putIfAbsent(event.id, event) + is NutzapEvent -> nutzapEvents.putIfAbsent(event.id, event) + is MintRecommendationEvent -> { + val key = event.dTag() ?: event.id + val current = recommendationEvents[key] + if (current == null || event.createdAt > current.createdAt) recommendationEvents[key] = event + } + else -> Unit + } + } + + val mints = + walletEvent?.let { evt -> + runCatching { evt.mints(signer) } + .onFailure { Log.w("CashuWalletReader") { "Failed to decrypt wallet mints: ${it.message}" } } + .getOrNull() + } ?: emptyList() + + val contents = decryptTokens(tokenEvents.values) + val tokenEntries = computeUnspent(tokenEvents.values, contents) + val pendingQuotes = computePending(quoteEvents.values, historyEvents.values) + + return WalletSnapshot( + walletEvent = walletEvent, + nutzapInfoEvent = nutzapInfoEvent, + mints = mints, + tokenEntries = tokenEntries, + history = historyEvents.values.sortedByDescending { it.createdAt }, + pendingQuotes = pendingQuotes, + nutzapEvents = nutzapEvents.values.sortedByDescending { it.createdAt }, + recommendations = recommendationEvents.values.sortedByDescending { it.createdAt }, + ) + } + + /** Decrypt every token event's content, skipping (and logging) failures. */ + suspend fun decryptTokens(events: Iterable): Map { + val out = HashMap() + events.forEach { evt -> + val content = + runCatching { evt.tokenContent(signer) } + .onFailure { Log.w("CashuWalletReader") { "Failed to decrypt token ${evt.id.take(8)}: ${it.message}" } } + .getOrNull() + if (content != null) out[evt.id] = content + } + return out + } + + companion object { + /** + * Project decrypted token events into the unspent set: drop anything a + * later token's `del` rollover marked destroyed or that failed to + * decrypt, then sort newest-first. + */ + fun computeUnspent( + events: Iterable, + contents: Map, + ): List { + val all = events.toList() + val deletedIds = mutableSetOf() + all.forEach { evt -> contents[evt.id]?.del?.let(deletedIds::addAll) } + return all + .filter { it.id !in deletedIds && contents.containsKey(it.id) } + .mapNotNull { evt -> contents[evt.id]?.let { TokenEntry(evt, it) } } + .sortedByDescending { it.event.createdAt } + } + + /** + * A quote is pending when it is neither expired nor referenced as + * "destroyed" by a kind:7376 history event (completion of a mint flow + * deletes the kind:7374 and records a destroyed reference to it). + */ + fun computePending( + quotes: Iterable, + history: Iterable, + now: Long = TimeUtils.now(), + ): List { + val destroyedQuoteIds = + history + .asSequence() + .flatMap { it.tags.asSequence() } + .filter { it.size >= 4 && it[0] == "e" && it[3] == "destroyed" } + .map { it[1] } + .toSet() + + return quotes + .filter { it.id !in destroyedQuoteIds } + .filter { evt -> + val exp = + evt.tags + .firstOrNull { it.size >= 2 && it[0] == "expiration" } + ?.get(1) + ?.toLongOrNull() + exp == null || exp > now + }.sortedByDescending { it.createdAt } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt index ae264b938d..c1d74c4089 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.nip60Cashu +package com.vitorpamplona.amethyst.commons.cashu.ops import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event @@ -947,6 +947,33 @@ class CashuWalletOps( proofs: List, ): Map = ops(mintUrl).checkStates(proofs) + /** + * NUT-07 scrub for a single mint: check every proof in [entries] against + * the mint's `/checkstate`, then NIP-09 delete every kind:7375 event that + * holds at least one SPENT proof (a mixed-state entry is unusable — the + * next swap would pick it and trip HTTP 400). Returns the deleted token + * events so the caller can drop them from its own indexes. + * + * Shared by Android's `CashuWalletState.scrubLocallyStaleProofs` and amy's + * `cashu maintenance scrub`, so both prune identically. + */ + suspend fun scrubStaleProofs( + mintUrl: String, + entries: List, + ): List { + val allProofs = entries.flatMap { it.content.proofs } + if (allProofs.isEmpty()) return emptyList() + val states = ops(mintUrl).checkStates(allProofs) + val stale = + entries.filter { entry -> + entry.content.proofs.any { states[it.secret] == ProofState.SPENT } + } + if (stale.isEmpty()) return emptyList() + val delTemplate = DeletionEvent.build(stale.map { it.event }) + publish(signer.sign(delTemplate)) + return stale.map { it.event } + } + /** * Swap every proof inside [entries] (which the caller has already * filtered to "contains at least one stale-keyset proof") onto the diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip86RelayManagement/Nip86Retriever.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayManagement/Nip86Retriever.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip86RelayManagement/Nip86Retriever.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayManagement/Nip86Retriever.kt index 306dc530a8..f9a07a6e51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip86RelayManagement/Nip86Retriever.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relayManagement/Nip86Retriever.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.nip86RelayManagement +package com.vitorpamplona.amethyst.commons.relayManagement import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt new file mode 100644 index 0000000000..3376178217 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -0,0 +1,627 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.kinds + +import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent +import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent +import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent +import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent +import com.vitorpamplona.quartz.experimental.clink.manage.ManageEvent +import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent +import com.vitorpamplona.quartz.experimental.decoupling.setup.EncryptionKeyListEvent +import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent +import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent +import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent +import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import com.vitorpamplona.quartz.experimental.nests.admin.AdminCommandEvent +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent +import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent +import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +import com.vitorpamplona.quartz.experimental.nns.NNSEvent +import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent +import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent +import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent +import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent +import com.vitorpamplona.quartz.feedDefinition.FeedDefinitionEvent +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent +import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.NotificationRequestEvent +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenListEvent +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenRemovalEvent +import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenRequestEvent +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip15Marketplace.auction.AuctionEvent +import com.vitorpamplona.quartz.nip15Marketplace.bid.BidEvent +import com.vitorpamplona.quartz.nip15Marketplace.bidConfirmation.BidConfirmationEvent +import com.vitorpamplona.quartz.nip15Marketplace.marketplace.MarketplaceEvent +import com.vitorpamplona.quartz.nip15Marketplace.product.ProductEvent +import com.vitorpamplona.quartz.nip15Marketplace.stall.StallEvent +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelHideMessageEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteEventEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.nip32Labeling.LabelEvent +import com.vitorpamplona.quartz.nip34Git.grasp.UserGraspListEvent +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent +import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import com.vitorpamplona.quartz.nip34Git.state.GitRepositoryStateEvent +import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent +import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent +import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent +import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent +import com.vitorpamplona.quartz.nip43RelayMembers.inviteRequest.RelayInviteRequestEvent +import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent +import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent +import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent +import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.PinListEvent +import com.vitorpamplona.quartz.nip51Lists.appCurationSet.AppCurationSetEvent +import com.vitorpamplona.quartz.nip51Lists.articleCurationSet.ArticleCurationSetEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent +import com.vitorpamplona.quartz.nip51Lists.gitAuthorList.GitAuthorListEvent +import com.vitorpamplona.quartz.nip51Lists.gitRepositoryList.GitRepositoryListEvent +import com.vitorpamplona.quartz.nip51Lists.goodWikiAuthorList.GoodWikiAuthorListEvent +import com.vitorpamplona.quartz.nip51Lists.goodWikiRelayList.GoodWikiRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent +import com.vitorpamplona.quartz.nip51Lists.kindMuteSet.KindMuteSetEvent +import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.mediaFollowList.MediaFollowListEvent +import com.vitorpamplona.quartz.nip51Lists.mediaStarterPack.MediaStarterPackEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.pictureCurationSet.PictureCurationSetEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent +import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent +import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent +import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent +import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent +import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent +import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent +import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent +import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.NappletSnapshotEvent +import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent +import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent +import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent +import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent +import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent +import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent +import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.draw.LiveChessDrawOfferEvent +import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent +import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.RelayMonitorEvent +import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip69P2pOrderEvents.P2POrderEvent +import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent +import com.vitorpamplona.quartz.nip71Video.VideoShortEvent +import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent +import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent +import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nip78AppData.AppDataEvent +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent +import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.addressables.AddressableAssertionEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.events.EventAssertionEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.externalIds.ExternalIdAssertionEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent +import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent +import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.contentSearch.NIP90ContentSearchRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.contentSearch.NIP90ContentSearchResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.eventCount.NIP90EventCountRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.eventCount.NIP90EventCountResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.eventPowDelegation.NIP90EventPowDelegationRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.eventPowDelegation.NIP90EventPowDelegationResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.eventPublishSchedule.NIP90EventPublishScheduleRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.eventPublishSchedule.NIP90EventPublishScheduleResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.eventTimestamping.NIP90EventTimestampingRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.eventTimestamping.NIP90EventTimestampingResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.imageGeneration.NIP90ImageGenerationRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.imageGeneration.NIP90ImageGenerationResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.imageToVideo.NIP90ImageToVideoRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.imageToVideo.NIP90ImageToVideoResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.malwareScanning.NIP90MalwareScanRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.malwareScanning.NIP90MalwareScanResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.opReturn.NIP90OpReturnRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.opReturn.NIP90OpReturnResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.peopleSearch.NIP90PeopleSearchRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.peopleSearch.NIP90PeopleSearchResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent +import com.vitorpamplona.quartz.nip90Dvms.summarization.NIP90SummarizationRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.summarization.NIP90SummarizationResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.textExtraction.NIP90TextExtractionRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.textExtraction.NIP90TextExtractionResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.textGeneration.NIP90TextGenerationRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.textGeneration.NIP90TextGenerationResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.textToSpeech.NIP90TextToSpeechRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.textToSpeech.NIP90TextToSpeechResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.translation.NIP90TranslationRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.translation.NIP90TranslationResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryRequest.NIP90UserDiscoveryRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryResponse.NIP90UserDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.videoConversion.NIP90VideoConversionRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.videoConversion.NIP90VideoConversionResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.videoTranslation.NIP90VideoTranslationRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.videoTranslation.NIP90VideoTranslationResponseEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent +import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent +import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent + +/** + * Human-readable label and defining NIP for a Nostr event kind. + */ +data class KindName( + val name: String, + val nip: String?, +) + +/** + * Canonical, **i18n-free** registry mapping event-kind numbers to an English + * label and the NIP that defines them. This is the single source of truth for + * "what is kind N" across the project: + * + * - headless consumers (the `amy` CLI) print [nameFor] directly; + * - localized front ends (the Android app) overlay their own translated + * strings on top and fall back to [nameFor] for kinds they don't translate. + * + * Keep this list as the place new kinds are registered; translations are a + * platform concern layered on top, never a fork of this data. + */ +object KindNames { + val names: Map = + mapOf( + AcceptedBadgeSetEvent.KIND to KindName("Accepted Badge Set", "58"), + AdvertisedRelayListEvent.KIND to KindName("Outbox Relays", "65"), + AppDefinitionEvent.KIND to KindName("Apps", "89"), + AppRecommendationEvent.KIND to KindName("App Recommendations", "89"), + AppSpecificDataEvent.KIND to KindName("User Settings", "78"), + AudioHeaderEvent.KIND to KindName("Audio Header", null), + AudioTrackEvent.KIND to KindName("Audio Track", null), + MusicTrackEvent.KIND to KindName("Music Track", null), + MusicPlaylistEvent.KIND to KindName("Music Playlist", null), + PodcastEpisodeEvent.KIND to KindName("Podcast Episode", "F4"), + PodcastMetadataEvent.KIND to KindName("Podcast Show", "F4"), + AuthoredPodcastsEvent.KIND to KindName("Authored Podcasts", "F4"), + FavoritePodcastsListEvent.KIND to KindName("Favorite Podcasts", "F4"), + AttestationEvent.KIND to KindName("Attestation", null), + AttestationRequestEvent.KIND to KindName("Attestation Request", null), + AttestorRecommendationEvent.KIND to KindName("Attestor Recommendation", null), + AttestorProficiencyEvent.KIND to KindName("Attestor Proficiency", null), + BadgeAwardEvent.KIND to KindName("Badge Awards", "58"), + BadgeDefinitionEvent.KIND to KindName("Badge Definitions", "58"), + BlockedRelayListEvent.KIND to KindName("Blocked Relays", "51"), + BlossomServersEvent.KIND to KindName("Blossom Servers", "B7"), + NestsServersEvent.KIND to KindName("Nests Servers", "53"), + BlossomAuthorizationEvent.KIND to KindName("Blossom Auth", "B7"), + BroadcastRelayListEvent.KIND to KindName("Broadcast Relays", "51"), + BookmarkListEvent.KIND to KindName("Bookmark List", "51"), + OldBookmarkListEvent.KIND to KindName("Old Bookmark List", "51"), + CalendarDateSlotEvent.KIND to KindName("Day Appointment", "52"), + CalendarEvent.KIND to KindName("Calendar", "52"), + CalendarTimeSlotEvent.KIND to KindName("Appointment", "52"), + CalendarRSVPEvent.KIND to KindName("Appt RSVP", "52"), + ChessGameEvent.KIND to KindName("Chess Games", "64"), + JesterEvent.KIND to KindName("Chess Auth", "64"), + RelayFeedsListEvent.KIND to KindName("Favorite Relays", "51"), + LiveChessGameChallengeEvent.KIND to KindName("Chess Challenges", "64"), + LiveChessGameAcceptEvent.KIND to KindName("Chess Game Accept", "64"), + LiveChessMoveEvent.KIND to KindName("Chess Move", "64"), + LiveChessGameEndEvent.KIND to KindName("Chess Game End", "64"), + LiveChessDrawOfferEvent.KIND to KindName("Chess Draw Offer", "64"), + ChannelCreateEvent.KIND to KindName("Channel Definition", "28"), + ChannelHideMessageEvent.KIND to KindName("Channel Hide Msg", "28"), + ChannelListEvent.KIND to KindName("Channel List", "28"), + ChannelMessageEvent.KIND to KindName("Channel Message", "28"), + ChannelMetadataEvent.KIND to KindName("Channel Metadata", "28"), + ChannelMuteUserEvent.KIND to KindName("Channel Mute User", "28"), + ChatMessageEncryptedFileHeaderEvent.KIND to KindName("DM File", "17"), + ChatMessageEvent.KIND to KindName("DM Message", "17"), + ChatMessageRelayListEvent.KIND to KindName("DM Relays", "17"), + ClassifiedsEvent.KIND to KindName("Classifieds", "99"), + CommentEvent.KIND to KindName("Comments", "22"), + CommunityDefinitionEvent.KIND to KindName("Community Def", "72"), + CommunityListEvent.KIND to KindName("Community List", "72"), + CommunityPostApprovalEvent.KIND to KindName("Community Post", "72"), + ContactListEvent.KIND to KindName("Follow List", "02"), + DeletionEvent.KIND to KindName("Deletions", "09"), + DraftWrapEvent.KIND to KindName("Drafts", "37"), + EmojiPackEvent.KIND to KindName("Emoji Packs", "30"), + EmojiPackSelectionEvent.KIND to KindName("Emoji Pack List", "30"), + EphemeralChatEvent.KIND to KindName("Ephemeral Chat", null), + EphemeralChatListEvent.KIND to KindName("Ephemeral Chatrooms", null), + FileHeaderEvent.KIND to KindName("File Headers", "94"), + ProfileGalleryEntryEvent.KIND to KindName("Profile Gallery", null), + FileServersEvent.KIND to KindName("File Servers", "96"), + FileStorageEvent.KIND to KindName("Blob Data", null), + FileStorageHeaderEvent.KIND to KindName("Blob Headers", null), + FhirResourceEvent.KIND to KindName("Medical Data", null), + FollowListEvent.KIND to KindName("Follow Packs", "51"), + GenericRepostEvent.KIND to KindName("Reposts (16)", "18"), + GeohashListEvent.KIND to KindName("Geohash Follows", "51"), + GiftWrapEvent.KIND to KindName("GiftWraps", "59"), + EphemeralGiftWrapEvent.KIND to KindName("GiftWraps", "59"), + GitIssueEvent.KIND to KindName("Git Issue", "34"), + GitPatchEvent.KIND to KindName("Git Patch", "34"), + GitRepositoryEvent.KIND to KindName("Git Repo", "34"), + GitReplyEvent.KIND to KindName("Git Reply", "34"), + GoalEvent.KIND to KindName("Zap Goals", "75"), + HashtagListEvent.KIND to KindName("Hashtag Follows", "51"), + HighlightEvent.KIND to KindName("Highlights", "84"), + HTTPAuthorizationEvent.KIND to KindName("Http Auth", "98"), + IndexerRelayListEvent.KIND to KindName("Index Relay List", "51"), + InteractiveStoryPrologueEvent.KIND to KindName("Adventure Prologue", null), + InteractiveStorySceneEvent.KIND to KindName("Adventure Scene", null), + InteractiveStoryReadingStateEvent.KIND to KindName("Adventure Reading", null), + LabeledBookmarkListEvent.KIND to KindName("Named Bookmarks", "51"), + LiveActivitiesChatMessageEvent.KIND to KindName("Live Chats", "53"), + LiveActivitiesEvent.KIND to KindName("Live Streams", "53"), + LnZapEvent.KIND to KindName("Zaps", "57"), + LnZapPaymentRequestEvent.KIND to KindName("NWC Request", "47"), + LnZapPaymentResponseEvent.KIND to KindName("NWC Response", "47"), + LnZapPrivateEvent.KIND to KindName("Private Zaps", "57"), + LnZapRequestEvent.KIND to KindName("Zap Req", "57"), + LongTextNoteEvent.KIND to KindName("Blogs", "23"), + MeetingRoomEvent.KIND to KindName("Meeting Room", "53"), + MeetingRoomPresenceEvent.KIND to KindName("Room Presence", "53"), + MeetingSpaceEvent.KIND to KindName("Meeting Space", "53"), + MetadataEvent.KIND to KindName("Profile", "01"), + MuteListEvent.KIND to KindName("Mute List", "51"), + NNSEvent.KIND to KindName("NNS", null), + NipTextEvent.KIND to KindName("NIP", null), + NostrConnectEvent.KIND to KindName("Nostr Connect", "46"), + NIP90StatusEvent.KIND to KindName("DVM Status", "90"), + NIP90ContentDiscoveryRequestEvent.KIND to KindName("DVM Content Req", "90"), + NIP90ContentDiscoveryResponseEvent.KIND to KindName("DVM Content Resp", "90"), + NIP90UserDiscoveryRequestEvent.KIND to KindName("DVM User Req", "90"), + NIP90UserDiscoveryResponseEvent.KIND to KindName("DVM User Resp", "90"), + OtsEvent.KIND to KindName("OTS", "03"), + PaymentTargetsEvent.KIND to KindName("PayTo", null), + PeopleListEvent.KIND to KindName("People Lists", "51"), + ProfileBadgesEvent.KIND to KindName("Profile Badges", "58"), + PictureEvent.KIND to KindName("Pictures", "68"), + WorkoutRecordEvent.KIND to KindName("Workouts", null), + PinListEvent.KIND to KindName("Pins", "51"), + ZapPollEvent.KIND to KindName("Zap Poll", null), + PollEvent.KIND to KindName("Poll", "88"), + PollResponseEvent.KIND to KindName("Poll Response", "88"), + PrivateDmEvent.KIND to KindName("NIP-04 DMs", "04"), + PrivateOutboxRelayListEvent.KIND to KindName("Private Relays", "37"), + ProxyRelayListEvent.KIND to KindName("Proxy Relays", "51"), + PublicMessageEvent.KIND to KindName("Public Message", "A4"), + ReactionEvent.KIND to KindName("Reactions", "25"), + ContactCardEvent.KIND to KindName("Contact Card", "85"), + RelayAuthEvent.KIND to KindName("Relay Auth", "42"), + RelayDiscoveryEvent.KIND to KindName("Relay Discovery", "66"), + RelayMonitorEvent.KIND to KindName("Relay Monitor Announcement", "66"), + RelaySetEvent.KIND to KindName("Relay Set", "51"), + ReportEvent.KIND to KindName("Reports", "56"), + RepostEvent.KIND to KindName("Reposts", "18"), + RequestToVanishEvent.KIND to KindName("User Delete", "62"), + SealedRumorEvent.KIND to KindName("Seals", "59"), + SearchRelayListEvent.KIND to KindName("Search Relays", "50"), + StatusEvent.KIND to KindName("User Status", "38"), + TextNoteEvent.KIND to KindName("Notes", "10"), + TextNoteModificationEvent.KIND to KindName("Edits", null), + TorrentEvent.KIND to KindName("Torrents", "35"), + TorrentCommentEvent.KIND to KindName("Torrent Comments", "35"), + TrustedRelayListEvent.KIND to KindName("Trusted Relays", "51"), + TrustProviderListEvent.KIND to KindName("Trusted Providers", "85"), + VideoHorizontalEvent.KIND to KindName("Video (Repl)", "71"), + VideoVerticalEvent.KIND to KindName("Shorts (Repl)", "71"), + VideoNormalEvent.KIND to KindName("Video", "71"), + VideoShortEvent.KIND to KindName("Shorts", "71"), + VoiceEvent.KIND to KindName("Voice Msg", "A0"), + VoiceReplyEvent.KIND to KindName("Voice Reply", "A0"), + WakeUpEvent.KIND to KindName("WakeUp", null), + WebBookmarkEvent.KIND to KindName("Web Bookmark", "B0"), + WikiNoteEvent.KIND to KindName("Wiki", "54"), + ChatEvent.KIND to KindName("Relay Chat", "C7"), + ThreadEvent.KIND to KindName("Thread", "7D"), + AppDataEvent.KIND to KindName("App Data", "78"), + WelcomeEvent.KIND to KindName("MLS Welcome", null), + GroupEvent.KIND to KindName("MLS Group Message", null), + NotificationRequestEvent.KIND to KindName("MLS Notification Request", null), + TokenRequestEvent.KIND to KindName("MLS Token Request", null), + TokenListEvent.KIND to KindName("MLS Token List", null), + TokenRemovalEvent.KIND to KindName("MLS Token Removal", null), + BidEvent.KIND to KindName("Marketplace Bid", "15"), + BidConfirmationEvent.KIND to KindName("Bid Confirmation", "15"), + LiveActivitiesRaidEvent.KIND to KindName("Live Raid", "53"), + LiveActivitiesClipEvent.KIND to KindName("Live Clip", "53"), + RoadEventReportEvent.KIND to KindName("Road Report", null), + RoadEventConfirmationEvent.KIND to KindName("Road Confirmation", null), + CodeSnippetEvent.KIND to KindName("Code Snippet", "C0"), + GitPullRequestEvent.KIND to KindName("Git Pull Request", "34"), + GitPullRequestUpdateEvent.KIND to KindName("Git PR Update", "34"), + LabelEvent.KIND to KindName("Label", "32"), + SoftwareAssetEvent.KIND to KindName("Software Asset", "82"), + AdminCommandEvent.KIND to KindName("Nests Admin Command", null), + NIP90TextExtractionRequestEvent.KIND to KindName("DVM Text Extraction Req", "90"), + NIP90SummarizationRequestEvent.KIND to KindName("DVM Summarization Req", "90"), + NIP90TranslationRequestEvent.KIND to KindName("DVM Translation Req", "90"), + NIP90TextGenerationRequestEvent.KIND to KindName("DVM Text Generation Req", "90"), + NIP90ImageGenerationRequestEvent.KIND to KindName("DVM Image Generation Req", "90"), + NappletSnapshotEvent.KIND to KindName("Napplet Snapshot", "5D"), + NIP90VideoConversionRequestEvent.KIND to KindName("DVM Video Conversion Req", "90"), + NIP90VideoTranslationRequestEvent.KIND to KindName("DVM Video Translation Req", "90"), + NIP90ImageToVideoRequestEvent.KIND to KindName("DVM Image To Video Req", "90"), + NIP90TextToSpeechRequestEvent.KIND to KindName("DVM Text To Speech Req", "90"), + NIP90ContentSearchRequestEvent.KIND to KindName("DVM Content Search Req", "90"), + NIP90PeopleSearchRequestEvent.KIND to KindName("DVM People Search Req", "90"), + NIP90EventCountRequestEvent.KIND to KindName("DVM Event Count Req", "90"), + NIP90MalwareScanRequestEvent.KIND to KindName("DVM Malware Scan Req", "90"), + NIP90EventTimestampingRequestEvent.KIND to KindName("DVM Timestamping Req", "90"), + NIP90OpReturnRequestEvent.KIND to KindName("DVM OpReturn Req", "90"), + NIP90EventPublishScheduleRequestEvent.KIND to KindName("DVM Publish Schedule Req", "90"), + NIP90EventPowDelegationRequestEvent.KIND to KindName("DVM PoW Delegation Req", "90"), + NIP90TextExtractionResponseEvent.KIND to KindName("DVM Text Extraction Resp", "90"), + NIP90SummarizationResponseEvent.KIND to KindName("DVM Summarization Resp", "90"), + NIP90TranslationResponseEvent.KIND to KindName("DVM Translation Resp", "90"), + NIP90TextGenerationResponseEvent.KIND to KindName("DVM Text Generation Resp", "90"), + NIP90ImageGenerationResponseEvent.KIND to KindName("DVM Image Generation Resp", "90"), + NIP90VideoConversionResponseEvent.KIND to KindName("DVM Video Conversion Resp", "90"), + NIP90VideoTranslationResponseEvent.KIND to KindName("DVM Video Translation Resp", "90"), + NIP90ImageToVideoResponseEvent.KIND to KindName("DVM Image To Video Resp", "90"), + NIP90TextToSpeechResponseEvent.KIND to KindName("DVM Text To Speech Resp", "90"), + NIP90ContentSearchResponseEvent.KIND to KindName("DVM Content Search Resp", "90"), + NIP90PeopleSearchResponseEvent.KIND to KindName("DVM People Search Resp", "90"), + NIP90EventCountResponseEvent.KIND to KindName("DVM Event Count Resp", "90"), + NIP90MalwareScanResponseEvent.KIND to KindName("DVM Malware Scan Resp", "90"), + NIP90EventTimestampingResponseEvent.KIND to KindName("DVM Timestamping Resp", "90"), + NIP90OpReturnResponseEvent.KIND to KindName("DVM OpReturn Resp", "90"), + NIP90EventPublishScheduleResponseEvent.KIND to KindName("DVM Publish Schedule Resp", "90"), + NIP90EventPowDelegationResponseEvent.KIND to KindName("DVM PoW Delegation Resp", "90"), + CashuMintQuoteEvent.KIND to KindName("Cashu Mint Quote", "60"), + CashuTokenEvent.KIND to KindName("Cashu Token", "60"), + CashuSpendingHistoryEvent.KIND to KindName("Cashu History", "60"), + RelayAddMemberEvent.KIND to KindName("Relay Add Member", "43"), + RelayRemoveMemberEvent.KIND to KindName("Relay Remove Member", "43"), + OnchainZapEvent.KIND to KindName("Onchain Zap", "BC"), + PutUserEvent.KIND to KindName("Group Put User", "29"), + RemoveUserEvent.KIND to KindName("Group Remove User", "29"), + EditMetadataEvent.KIND to KindName("Group Edit Metadata", "29"), + DeleteEventEvent.KIND to KindName("Group Delete Event", "29"), + CreateGroupEvent.KIND to KindName("Group Create", "29"), + DeleteGroupEvent.KIND to KindName("Group Delete", "29"), + CreateInviteEvent.KIND to KindName("Group Create Invite", "29"), + JoinRequestEvent.KIND to KindName("Group Join Request", "29"), + LeaveRequestEvent.KIND to KindName("Group Leave Request", "29"), + NutzapEvent.KIND to KindName("Nutzap", "61"), + SimpleGroupListEvent.KIND to KindName("Group List", "51"), + ExternalIdentitiesEvent.KIND to KindName("External Identities", "39"), + GitAuthorListEvent.KIND to KindName("Git Authors", "51"), + GitRepositoryListEvent.KIND to KindName("Git Repos", "51"), + NutzapInfoEvent.KIND to KindName("Nutzap Info", "61"), + MediaFollowListEvent.KIND to KindName("Media Follows", "51"), + EncryptionKeyListEvent.KIND to KindName("Encryption Keys", null), + KeyPackageRelayListEvent.KIND to KindName("MLS KeyPackage Relays", null), + FavoriteAlgoFeedsListEvent.KIND to KindName("Favorite Feeds", "51"), + GoodWikiAuthorListEvent.KIND to KindName("Wiki Authors", "51"), + GoodWikiRelayListEvent.KIND to KindName("Wiki Relays", "51"), + UserGraspListEvent.KIND to KindName("GRASP Servers", "34"), + BirdexEvent.KIND to KindName("Birdex", null), + NwcInfoEvent.KIND to KindName("NWC Info", "47"), + RelayMembershipListEvent.KIND to KindName("Relay Memberships", "43"), + RootSiteEvent.KIND to KindName("Website Root", "5A"), + RootNappletEvent.KIND to KindName("Napplet Root", "5D"), + CashuWalletEvent.KIND to KindName("Cashu Wallet", "60"), + OfferEvent.KIND to KindName("CLINK Offer", null), + DebitEvent.KIND to KindName("CLINK Debit", null), + ManageEvent.KIND to KindName("CLINK Manage", null), + NwcNotificationEvent.KIND to KindName("NWC Notification", "47"), + CallOfferEvent.KIND to KindName("Call Offer", "AC"), + CallAnswerEvent.KIND to KindName("Call Answer", "AC"), + CallIceCandidateEvent.KIND to KindName("Call ICE Candidate", "AC"), + CallHangupEvent.KIND to KindName("Call Hangup", "AC"), + CallRejectEvent.KIND to KindName("Call Reject", "AC"), + CallRenegotiateEvent.KIND to KindName("Call Renegotiate", "AC"), + RelayJoinRequestEvent.KIND to KindName("Relay Join Request", "43"), + RelayInviteRequestEvent.KIND to KindName("Relay Invite Request", "43"), + RelayLeaveRequestEvent.KIND to KindName("Relay Leave Request", "43"), + ArticleCurationSetEvent.KIND to KindName("Article Sets", "51"), + VideoCurationSetEvent.KIND to KindName("Video Sets", "51"), + PictureCurationSetEvent.KIND to KindName("Picture Sets", "51"), + KindMuteSetEvent.KIND to KindName("Kind Mute Sets", "51"), + InterestSetEvent.KIND to KindName("Interest Sets", "51"), + StallEvent.KIND to KindName("Marketplace Stall", "15"), + ProductEvent.KIND to KindName("Marketplace Product", "15"), + MarketplaceEvent.KIND to KindName("Marketplace", "15"), + AuctionEvent.KIND to KindName("Marketplace Auction", "15"), + ReleaseArtifactSetEvent.KIND to KindName("Release Artifacts", "51"), + AppCurationSetEvent.KIND to KindName("App Sets", "51"), + EventAssertionEvent.KIND to KindName("Event Assertion", "85"), + AddressableAssertionEvent.KIND to KindName("Addressable Assertion", "85"), + ExternalIdAssertionEvent.KIND to KindName("External ID Assertion", "85"), + KeyPackageEvent.KIND to KindName("MLS KeyPackage", null), + GitRepositoryStateEvent.KIND to KindName("Git Repo State", "34"), + FeedDefinitionEvent.KIND to KindName("Feed Definition", null), + SoftwareApplicationEvent.KIND to KindName("Software Application", "82"), + ExerciseTemplateEvent.KIND to KindName("Exercise Template", null), + FundraiserEvent.KIND to KindName("Fundraiser", null), + CommunityRulesEvent.KIND to KindName("Community Rules", "72"), + NamedSiteEvent.KIND to KindName("Website", "5A"), + NamedNappletEvent.KIND to KindName("Napplet", "5D"), + MintRecommendationEvent.KIND to KindName("Mint Recommendation", "87"), + CashuMintEvent.KIND to KindName("Cashu Mint", "87"), + FedimintEvent.KIND to KindName("Fedimint", "87"), + P2POrderEvent.KIND to KindName("P2P Order", "69"), + GroupMetadataEvent.KIND to KindName("Group Metadata", "29"), + GroupAdminsEvent.KIND to KindName("Group Admins", "29"), + GroupMembersEvent.KIND to KindName("Group Members", "29"), + SupportedRolesEvent.KIND to KindName("Group Roles", "29"), + MediaStarterPackEvent.KIND to KindName("Media Starter Pack", "51"), + ) + + fun infoFor(kind: Int): KindName? = names[kind] + + fun nameFor(kind: Int): String? = names[kind]?.name + + fun nipFor(kind: Int): String? = names[kind]?.nip + + /** Case-insensitive substring search by label; returns matching (kind, info) sorted by kind. */ + fun search(query: String): List> { + val q = query.trim().lowercase() + if (q.isEmpty()) return emptyList() + return names.entries + .filter { + it.value.name + .lowercase() + .contains(q) + }.map { it.key to it.value } + .sortedBy { it.first } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponse.kt index 4829578777..a2efda7826 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponse.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponse.kt @@ -24,4 +24,9 @@ open class BunkerResponse( val id: String, val result: String?, val error: String?, -) : BunkerMessage() +) : BunkerMessage() { + companion object { + /** NIP-46 auth-challenge marker: `result == "auth_url"`, with the URL carried in `error`. */ + const val RESULT_AUTH_URL = "auth_url" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerResponseKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerResponseKSerializer.kt index 9787b10eb3..8d6c80ab05 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerResponseKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerResponseKSerializer.kt @@ -75,6 +75,14 @@ object BunkerResponseKSerializer : KSerializer { val result = jsonObject["result"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content } val error = jsonObject["error"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content } + // NIP-46 auth challenge: `{"result":"auth_url","error":""}`. It carries + // an `error` (the URL) but is NOT a failure — keep both fields so the client + // can surface the URL and keep waiting for the real response. Must precede the + // generic error branch below, which would otherwise drop the `auth_url` marker. + if (result == BunkerResponse.RESULT_AUTH_URL) { + return BunkerResponse(id, result, error) + } + if (error != null) { return BunkerResponseError.parse(id, result, error) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt index ccc750e5a3..a1f419ca2d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt @@ -57,6 +57,12 @@ class NostrSignerRemote( val client: INostrClient, val permissions: String? = null, val secret: String? = null, + /** + * Invoked with the authorization URL when the bunker answers with a NIP-46 + * `auth_url` challenge. Surface it (open a browser / print it); the pending + * request keeps waiting for the real response. + */ + val onAuthUrl: ((String) -> Unit)? = null, ) : NostrSigner(signer.pubKey) { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) @@ -66,6 +72,7 @@ class NostrSignerRemote( remoteKey = remotePubkey, relayList = relays, client = client, + onAuthUrl = onAuthUrl, ) val subscription = diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt index 432c715e41..44fbc2ca90 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt @@ -41,6 +41,13 @@ class RemoteSignerManager( val remoteKey: String, val relayList: Set, val maxRetries: Int = 1, + /** + * Invoked with the authorization URL when the bunker answers a request with + * a NIP-46 `auth_url` challenge. The caller should surface it (open a + * browser / print it) so the user can authorize; the manager keeps waiting + * for the real response on the same request id. + */ + val onAuthUrl: ((String) -> Unit)? = null, ) { private val pending = LargeCache>() @@ -48,7 +55,10 @@ class RemoteSignerManager( val decryptedJson = signer.decrypt(responseEvent.content, remoteKey) val bunkerResponse = OptimizedJsonMapper.fromJsonTo(decryptedJson) - val channel = pending.remove(bunkerResponse.id) + // Peek (don't remove): a request may receive an `auth_url` challenge + // followed by the real response under the same id. The waiting + // continuation removes the entry in its `finally`. + val channel = pending.get(bunkerResponse.id) if (channel == null) { Log.d("NIP46") { "no channel for bunker response id=${bunkerResponse.id} (duplicate, unknown, or late)" } return @@ -89,13 +99,32 @@ class RemoteSignerManager( signer = signer, ) - val channel = Channel(capacity = 1) + // UNLIMITED so an `auth_url` challenge and the follow-up real + // response (same id) can both be buffered without dropping either. + val channel = Channel(capacity = Channel.UNLIMITED) pending.put(attemptRequest.id, channel) + var announcedAuthUrl: String? = null val response = try { client.publish(event, relayList = relayList) - withTimeoutOrNull(timeout) { channel.receive() } + withTimeoutOrNull(timeout) { + var real: BunkerResponse? = null + while (real == null) { + val r = channel.receive() + if (r.result == BunkerResponse.RESULT_AUTH_URL) { + // Surface the auth URL once and keep waiting for the real response. + val url = r.error + if (url != null && url != announcedAuthUrl) { + announcedAuthUrl = url + onAuthUrl?.invoke(url) + } + } else { + real = r + } + } + real + } } finally { pending.remove(attemptRequest.id) channel.close() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt index 99d18cb353..3f346531b8 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.utils.Hex @@ -72,6 +73,62 @@ class RemoteSignerManagerRetryTest { signer = bunkerSigner, ) + private suspend fun bunkerAuthUrlFor( + requestId: String, + url: String, + ): NostrConnectEvent = + NostrConnectEvent.create( + message = BunkerResponse(requestId, BunkerResponse.RESULT_AUTH_URL, url), + remoteKey = signer.pubKey, + signer = bunkerSigner, + ) + + @Test + fun authUrlResponseKeepsBothResultAndError() { + val json = """{"id":"abc","result":"auth_url","error":"https://signer.example/auth?x=1"}""" + val resp = OptimizedJsonMapper.fromJsonTo(json) + // Must NOT be flattened into a plain error — the auth_url marker has to survive. + assertEquals(BunkerResponse.RESULT_AUTH_URL, resp.result) + assertEquals("https://signer.example/auth?x=1", resp.error) + } + + @Test + fun authUrlChallengeIsSurfacedThenRealResponseResumes() = + runTest { + val capturing = CapturingNostrClient() + val seenUrls = mutableListOf() + val manager = + RemoteSignerManager( + timeout = 5_000, + client = capturing, + signer = signer, + remoteKey = remoteKey, + relayList = setOf(relay), + maxRetries = 0, + onAuthUrl = { seenUrls.add(it) }, + ) + + val deferred = + async { + manager.launchWaitAndParse( + bunkerRequestBuilder = { BunkerRequestPing() }, + parser = PingResponse::parse, + ) + } + runCurrent() + + val requestId = decodeRequestId(capturing.publishedEvents.single()) + // The bunker first asks for web authorization, then (after the user + // authorizes) sends the real response under the same id. + manager.newResponse(bunkerAuthUrlFor(requestId, "https://signer.example/auth")) + manager.newResponse(bunkerPongFor(requestId)) + advanceUntilIdle() + + val result = deferred.await() + assertIs>(result) + assertEquals(listOf("https://signer.example/auth"), seenUrls) + } + @Test fun timeoutReturnsTimedOutAfterMaxRetries() = runTest { diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerResponseDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerResponseDeserializer.kt index ccaf834c4b..40f61798fb 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerResponseDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerResponseDeserializer.kt @@ -44,6 +44,14 @@ class BunkerResponseDeserializer : StdDeserializer(BunkerRespons val result = jsonObject.get("result")?.asText() val error = jsonObject.get("error")?.asText() + // NIP-46 auth challenge: `{"result":"auth_url","error":""}`. It carries + // an `error` (the URL) but is NOT a failure — keep both fields so the client + // can surface the URL and keep waiting for the real response. Must precede the + // generic error branch below, which would otherwise drop the `auth_url` marker. + if (result == BunkerResponse.RESULT_AUTH_URL) { + return BunkerResponse(id, result, error) + } + if (error != null) { return BunkerResponseError.parse(id, result, error) }