Merge remote-tracking branch 'origin/main' into claude/awesome-pasteur-xwiwad

This commit is contained in:
Claude
2026-06-22 15:04:44 +00:00
67 changed files with 6200 additions and 102 deletions
+3 -1
View File
@@ -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-<slug>.md` (e.g. `cli/plans/`, `commons/plans/`).
+16 -1
View File
@@ -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.
@@ -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<HexKey>()
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<Event> {
@@ -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())
}
}
@@ -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.
@@ -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),
)
}
@@ -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<Int>): 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 ""
@@ -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 -> {
@@ -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",
@@ -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
@@ -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
@@ -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
@@ -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
+17
View File
@@ -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_<status>`, `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":{"<id>":<long>}}
│ └── marmot/
│ ├── keypackages.bundle # MLS KeyPackage bundles (NostrSignerInternal)
│ └── groups/
+162
View File
@@ -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://<alice-pubkey>?relay=wss://relay.example/&secret=s3cret
# terminal 2 — bob signs through it
amy --account bob login 'bunker://<alice-pubkey>?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","<id>"]]'`. |
| `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/<account>/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 |
+64
View File
@@ -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
+4
View File
@@ -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)
+21 -3
View File
@@ -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
@@ -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://<remote-pubkey>?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<String>()
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<String>,
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<String>,
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))
@@ -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 `<data-dir>/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<Event>(
Filter(
authors = listOf(pk),
kinds =
listOf(
CashuWalletEvent.KIND,
CashuTokenEvent.KIND,
CashuSpendingHistoryEvent.KIND,
CashuMintQuoteEvent.KIND,
NutzapInfoEvent.KIND,
MintRecommendationEvent.KIND,
),
),
)
val inboundNutzaps =
store.query<Event>(
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<NormalizedRelayUrl> =
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) {
@@ -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<String>): 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<String>): 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 [<same filter flags as fetch>] live stream: print each event as it arrives
| [--relay URL[,URL…]] [--timeout SECS] (NDJSON). Runs until --timeout or interrupt.
| count [<same filter flags as fetch>] NIP-45 COUNT: per-relay match counts, no
| [--relay URL[,URL…]] [--timeout SECS] event download.
| sync --relay URL [<filter flags>] 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
@@ -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<String>,
): 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
}
}
@@ -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 <upload|download|list|delete>` — 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<String>,
): Int =
route(
"blossom",
tail,
"blossom <upload|download|list|delete|check|mirror>",
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<String>,
): 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<String>,
): 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<String>,
): 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<String>,
): 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<String>,
): 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<String>,
): 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
}
}
}
}
@@ -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<String>,
): 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<String>,
): 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 <nostrconnect://…>` — 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<String>,
): 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<NormalizedRelayUrl>,
secret: String,
timeoutMs: Long?,
) {
val self = ctx.identity.pubKeyHex
val events = Channel<NostrConnectEvent>(UNLIMITED)
val seen = mutableSetOf<String>()
val subId = newSubId()
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
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<NormalizedRelayUrl>,
) {
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<Event>(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}")
}
}
}
@@ -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 [<same filter flags as fetch>] [--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<String>,
): 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
}
}
}
@@ -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<String>,
): 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<String>,
): 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
}
}
}
@@ -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 <bech32>` — 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<String>): 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<String, Any?> =
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
}
}
@@ -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 <type> …` — build a NIP-19 entity from raw parts (nak's
* `encode`). Local, no network, no account.
*
* encode npub <hex>
* encode nsec <hex>
* encode note <event-id-hex>
* encode nevent <event-id-hex> [--author HEX] [--kind N] [--relay URL[,URL…]]
* encode nprofile <pubkey-hex> [--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<String>): Int {
if (rest.isEmpty()) return Output.error("bad_args", "encode <npub|nsec|note|nevent|nprofile|naddr> …")
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<NormalizedRelayUrl> =
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
}
}
@@ -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","<hex>"],["t","nostr"],["e","<id>","","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<String>,
): 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<Array<String>> =
try {
args
.flag("tags")
?.let { Output.mapper.readValue<List<List<String>>>(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
}
}
}
@@ -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 <nevent1…|naddr1…|nprofile1…|npub1…|note1…|name@domain>
*
* 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<String>,
): 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<NormalizedRelayUrl>()
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<NormalizedRelayUrl> {
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()
}
}
@@ -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<String>): Int {
val filter = RawEventSupport.buildFilter(Args(rest))
Output.emit(Output.mapper.readTree(filter.toJson()))
return 0
}
}
@@ -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<String>,
): Int =
route(
"gift",
tail,
"gift <wrap|unwrap>",
mapOf(
"wrap" to { rest -> wrap(dataDir, rest) },
"unwrap" to { rest -> unwrap(dataDir, rest) },
),
)
private suspend fun wrap(
dataDir: DataDir,
rest: Array<String>,
): 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<String>,
): 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
}
}
}
@@ -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 <announce|list|show|issue>` — 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<String>,
): Int =
route(
"git",
tail,
"git <announce|list|show|issue>",
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<String>,
): 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<String>,
): 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<GitRepositoryEvent>()
.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<String>,
): 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<String>,
): 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<Event>(filter)
.firstOrNull()
?.let { return it as? GitRepositoryEvent }
val relays = RawEventSupport.queryTargets(ctx, args)
ctx.drain(relays.associateWith { listOf(filter) })
return ctx.store.query<Event>(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<String, Any?> =
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()),
)
}
@@ -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 <nsec|hex-priv> derive the public key from a secret key
* key encrypt <nsec|hex> --password X NIP-49 encrypt to an ncryptsec1…
* key decrypt <ncryptsec> --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<String>): Int =
route(
"key",
rest,
"key <generate|public|encrypt|decrypt>",
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<String>): 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<String>): 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<String>): 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<String>): 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
}
}
@@ -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 <N|name>` — 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<String>): 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
}
}
@@ -49,8 +49,14 @@ object LoginCommand {
dataDir: DataDir,
rest: Array<String>,
): 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 <nsec|ncryptsec|mnemonic|npub|nprofile|hex|nip05> [--password X]")
return Output.error("bad_args", "login <nsec|ncryptsec|mnemonic|npub|nprofile|hex|nip05|bunker://|--nostrconnect> [--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 =
@@ -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 <N>` / `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<String>): 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<Map<String, Any?>> {
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<Event>(UNLIMITED)
val done = Channel<NormalizedRelayUrl>(UNLIMITED)
val remaining = SEARCH_RELAYS.toMutableSet()
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
events.trySend(event)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
done.trySend(relay)
}
}
val collected = mutableListOf<Event>()
try {
client.connect()
client.subscribe(subId, SEARCH_RELAYS.associateWith { listOf(filter) }, listener)
withTimeoutOrNull(timeoutMs) {
while (remaining.isNotEmpty()) {
select<Unit> {
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)),
)
}
}
}
@@ -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<NormalizedRelayUrl>,
val secret: String,
val name: String?,
)
/** Parse `nostrconnect://<client-pubkey>?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<NormalizedRelayUrl>()
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<NormalizedRelayUrl>,
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<NostrConnectEvent>(UNLIMITED)
val subId = newSubId()
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
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
}
}
@@ -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<String>,
): 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
}
}
}
@@ -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 <metadata|publish|list>` — 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<String>,
): Int =
route(
"podcast",
tail,
"podcast <metadata|publish|list>",
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<String>,
): 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<String>,
): 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<String>,
): 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<PodcastMetadataEvent>().maxByOrNull { it.createdAt }
val episodes =
events
.filterIsInstance<PodcastEpisodeEvent>()
.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
}
}
}
@@ -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<String>,
): 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
}
}
}
@@ -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<NormalizedRelayUrl> =
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<NormalizedRelayUrl> = 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<NormalizedRelayUrl> = 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=<id>,p=<pk>,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"),
)
}
}
@@ -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 <add|list|publish-lists>` — manage this account's relay sets.
@@ -55,14 +59,60 @@ object RelayCommands {
route(
"relay",
tail,
"relay <add|list|publish-lists> …",
"relay <add|list|publish-lists|info> …",
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<String>): 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,
@@ -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<String>,
): 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()
}
}
@@ -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 [<same filter flags as fetch>] [--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<String>,
): 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<Filter>?,
) {
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
}
}
}
@@ -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<String>,
): 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<Event>(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<HexKey>,
val needIds: List<HexKey>,
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<Event>,
timeoutMs: Long,
subId: String = "amy-sync",
maxRounds: Int = 64,
): Diff? {
val incoming = Channel<String>(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<HexKey>()
val need = mutableSetOf<HexKey>()
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")
}
}
}
@@ -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<String>): 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
}
}
@@ -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<String>,
): 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
}
}
@@ -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<String>,
): Int =
route(
name = "cashu",
tail = tail,
usage = "cashu <wallet|mint|balance|receive|send|maintenance|mint-rec>",
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) },
),
)
}
@@ -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 <scrub|restore|migrate-keysets>` — wallet hygiene,
* all on the shared commons CashuWalletOps.
*/
object CashuMaintenanceCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
name = "cashu maintenance",
tail = tail,
usage = "cashu maintenance <scrub|restore|migrate-keysets>",
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<String>,
): 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<Map<String, Any?>>()
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<String>,
): 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<String>,
): 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<Map<String, Any?>>()
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
}
}
@@ -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 <ping|info> 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<String>): Int =
route(
name = "cashu mint",
tail = tail,
usage = "cashu mint <ping|info> 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<String>): 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<String>): 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)
}
}
}
@@ -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 <show|add|remove>` — NIP-87 mint recommendations
* (kind:38000), on the shared commons CashuWalletOps.
*/
object CashuMintRecCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
name = "cashu mint-rec",
tail = tail,
usage = "cashu mint-rec <show|add|remove>",
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<String>,
): 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<Event>(filter).isEmpty()) ctx.drain(ctx.bootstrapRelays().associateWith { listOf(filter) })
ctx.store
.query<Event>(filter)
.filterIsInstance<MintRecommendationEvent>()
.sortedByDescending { it.createdAt }
}
Output.emit(mapOf("recommendations" to recs.map { render(it) }))
}
return 0
}
private suspend fun add(
dataDir: DataDir,
rest: Array<String>,
): 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<String>,
): 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<Event>(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
}
}
@@ -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 <ln|complete|resume|token|nutzap-sweep>` — inbound flows,
* all on the shared commons CashuWalletOps the Android wallet runs.
*/
object CashuReceiveCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
name = "cashu receive",
tail = tail,
usage = "cashu receive <ln|complete|resume|token|nutzap-sweep>",
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>,
): String? = flag?.trimEnd('/') ?: mints.firstOrNull()
private suspend fun ln(
dataDir: DataDir,
rest: Array<String>,
): 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<String>,
): 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<String>,
): 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<String>,
): 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<Map<String, Any?>>()
val skipped = mutableListOf<Map<String, Any?>>()
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"
}
@@ -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 <ln|token|nutzap>` — 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<String>,
): Int =
route(
name = "cashu send",
tail = tail,
usage = "cashu send <ln|token|nutzap>",
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>,
): String? = flag?.trimEnd('/') ?: mints.firstOrNull()
private suspend fun ln(
dataDir: DataDir,
rest: Array<String>,
): 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<String>,
): 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<String>,
): 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<Event>(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<Event>(filter).firstOrNull() as? NutzapInfoEvent)?.let { return it }
ctx.drain(ctx.bootstrapRelays().associateWith { listOf(filter) })
return ctx.store.query<Event>(filter).firstOrNull() as? NutzapInfoEvent
}
private fun describe(e: Throwable): String = e.message ?: e::class.simpleName ?: "error"
}
@@ -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|show|export-key|destroy>`.
*
* create [--mint URL] [--mints a,b] [--privkey HEX] [--relay r1,r2]
* show
* export-key
* destroy
*/
object CashuWalletCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
name = "cashu wallet",
tail = tail,
usage = "cashu wallet <create|show|export-key|destroy>",
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<String> =
flag(key)
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
private suspend fun create(
dataDir: DataDir,
rest: Array<String>,
): 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<String>,
): 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<String>,
): 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<String>,
): 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
}
}
@@ -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<String>,
val connectSecret: String? = null,
)
@@ -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/<account>/cashu.json` as `{ "keyset_counters": { "<id>": <long> } }`.
*
* [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<String, Long> = mutableMapOf(),
)
private fun load(): Persisted =
if (file.exists()) {
runCatching { mapper.readValue<Persisted>(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
}
}
@@ -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/<account>/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
}
@@ -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<String>,
val tokenEntries: List<TokenEntry>,
val history: List<CashuSpendingHistoryEvent>,
val pendingQuotes: List<CashuMintQuoteEvent>,
val nutzapEvents: List<NutzapEvent>,
val recommendations: List<MintRecommendationEvent>,
) {
/** 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<String, Long>
get() =
tokenEntries
.groupBy { it.content.mint }
.mapValues { (_, byMint) -> byMint.sumOf { it.content.totalAmount() } }
}
suspend fun project(events: Iterable<Event>): WalletSnapshot {
var walletEvent: CashuWalletEvent? = null
var nutzapInfoEvent: NutzapInfoEvent? = null
val tokenEvents = LinkedHashMap<HexKey, CashuTokenEvent>()
val historyEvents = LinkedHashMap<HexKey, CashuSpendingHistoryEvent>()
val quoteEvents = LinkedHashMap<HexKey, CashuMintQuoteEvent>()
val nutzapEvents = LinkedHashMap<HexKey, NutzapEvent>()
val recommendationEvents = LinkedHashMap<String, MintRecommendationEvent>()
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<CashuTokenEvent>): Map<HexKey, TokenContent> {
val out = HashMap<HexKey, TokenContent>()
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<CashuTokenEvent>,
contents: Map<HexKey, TokenContent>,
): List<TokenEntry> {
val all = events.toList()
val deletedIds = mutableSetOf<HexKey>()
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<CashuMintQuoteEvent>,
history: Iterable<CashuSpendingHistoryEvent>,
now: Long = TimeUtils.now(),
): List<CashuMintQuoteEvent> {
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 }
}
}
}
@@ -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<CashuProof>,
): Map<String, ProofState> = 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<TokenEntry>,
): List<CashuTokenEvent> {
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
@@ -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
@@ -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<Int, KindName> =
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<Pair<Int, KindName>> {
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 }
}
}
@@ -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"
}
}
@@ -75,6 +75,14 @@ object BunkerResponseKSerializer : KSerializer<BunkerResponse> {
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":"<url>"}`. 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)
}
@@ -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 =
@@ -41,6 +41,13 @@ class RemoteSignerManager(
val remoteKey: String,
val relayList: Set<NormalizedRelayUrl>,
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<String, Channel<BunkerResponse>>()
@@ -48,7 +55,10 @@ class RemoteSignerManager(
val decryptedJson = signer.decrypt(responseEvent.content, remoteKey)
val bunkerResponse = OptimizedJsonMapper.fromJsonTo<BunkerResponse>(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<BunkerResponse>(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<BunkerResponse>(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()
@@ -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<BunkerResponse>(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<String>()
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<SignerResult.RequestAddressed.Successful<PingResult>>(result)
assertEquals(listOf("https://signer.example/auth"), seenUrls)
}
@Test
fun timeoutReturnsTimedOutAfterMaxRetries() =
runTest {
@@ -44,6 +44,14 @@ class BunkerResponseDeserializer : StdDeserializer<BunkerResponse>(BunkerRespons
val result = jsonObject.get("result")?.asText()
val error = jsonObject.get("error")?.asText()
// NIP-46 auth challenge: `{"result":"auth_url","error":"<url>"}`. 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)
}