mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge remote-tracking branch 'origin/main' into claude/awesome-pasteur-xwiwad
This commit is contained in:
+3
-1
@@ -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/`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
+22
-64
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+3
-4
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
+2
-1
@@ -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 ""
|
||||
|
||||
+2
-1
@@ -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 -> {
|
||||
|
||||
+3
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+57
@@ -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) },
|
||||
),
|
||||
)
|
||||
}
|
||||
+134
@@ -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
|
||||
}
|
||||
}
|
||||
+82
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+117
@@ -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
|
||||
}
|
||||
}
|
||||
+198
@@ -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"
|
||||
}
|
||||
+202
@@ -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"
|
||||
}
|
||||
+148
@@ -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,
|
||||
)
|
||||
|
||||
+70
@@ -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
|
||||
}
|
||||
}
|
||||
+50
@@ -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
|
||||
}
|
||||
+190
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-1
@@ -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
|
||||
+1
-1
@@ -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 }
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -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)
|
||||
}
|
||||
|
||||
+7
@@ -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 =
|
||||
|
||||
+32
-3
@@ -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()
|
||||
|
||||
+57
@@ -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 {
|
||||
|
||||
+8
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user