Merge pull request #3052 from vitorpamplona/claude/brave-clarke-hJ0PK

onchain zaps + nip-05 filter when returning users to Gemini
This commit is contained in:
Vitor Pamplona
2026-05-26 17:22:25 -04:00
committed by GitHub
2 changed files with 405 additions and 34 deletions
@@ -45,6 +45,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
@@ -60,6 +61,9 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withTimeoutOrNull
/**
@@ -108,6 +112,14 @@ class AmethystAppFunctions {
* configured search relays (kind:10007), with a fallback to
* Amethyst's curated default search-relay set.
*
* Results carrying a NIP-05 claim are verified in parallel and dropped
* when the claim explicitly fails — the listed domain doesn't sign for
* that pubkey, or returns a different one. Profiles with no NIP-05 at
* all are kept (no claim, nothing to refute). This is the
* anti-impersonation guard for the downstream write verbs: if Gemini
* asks the user "do you mean alice@damus.io?" the user should be able
* to trust that the npub actually controls that handle.
*
* @param query free-form search text (display name, NIP-05 handle, etc.)
* @param limit max number of profiles to return — capped to 50.
*/
@@ -145,17 +157,60 @@ class AmethystAppFunctions {
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
)
val hits =
val candidates =
events
.mapNotNull { it as? MetadataEvent }
.distinctBy { it.pubKey }
.sortedByDescending { it.createdAt }
.take(cappedLimit)
.map { it.toProfileHit() }
// Verify NIP-05 claims in parallel with a bounded timeout, then
// drop only the ones that explicitly fail (the domain returns a
// different pubkey, or no entry for the local part). Network
// errors / DNS failures / timeouts are inconclusive — keep those
// so a flaky .well-known doesn't censor legitimate users.
val verified =
withTimeoutOrNull(NIP05_FILTER_TIMEOUT_MS) {
coroutineScope {
candidates
.map { meta ->
async { meta to nip05IsExplicitlyFailed(meta) }
}.awaitAll()
}
} ?: candidates.map { it to false }
val hits =
verified
.filterNot { (_, failed) -> failed }
.map { (meta, _) -> meta.toProfileHit() }
return SearchProfilesResult(matches = hits)
}
/**
* Returns true only when the recipient's kind:0 carries a NIP-05 claim
* that the listed domain actively refuses to sign for. Returns false
* for "no claim", "unparseable claim", "network error", "namecoin
* unreachable" — those are inconclusive, not refutations.
*/
private suspend fun nip05IsExplicitlyFailed(meta: MetadataEvent): Boolean {
val claim =
meta
.contactMetaData()
?.nip05
?.trim()
.orEmpty()
if (claim.isEmpty()) return false
val parsed = Nip05Id.parse(claim) ?: return false
return runCatching {
!Amethyst.instance.nip05Client.verify(parsed, meta.pubKey)
}.getOrElse {
// verify() throws IllegalStateException on network / parse
// errors — treat as inconclusive rather than a failure.
false
}
}
/**
* Read the user's Nostr timeline / home feed. Use when the user asks
* "what's new on Nostr", "what's happening on Nostr today", "catch me
@@ -1013,12 +1068,26 @@ class AmethystAppFunctions {
* and publishes the updated list. [WriteResult.changed] reports
* `false` when the user is already followed.
*
* **BEFORE INVOKING:** the agent MUST resolve [user] via
* [searchProfiles] / [getProfile] first and present the recipient
* to the human with all three identity signals — display name,
* npub, and NIP-05 handle — and wait for explicit confirmation
* ("yes, follow Vitor (vitor@vitorpamplona.com, npub1…)"). Nostr
* has no global namespace, so multiple users can share a display
* name; the NIP-05 + npub disambiguates.
*
* @param user npub (`npub1…`) or 64-character hex pubkey.
* @param expectedDisplayName when set, the verb cross-checks that
* the resolved profile's display name / NIP-05 contains (or is
* contained by) this string. Pass the same name you showed the
* user during the confirmation prompt — a mismatch aborts the
* write with a typed error so the agent can re-prompt.
*/
@AppFunction(isDescribedByKDoc = true)
suspend fun followUser(
appFunctionContext: AppFunctionContext,
user: String,
expectedDisplayName: String? = null,
): WriteResult {
val target = decodeUserOrThrow(user)
val account = Amethyst.instance.sessionManager.loggedInAccount() ?: throw notSignedIn()
@@ -1026,6 +1095,7 @@ class AmethystAppFunctions {
throw AppFunctionInvalidArgumentException("cannot follow yourself")
}
requireInProcessSigner(account.signer)
verifyExpectedRecipient(account, target, expectedDisplayName, requireFollow = false)
val relays = account.outboxRelays.flow.value
if (relays.isEmpty()) throw AppFunctionInvalidArgumentException("account has no outbox relays configured")
@@ -1105,15 +1175,38 @@ class AmethystAppFunctions {
* DM-inbox list fall back through NIP-65 read relays then
* bootstrap relays.
*
* **BEFORE INVOKING:** the agent MUST resolve [recipient] via
* [searchProfiles] / [getProfile] first and confirm with the user
* using all three identity signals — display name, npub, and
* NIP-05 handle ("DM Alice (alice@damus.io, npub1…)?"). Nostr has
* no global namespace and impersonation is trivial; the npub +
* NIP-05 are the only cryptographic disambiguators.
*
* By default, the verb refuses to message anyone not in the user's
* follow list — a near-zero-cost guard against same-name
* impersonators. Pass `requireFollow = false` only after the user
* explicitly approves messaging a stranger.
*
* @param recipient npub (`npub1…`) or 64-character hex pubkey.
* @param text the message body. Cannot be blank; capped at 8000
* characters.
* @param expectedDisplayName when set, the verb cross-checks that
* the resolved profile's display name / NIP-05 contains (or is
* contained by) this string. Pass the same name you showed the
* user during the confirmation prompt — a mismatch aborts the
* send with a typed error.
* @param requireFollow when true (the default), the verb refuses
* to send to a pubkey the user doesn't already follow on Nostr.
* Override to false only when the user explicitly confirmed they
* want to DM a stranger.
*/
@AppFunction(isDescribedByKDoc = true)
suspend fun sendDm(
appFunctionContext: AppFunctionContext,
recipient: String,
text: String,
expectedDisplayName: String? = null,
requireFollow: Boolean = true,
): SendDmResult {
val body = text.trim()
if (body.isEmpty()) throw AppFunctionInvalidArgumentException("text cannot be blank")
@@ -1123,6 +1216,7 @@ class AmethystAppFunctions {
val recipientPub = decodeUserOrThrow(recipient)
val account = Amethyst.instance.sessionManager.loggedInAccount() ?: throw notSignedIn()
requireInProcessSigner(account.signer)
verifyExpectedRecipient(account, recipientPub, expectedDisplayName, requireFollow)
val client = Amethyst.instance.client
val result = DmActions.buildTextDm(account.signer, recipientPub, body)
@@ -1179,19 +1273,43 @@ class AmethystAppFunctions {
}
/**
* Tip a Nostr user with Lightning sats (NIP-57 profile zap). Use
* when the user asks "zap [X] on Nostr", "tip [user] [N] sats",
* "send a Lightning tip to [npub]", or "thank [user] with sats".
* Tip a Nostr user with Bitcoin sats. Use when the user asks "zap
* [X] on Nostr", "tip [user] [N] sats", "send a Lightning tip to
* [npub]", "send [N] sats onchain to [user]", "send Alice
* [N] sats via Bitcoin", or "thank [user] with sats".
*
* Builds the NIP-57 kind:9734 zap request, fetches a BOLT11
* invoice from the recipient's Lightning service, and — when the
* user has a Nostr Wallet Connect wallet configured in Amethyst —
* pays the invoice automatically over NIP-47. Falls back to
* returning the invoice for manual payment when no NWC wallet is
* set up.
* Two rails are supported:
*
* Defaults to 21 sats — the canonical "small thank-you" zap. Cap
* is 1,000,000 sats so an accidental tip can't drain a wallet.
* * **Lightning** (default) — NIP-57 zap. Builds the kind:9734
* request, fetches a BOLT11 invoice from the recipient's
* Lightning service, and — when the user has a Nostr Wallet
* Connect wallet configured — pays automatically over NIP-47.
* Falls back to returning the invoice for manual payment.
*
* * **Onchain** (NIP-BC kind:8333) — when [chain] is "onchain",
* builds and broadcasts a Bitcoin transaction paying the
* recipient's derived Taproot address, then publishes a
* kind:8333 zap receipt. Requires the user to have a Bitcoin
* chain backend configured in Amethyst (an Esplora-compatible
* API like mempool.space — see Settings → Bitcoin).
*
* Defaults to 21 sats Lightning. Cap is 1,000,000 sats so an
* accidental tip can't drain a wallet.
*
* **BEFORE INVOKING:** zaps move real money, so the agent MUST
* resolve [user] via [searchProfiles] / [getProfile] first and
* confirm with the human using all three identity signals —
* display name, npub, and NIP-05 handle ("Zap Alice
* (alice@damus.io, npub1…) 21 sats?"). Nostr has no global
* namespace; a same-name impersonator is the most likely way for
* a zap to go to the wrong person.
*
* By default, the verb refuses to zap anyone not in the user's
* follow list. This is the strongest guard against same-name
* impersonators — even if Gemini picked the wrong Alice, the user
* almost certainly isn't following her. Pass `requireFollow =
* false` only after the user explicitly approves zapping a
* stranger (e.g. a public-figure npub they read out themselves).
*
* @param user npub (`npub1…`) or 64-character hex pubkey of the
* zap recipient.
@@ -1199,6 +1317,21 @@ class AmethystAppFunctions {
* sats. Default 21.
* @param comment optional message to attach to the zap. Capped at
* 280 characters.
* @param chain transport rail: "lightning" (default) or "onchain".
* Null / blank is treated as Lightning.
* @param feeRateSatPerVByte miner fee rate for onchain zaps in
* sats per virtual byte. Ignored for Lightning. Default 5.0,
* which targets fast confirmation under typical mempool
* conditions without being aggressive.
* @param expectedDisplayName when set, the verb cross-checks that
* the resolved profile's display name / NIP-05 contains (or is
* contained by) this string. Pass the same name you showed the
* user during the confirmation prompt — a mismatch aborts the
* zap before any money moves.
* @param requireFollow when true (the default), the verb refuses
* to zap a pubkey the user doesn't already follow on Nostr.
* Override to false only when the user explicitly confirmed
* they want to zap a stranger.
*/
@AppFunction(isDescribedByKDoc = true)
suspend fun zapUser(
@@ -1206,12 +1339,36 @@ class AmethystAppFunctions {
user: String,
sats: Long = 21,
comment: String? = null,
chain: String? = null,
feeRateSatPerVByte: Double = 5.0,
expectedDisplayName: String? = null,
requireFollow: Boolean = true,
): ZapResult {
val cappedSats = sats.coerceIn(1L, MAX_ZAP_SATS)
val trimmedComment = comment.orEmpty().trim().take(MAX_ZAP_COMMENT_LENGTH)
val recipientPub = decodeUserOrThrow(user)
val account = Amethyst.instance.sessionManager.loggedInAccount() ?: throw notSignedIn()
requireInProcessSigner(account.signer)
verifyExpectedRecipient(account, recipientPub, expectedDisplayName, requireFollow)
val rail = chain?.trim()?.lowercase()?.takeIf { it.isNotEmpty() } ?: "lightning"
return when (rail) {
"lightning", "ln" -> zapUserViaLightning(account, recipientPub, cappedSats, trimmedComment)
"onchain", "btc", "bitcoin" ->
zapUserViaOnchain(account, recipientPub, cappedSats, trimmedComment, feeRateSatPerVByte)
else ->
throw AppFunctionInvalidArgumentException(
"chain must be \"lightning\" or \"onchain\" (got \"$chain\").",
)
}
}
private suspend fun zapUserViaLightning(
account: com.vitorpamplona.amethyst.model.Account,
recipientPub: HexKey,
sats: Long,
comment: String,
): ZapResult {
val client = Amethyst.instance.client
// Pull the recipient's kind:0 — needs lnAddress to receive the zap.
@@ -1225,25 +1382,26 @@ class AmethystAppFunctions {
?.let { extractLnAddressFromMetadata(it) }
?: fetchProfileForZap(client, account, recipientPub)
?: throw AppFunctionInvalidArgumentException(
"No kind:0 metadata for $user — recipient must have a Nostr profile first.",
"No kind:0 metadata for ${NPub.create(recipientPub)} — recipient must have a Nostr profile first.",
)
val lnAddress =
metadata.takeIf { it.isNotBlank() }
?: throw AppFunctionInvalidArgumentException(
"Recipient has no lud16 or lud06 in their profile — they can't receive Lightning zaps.",
"Recipient has no lud16 or lud06 in their profile — they can't receive Lightning zaps. " +
"Try chain=\"onchain\" instead if they have NIP-BC enabled on their account.",
)
val zapRequest =
ZapActions.buildUserZapRequest(
signer = account.signer,
recipientPubkey = recipientPub,
amountMillisats = ZapActions.satsToMillisats(cappedSats),
amountMillisats = ZapActions.satsToMillisats(sats),
inboxRelays = account.nip65RelayList.inboxFlow.value,
comment = trimmedComment,
comment = comment,
zapType = LnZapEvent.ZapType.PUBLIC,
)
val invoice = fetchInvoiceOrThrow(lnAddress, cappedSats, trimmedComment, zapRequest)
val invoice = fetchInvoiceOrThrow(lnAddress, sats, comment, zapRequest)
// If the user has a Nostr Wallet Connect wallet configured, pay
// the invoice automatically over NIP-47 so Gemini can answer
@@ -1253,21 +1411,125 @@ class AmethystAppFunctions {
val nwc = payViaNwcOrNull(account, invoice, null)
return ZapResult(
chain = "lightning",
recipientNpub = NPub.create(recipientPub),
recipientPubkeyHex = recipientPub,
recipientDisplayName = displayNameOf(recipientPub),
lnAddress = lnAddress,
amountSats = cappedSats,
comment = trimmedComment,
amountSats = sats,
comment = comment,
invoice = invoice,
zapRequestId = zapRequest.id,
nwcAttempted = nwc != null,
nwcPaid = nwc?.success == true,
nwcPreimage = nwc?.preimage,
nwcError = nwc?.errorMessage,
onchainTxid = null,
onchainFeeSats = null,
onchainChangeSats = null,
onchainReceiptEventId = null,
onchainError = null,
onchainStage = null,
)
}
private suspend fun zapUserViaOnchain(
account: com.vitorpamplona.amethyst.model.Account,
recipientPub: HexKey,
sats: Long,
comment: String,
feeRateSatPerVByte: Double,
): ZapResult {
// The Account.sendOnchainZap call returns Failure when the
// backend isn't configured; we surface that as a typed
// NotSupported error so the LLM can tell the user "you need to
// set up a Bitcoin backend in Amethyst Settings → Bitcoin."
if (account.cache.onchainBackend == null) {
throw AppFunctionNotSupportedException(
"Amethyst has no Bitcoin chain backend configured. " +
"Open Settings → Bitcoin and add an Esplora-compatible endpoint (e.g. mempool.space) " +
"before sending onchain zaps.",
)
}
if (feeRateSatPerVByte < 0.1) {
throw AppFunctionInvalidArgumentException(
"feeRateSatPerVByte must be at least 0.1 (got $feeRateSatPerVByte).",
)
}
if (feeRateSatPerVByte > 1000) {
throw AppFunctionInvalidArgumentException(
"feeRateSatPerVByte must be ≤ 1000 (got $feeRateSatPerVByte) — anything higher is almost certainly a mistake.",
)
}
val result =
account.sendOnchainZap(
recipientPubKey = recipientPub,
amountSats = sats,
feeRateSatPerVByte = feeRateSatPerVByte,
comment = comment,
// No zappedEvent: this is a profile zap. Event zaps via
// onchain would need a separate verb that takes an
// event id (and resolves splits) — deferred.
zappedEvent = null,
)
return when (result) {
is com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult.Success ->
ZapResult(
chain = "onchain",
recipientNpub = NPub.create(recipientPub),
recipientPubkeyHex = recipientPub,
recipientDisplayName = displayNameOf(recipientPub),
// Onchain doesn't go through an LN service.
lnAddress = "",
amountSats = sats,
comment = comment,
// Onchain has no BOLT11; pre-fill empty to satisfy
// the non-null result-class contract.
invoice = "",
zapRequestId = "",
nwcAttempted = false,
nwcPaid = false,
nwcPreimage = null,
nwcError = null,
onchainTxid = result.txid,
onchainFeeSats = result.feeSats,
onchainChangeSats = result.changeSats,
onchainReceiptEventId = result.receiptEventId,
onchainError = null,
onchainStage = null,
)
is com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult.Failure ->
ZapResult(
chain = "onchain",
recipientNpub = NPub.create(recipientPub),
recipientPubkeyHex = recipientPub,
recipientDisplayName = displayNameOf(recipientPub),
lnAddress = "",
amountSats = sats,
comment = comment,
invoice = "",
zapRequestId = "",
nwcAttempted = false,
nwcPaid = false,
nwcPreimage = null,
nwcError = null,
// When stage == PUBLISHING, the tx WAS broadcast
// but the receipt couldn't be published — surface
// the txid even on failure so the user can verify
// on a block explorer.
onchainTxid = result.broadcastTxid,
onchainFeeSats = null,
onchainChangeSats = null,
onchainReceiptEventId = null,
onchainError = result.message,
onchainStage = result.stage.name.lowercase(),
)
}
}
/**
* Zap a specific Nostr note (NIP-57 event zap). Use when the user
* asks "zap this Nostr post", "tip the author of [event id]",
@@ -1542,6 +1804,88 @@ class AmethystAppFunctions {
}
}
/**
* Anti-impersonation guard for write verbs. Two checks, both
* defensive against an agent that misroutes "DM Alice" to the wrong
* Alice:
*
* 1. If [expectedDisplayName] is provided, verify it loosely
* matches the cached display name, name, or NIP-05 handle of
* [target]. The match is case-insensitive and bidirectional
* ("vitor" matches "Vitor Pamplona" and vice-versa) so the
* agent can pass whatever the user said without having to
* reconstruct the exact metadata string.
*
* 2. If [requireFollow] is true, verify the target is in the
* active account's kind:3 follow list. This is the strongest
* lever against wrong-recipient writes: even if Gemini picked
* a same-name impersonator, the user is overwhelmingly
* unlikely to be following them. Caller can pass
* `requireFollow = false` to override (e.g. zapping a stranger
* the user explicitly named by npub).
*
* Throws [AppFunctionInvalidArgumentException] with the npub +
* cached NIP-05 + display name on mismatch so the agent can render
* a clarifying prompt to the user.
*/
private fun verifyExpectedRecipient(
account: com.vitorpamplona.amethyst.model.Account,
target: HexKey,
expectedDisplayName: String?,
requireFollow: Boolean,
) {
val user = account.cache.checkGetOrCreateUser(target)
val meta =
user
?.metadataOrNull()
?.flow
?.value
?.info
val cachedName = meta?.bestName()
val cachedNip05 = meta?.nip05?.trim()?.takeIf { it.isNotEmpty() }
val npub = NPub.create(target)
val expected = expectedDisplayName?.trim()?.takeIf { it.isNotEmpty() }
if (expected != null) {
val normalized = expected.lowercase()
val candidates =
listOfNotNull(
cachedName?.lowercase(),
meta
?.displayName
?.trim()
?.lowercase()
?.takeIf { it.isNotEmpty() },
meta
?.name
?.trim()
?.lowercase()
?.takeIf { it.isNotEmpty() },
cachedNip05?.lowercase(),
cachedNip05?.substringBefore('@')?.lowercase()?.takeIf { it.isNotEmpty() },
)
val matches =
candidates.any { it == normalized || it.contains(normalized) || normalized.contains(it) }
if (!matches) {
throw AppFunctionInvalidArgumentException(
"Recipient mismatch: expectedDisplayName=\"$expected\" doesn't match the resolved " +
"Nostr profile (npub=$npub, displayName=${cachedName ?: "<unknown>"}, " +
"nip05=${cachedNip05 ?: "<none>"}). Ask the user to confirm with the agent " +
"before retrying — there may be multiple users with the same name on Nostr.",
)
}
}
if (requireFollow && target !in account.kind3FollowList.flow.value.authors) {
throw AppFunctionInvalidArgumentException(
"Recipient is not in the user's Nostr follow list (npub=$npub, " +
"displayName=${cachedName ?: "<unknown>"}, nip05=${cachedNip05 ?: "<none>"}). " +
"Ask the user to confirm they want to act on this stranger, then retry with " +
"requireFollow=false. This is a guard against acting on a same-name impersonator.",
)
}
}
/**
* Reject the call when the active signer can't sign in-process —
* NIP-55 external signers (Amber) need a foreground activity to
@@ -1790,6 +2134,14 @@ class AmethystAppFunctions {
/** Cap on the number of mentioned users surfaced in a feed
* digest. Same rationale as TOP_HASHTAGS_LIMIT. */
private const val TOP_MENTIONS_LIMIT = 10
/** Overall budget for the NIP-05 verification pass inside
* [searchProfiles]. We fan out one HTTP request per result so
* the wall-clock is dominated by the slowest .well-known.
* 4s is short enough to keep search snappy and long enough to
* catch most genuine refutations. On timeout we keep all
* candidates rather than censoring them. */
private const val NIP05_FILTER_TIMEOUT_MS = 4_000L
}
}
@@ -2192,34 +2544,35 @@ class SendDmResult(
)
/**
* Result of [AmethystAppFunctions.zapUser]. Always carries the BOLT11
* invoice so the caller can fall back to manual payment; when the
* user has a Nostr Wallet Connect (NIP-47) wallet configured, the
* verb also attempts to pay the invoice via that wallet and the
* `nwc*` fields report the outcome.
* Result of [AmethystAppFunctions.zapUser]. Carries either the
* Lightning BOLT11 invoice (+ NWC payment outcome when configured) or
* the onchain Bitcoin broadcast result, depending on the rail used.
* The [chain] field tells callers which set of fields to read.
*/
@AppFunctionSerializable(isDescribedByKDoc = true)
class ZapResult(
/** Transport rail actually used: "lightning" or "onchain". */
val chain: String,
/** Bech32 npub of the zap recipient. */
val recipientNpub: String,
/** Hex pubkey of the recipient. */
val recipientPubkeyHex: String,
/** Best-effort display name from the local kind:0 cache. */
val recipientDisplayName: String?,
/** LN address the invoice was fetched from. */
/** LN address the invoice was fetched from. Empty for onchain zaps. */
val lnAddress: String,
/** Amount actually requested (after capping). */
/** Amount actually billed (after capping). For onchain, the
* miner fee is reported separately in [onchainFeeSats]. */
val amountSats: Long,
/** Comment attached to the zap (truncated to 280 chars). */
val comment: String,
/** BOLT11 invoice — always set. Pay this manually if [nwcPaid]
* is false. */
/** BOLT11 invoice — populated for Lightning, empty for onchain.
* Pay manually if [nwcPaid] is false. */
val invoice: String,
/** Hex event id of the signed kind:9734 zap request. */
/** Hex event id of the signed kind:9734 zap request. Empty for onchain. */
val zapRequestId: String,
/** True when the user has NWC configured and we tried to auto-pay.
* False means the caller should surface the invoice for manual
* payment. */
* Always false for onchain. */
val nwcAttempted: Boolean,
/** True only when an NWC wallet confirmed the payment. */
val nwcPaid: Boolean,
@@ -2227,6 +2580,24 @@ class ZapResult(
val nwcPreimage: String?,
/** Failure reason when [nwcAttempted] is true but [nwcPaid] is false. */
val nwcError: String?,
/** Bitcoin transaction id on a successful onchain zap, or — when
* the tx was broadcast but the receipt failed to publish — the
* txid so the user can verify the payment manually. Null for
* Lightning zaps. */
val onchainTxid: String?,
/** Miner fee paid in sats on a successful onchain zap. */
val onchainFeeSats: Long?,
/** Change returned to the sender's address on an onchain zap. */
val onchainChangeSats: Long?,
/** Hex event id of the kind:8333 onchain zap receipt. */
val onchainReceiptEventId: String?,
/** Failure reason when an onchain zap failed. */
val onchainError: String?,
/** Stage at which an onchain zap failed: "loading_utxos",
* "building", "signing", "broadcasting", or "publishing"
* (last means the tx is on-chain but the receipt didn't land
* on relays — the payment is still real). */
val onchainStage: String?,
)
/**
+2 -2
View File
@@ -12,5 +12,5 @@
the @AppFunction surface grows.
-->
<AppFunctionAppMetadata xmlns:appfn="http://schemas.android.com/apk/res-auto"
appfn:description="Amethyst is a Nostr social client. The agent can read: search Nostr profiles, notes, hashtags, and long-form articles; look up any user's profile by npub; read recent posts from the signed-in user's follows or any specific user; summarize the feed for a time window (with top hashtags, top mentions, counts pre-extracted for AI digestion); read recent direct messages (decrypted); list NIP-57 zaps received and total sats earned in a time window; surface notes that mention or reply to the user; list currently-live audio/video streams; and report basic account info. The agent can also write: publish short text notes, follow or unfollow other users, send NIP-17 gift-wrapped direct messages, and zap users or specific notes via Lightning (with NWC auto-pay when configured) — provided the user is signed in with a local key or NIP-46 bunker. NIP-55 external signers (Amber) are read-only from the agent for now."
appfn:displayDescription="Read, write, summarize, and zap Nostr through Amethyst" />
appfn:description="Amethyst is a Nostr social client. The agent can read: search Nostr profiles, notes, hashtags, and long-form articles; look up any user's profile by npub; read recent posts from the signed-in user's follows or any specific user; summarize the feed for a time window (with top hashtags, top mentions, counts pre-extracted for AI digestion); read recent direct messages (decrypted); list NIP-57 zaps received and total sats earned in a time window; surface notes that mention or reply to the user; list currently-live audio/video streams; and report basic account info. The agent can also write: publish short text notes, follow or unfollow other users, send NIP-17 gift-wrapped direct messages, and zap users via Lightning or onchain Bitcoin (NIP-BC) — Lightning zaps auto-pay over NIP-47 when NWC is configured; onchain zaps broadcast a real Bitcoin transaction when a chain backend is set up. Specific notes can also be zapped via Lightning with NIP-57 zap splits honored. Sign-in required with a local key or NIP-46 bunker; NIP-55 external signers (Amber) are read-only from the agent for now."
appfn:displayDescription="Read, write, summarize, and zap Nostr (Lightning + onchain) through Amethyst" />