feat(amethyst): zap verbs + LLM-friendly kdocs + Gemini discovery plan

Three deliverables:

1) Two new write verbs:
   * zapUser(user, sats, comment?) — builds the NIP-57 kind:9734
     profile zap and fetches a BOLT11 invoice from the recipient's
     Lightning service. Returns the invoice — caller pastes into a
     Lightning wallet (no NWC auto-pay yet). 21 sats default,
     1M sats cap, 280-char comment cap.
   * zapEvent(eventId, sats, comment?) — same but for a specific
     note, with full NIP-57 zap-split support via
     ZapActions.buildEventZapRequestsForSplits. Returns one invoice
     per recipient when the post carries `zap` tags.

   Total verb count: 21 (8 read for feeds/profiles, 3 read for
   identity / followers, 4 read for inbox/zaps/streams, 4 write
   for note/follow/unfollow/dm, 2 write for zaps).

2) Reworked every verb's kdoc first sentence into an LLM-friendly
   "use when..." trigger phrase. Gemini's tool picker matches user
   queries against the descriptions (we generate them via
   @AppFunction(isDescribedByKDoc = true)) — phrasing like "Find a
   person on Nostr by name. Use when the user wants to look someone
   up..." gives the model concrete prompts to recognise instead of
   internal NIP names.

   Affected: searchProfiles, getRecentFromFollows, getNotesByUser,
   getProfile, searchByHashtag, getActiveAccountInfo, getRecentDms,
   getZapsReceived, postNote, followUser, unfollowUser, sendDm,
   zapUser, zapEvent.

3) amethyst/plans/2026-05-26-appfunctions-gemini-discovery.md —
   verification protocol for testing on-device whether Gemini's
   tool picker actually surfaces our verbs from natural-language
   prompts. Includes specific test prompts mapped to expected
   verbs, fallback diagnostics (clear AppSearch + restart), and
   the conditions under which it'd be worth defining our own
   @AppFunctionSchemaDefinition namespace.

Plus minor: comment parameters switched to nullable (String? = null)
because KSP rejects non-nullable types with defaults.
This commit is contained in:
Claude
2026-05-26 00:01:13 +00:00
parent 8e1b31a550
commit 5efb5d90e5
2 changed files with 584 additions and 54 deletions
@@ -0,0 +1,131 @@
# Verifying Gemini-side AppFunctions discovery
**Date:** 2026-05-26
**Status:** Active — answers the open question from
`2026-05-25-appfunctions-signer-prompts.md`
The Phase 2 work proves the app side: 21 `@AppFunction` verbs are
registered, indexed by `AppFunctionManagerService`, and dispatchable
via `adb shell cmd app_function execute-app-function`. The remaining
unknown is whether **Gemini's chat UI** actually surfaces our verbs to
the user — that's a separate layer (model-side tool picker) we can't
exercise from the test command.
## What we know
* **Library state.** Built against `androidx.appfunctions
1.0.0-alpha09`. Schemas (`@AppFunctionSchemaDefinition`) are
optional and the official Google sample (`android/appfunctions`
ChatApp) doesn't use them — meaning we're not at a structural
disadvantage by not defining our own. There's no canonical
`nostr.social` schema registry yet.
* **Discovery strategy.** Without schemas, Gemini's tool picker
matches on the function's natural-language description (KDoc, via
`@AppFunction(isDescribedByKDoc = true)`) and the parameter
descriptions. We've reworked every verb's first sentence to be a
use-when imperative — "Find a person on Nostr by name…" — instead
of an implementation description ("Searches kind:0 metadata…").
## What we don't know yet
* Whether Gemini's model picks up our verbs at all from a typical
user query.
* Whether Gemini's `AppFunctionSearchSpec` filters by
`schemaCategory` / `schemaName` (in which case we're invisible
until we annotate) or by description (in which case we should
surface).
* What feature flags / Gemini-app versions are required. App
Functions is generally available on Android 16+, but Gemini's
third-party tool picker has shipped in waves.
## Verification protocol
### 1. Confirm the device is set up
```bash
# Pixel 8 or newer on Android 16 QPR1+
adb shell getprop ro.build.version.release
adb shell pm list packages | grep -i gemini # com.google.android.apps.bard
```
### 2. Reinstall the Play debug APK with the new descriptions
```bash
./gradlew :amethyst:assemblePlayDebug
adb install -r amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk
adb shell am start -n com.vitorpamplona.amethyst.debug/com.vitorpamplona.amethyst.ui.MainActivity
# sign in if needed, give Amethyst a few seconds to register
```
### 3. Confirm metadata is indexed end-to-end
```bash
adb shell cmd app_function list-app-functions | grep -c amethyst
# should print ≥ 21 — one entry per @AppFunction across our class
```
### 4. Test prompts in Gemini
These are deliberately mapped to one specific verb each. Run them in
order, take notes on which surface a tool call and which don't.
| Prompt to Gemini | Should pick |
|---|---|
| "Find vitorpamplona on Nostr" | searchProfiles |
| "What's happening on Nostr today?" | getRecentFromFollows |
| "Who am I logged in as on Nostr?" | getActiveAccountInfo |
| "Did anyone DM me on Nostr recently?" | getRecentDms |
| "How many sats did I earn on Nostr this week?" | getZapsReceived |
| "Show me Nostr posts about bitcoin" | searchByHashtag |
| "Tell me about npub1xq5eqwlhxy3ldakahsfglccvzy4j6ayyxje5a92zu90hc05dxn7qrsns90" | getProfile |
| "What are people I follow saying on Nostr?" | getRecentFromFollows |
| "Catch me up on what Snowden's been posting" | getNotesByUser |
For each: did Gemini offer to call the tool? Did it call the right
one? Did it render the result?
### 5. Diagnose any miss
If Gemini doesn't surface a verb:
1. **Check Gemini's tools view.** In the Gemini app:
Settings → Apps. Our package should appear in the list of apps
the assistant can interact with. If it's not there at all, the
system hasn't told Gemini about us yet — wait a few minutes after
install or force-reindex by clearing AppSearch.
2. **Force a re-index.**
```bash
adb shell pm clear --user 0 com.android.appsearch || true
adb shell am force-stop com.vitorpamplona.amethyst.debug
adb shell am start -n com.vitorpamplona.amethyst.debug/com.vitorpamplona.amethyst.ui.MainActivity
```
3. **Verify per-prompt.** If the package is listed but a specific
prompt doesn't trigger a tool call, the issue is description
matching — our use-when phrasing isn't catching that query.
Adjust the kdoc and rebuild.
## When schemas become worth doing
We'll move from "skipped" to "implement" if:
1. Step 4 above shows Gemini consistently fails to surface verbs that
should obviously match (suggesting it's filtering by schema, not
description), OR
2. Another Nostr Android client ships AppFunctions and wants to
co-implement a shared schema namespace (so a Nostr-aware agent
could route to whichever client is installed).
Until either of those happens, the simpler description-matching path
is in place and is what every public AppFunctions sample uses today.
## Open follow-ups (independent of this verification)
* NIP-55 (Amber) signer support — write verbs currently refuse with
`AppFunctionNotSupportedException` because we can't launch Amber's
approval activity from a background dispatch. The PendingIntent
escape hatch (Option A in the signer-prompt plan) is the next move
if NIP-55 usage matters.
* NWC auto-pay for `zapUser` / `zapEvent` — today we return the
BOLT11 invoice; the caller pastes it into a wallet. With NWC
configured we could pay automatically.
* Schema definitions if step 4 above shows we need them.
@@ -29,8 +29,10 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.actions.DmActions
import com.vitorpamplona.amethyst.commons.actions.FollowActions
import com.vitorpamplona.amethyst.commons.actions.SearchActions
import com.vitorpamplona.amethyst.commons.actions.ZapActions
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull
import com.vitorpamplona.amethyst.commons.services.lnurl.LightningAddressResolver
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -39,6 +41,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirmDetailed
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.nip10Notes.TextNoteEvent
@@ -88,10 +91,15 @@ import com.vitorpamplona.quartz.utils.TimeUtils
*/
class AmethystAppFunctions {
/**
* Searches for Nostr user profiles matching [query] via NIP-50 full-text
* search across the active account's configured search relays
* (kind:10007), falling back to Amethyst's curated default search-relay
* set when none is configured.
* Find a person on Nostr by name, handle, or NIP-05. Use when the user
* wants to look someone up on Nostr ("find vitor on nostr", "search for
* jack dorsey", "who is alice@damus on nostr"), translate a display
* name to an npub, or discover a user before following / DMing /
* zapping them.
*
* Backed by NIP-50 full-text search across the active account's
* configured search relays (kind:10007), with a fallback to
* Amethyst's curated default search-relay set.
*
* @param query free-form search text (display name, NIP-05 handle, etc.)
* @param limit max number of profiles to return — capped to 50.
@@ -142,13 +150,15 @@ class AmethystAppFunctions {
}
/**
* Drains recent kind:1 short text notes from the people the active
* account follows. The "what's new on Nostr" verb — same query the
* Amethyst home-feed UI runs, just truncated to one batch.
* 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
* up on my Nostr feed", or wants a summary of recent posts from
* people they follow.
*
* Queried relays: the active account's home relays (NIP-65 outbox +
* any private storage + local relays). Same source the UI uses, so
* Gemini sees what the user would see on their timeline.
* Drains recent kind:1 short text notes from the people the active
* account follows; the same query the Amethyst home-feed UI runs,
* truncated to one batch. Queries the account's home relays (NIP-65
* outbox + any private storage + local relays).
*
* @param limit max notes to return, capped to 200. Default 30.
*/
@@ -191,13 +201,15 @@ class AmethystAppFunctions {
}
/**
* Recent kind:1 short text notes from a specific user. Use this when
* the user asks "what did Vitor post recently?" or "catch me up on
* Snowden" — pass that user's npub or 64-hex pubkey.
* Read recent Nostr posts from a specific user. Use when the user
* asks "what did Snowden post recently on Nostr", "catch me up on
* what Jack has been posting", "show me Alice's latest notes", or
* wants to see one specific Nostr user's activity.
*
* Queried relays: prefer the target's NIP-65 write relays (where they
* publish) when their kind:10002 is cached locally; fall back to the
* active account's home relays.
* Pass the target user's npub or hex pubkey — use [searchProfiles]
* first if you only have a display name. Queries the target's
* NIP-65 write relays when cached, falling back to the active
* account's home relays.
*
* @param user npub (`npub1…`) or 64-character hex pubkey.
* @param limit max notes to return, capped to 100. Default 20.
@@ -249,9 +261,14 @@ class AmethystAppFunctions {
}
/**
* Looks up one Nostr profile by [user] (npub or 64-hex). Returns the
* latest kind:0 metadata available — checks the local cache first,
* falls back to a short network drain.
* Look up one Nostr profile by npub or hex pubkey. Use when the user
* asks "who is npub1…", "tell me about [npub]", "what's [user]'s
* Nostr profile", or wants the bio / NIP-05 / Lightning address of a
* specific Nostr user.
*
* Returns the latest kind:0 metadata — cache-first, with a short
* network fallback when the user's profile hasn't been observed
* locally yet.
*
* @param user npub (`npub1…`) or 64-character hex pubkey.
*/
@@ -312,10 +329,14 @@ class AmethystAppFunctions {
}
/**
* Find kind:1 short text notes tagged with a hashtag (NIP-12 `t`
* tag). For "find me Nostr posts about Bitcoin" — pass `bitcoin`,
* not `#bitcoin`. The hashtag is lowercased before matching, which
* is the convention most Nostr clients (Amethyst included) follow.
* Find Nostr posts about a topic via hashtag. Use when the user asks
* "show me Nostr posts about Bitcoin", "find Nostr discussion of
* #Tor", "what's the Nostr take on [topic]", or wants to browse
* conversation about a specific subject.
*
* Pass the tag value without the leading `#` — "bitcoin", not
* "#bitcoin". The hashtag is lowercased before matching (the
* convention most Nostr clients follow).
*
* @param hashtag the tag value without the leading `#`.
* @param limit max notes to return, capped to 100. Default 30.
@@ -359,10 +380,15 @@ class AmethystAppFunctions {
}
/**
* Returns who the user is currently signed in as on Nostr — their
* npub, best-effort display name, follow count, and how many relays
* are configured for outbox / inbox. Diagnostic verb for queries
* like "who am I logged in as?" or "what's my npub?".
* Report who the user is signed in as on Nostr. Use when the user
* asks "who am I logged in as on Nostr", "what's my npub", "what's
* my Nostr identity", "how many people do I follow on Nostr", or
* any other "tell me about my Nostr account" query.
*
* Returns the active account's npub, display name, NIP-05 handle,
* follow count, and how many relays are configured for outbox /
* DM inbox. Use this for Nostr-side diagnostics rather than as a
* general "who am I" answer.
*/
@AppFunction(isDescribedByKDoc = true)
suspend fun getActiveAccountInfo(appFunctionContext: AppFunctionContext): AccountInfoResult {
@@ -519,13 +545,14 @@ class AmethystAppFunctions {
}
/**
* Total sats received as NIP-57 zaps in the last [hoursBack] hours,
* plus a count of distinct zappers. For "did I earn any sats
* today?" — defaults to 24 hours.
* Report how many sats the user earned on Nostr in a time window.
* Use when the user asks "did I get any zaps today", "how many sats
* did I earn on Nostr this week", "did anyone zap my last post",
* or wants a summary of incoming NIP-57 Lightning zaps.
*
* Each kind:9735 receipt carries a `bolt11` invoice; we parse the
* amount out and sum them. Receipts without a parseable amount are
* counted but contribute 0 sats.
* Drains kind:9735 zap receipts addressed to the user in the window
* and parses the bolt11 invoice from each to compute total sats.
* Returns total + per-window zap count + unique zapper count.
*
* @param hoursBack window size in hours. Capped to 168 (7 days),
* default 24.
@@ -595,12 +622,21 @@ class AmethystAppFunctions {
}
/**
* Recent direct messages addressed to the active account. Drains
* NIP-17 gift wraps from inbox relays, decrypts each, and returns
* the inner kind:14 messages.
* Read recent Nostr direct messages. Use when the user asks "did I
* get any Nostr DMs", "what did Alice DM me", "show me my recent
* Nostr messages", "summarize my unread Nostr DMs", or wants
* decrypted message content (not just notifications) from Nostr.
*
* Drains NIP-17 gift wraps from the active account's DM-inbox
* relays, decrypts each in-process (Amethyst is the only place
* the user's NIP-44 keys live), and returns the inner kind:14
* messages with sender display names attached. File-attachment
* DMs (kind:15) are filtered out for now to keep the response
* small.
*
* @param peer optional npub/hex; when set, only returns messages
* from that specific peer. When null, returns from anyone.
* to/from that specific peer. When null, returns conversations
* with anyone.
* @param hoursBack window size in hours. Capped to 168 (7 days),
* default 24.
* @param limit max messages to return, capped to 100. Default 20.
@@ -803,9 +839,14 @@ class AmethystAppFunctions {
// ------------------------------------------------------------------
/**
* Publishes a short text note (NIP-10 kind:1) on Nostr as the
* signed-in user, broadcast to the account's configured outbox
* relays.
* Publish a short text note on Nostr. Use when the user asks "post
* this to Nostr", "tweet this on Nostr", "share [X] on Nostr",
* "publish a Nostr note saying [X]", or any other "send to Nostr"
* intent for plain-text content.
*
* Publishes a NIP-10 kind:1 short text note as the signed-in user,
* broadcast to the account's configured outbox relays. Returns per-
* relay ack so the caller can confirm the post landed.
*
* @param text the note body. Cannot be blank; capped at 8000
* characters so an accidentally-pasted document doesn't try to
@@ -838,9 +879,14 @@ class AmethystAppFunctions {
}
/**
* Follow a user on Nostr. Use when the user asks "follow [X] on
* Nostr", "add [npub] to my Nostr follows", or "subscribe to
* [user]" with a Nostr context. Idempotent — re-following someone
* already followed is a safe no-op.
*
* Adds [user] to the signed-in account's NIP-02 kind:3 follow list
* and publishes the updated list. No-op when the user is already
* followed — [WriteResult.changed] reports `false` in that case.
* and publishes the updated list. [WriteResult.changed] reports
* `false` when the user is already followed.
*
* @param user npub (`npub1…`) or 64-character hex pubkey.
*/
@@ -884,9 +930,14 @@ class AmethystAppFunctions {
}
/**
* Removes [user] from the signed-in account's NIP-02 kind:3 follow
* list and publishes the updated list. No-op when the user wasn't
* followed — [WriteResult.changed] reports `false`.
* Unfollow a user on Nostr. Use when the user asks "unfollow [X]
* on Nostr", "remove [npub] from my Nostr follows", or "stop
* following [user]" with a Nostr context. Idempotent — unfollowing
* someone the user wasn't following is a safe no-op.
*
* Removes [user] from the signed-in account's NIP-02 kind:3
* follow list and publishes the updated list. [WriteResult.changed]
* reports `false` when the user wasn't followed.
*
* @param user npub (`npub1…`) or 64-character hex pubkey.
*/
@@ -918,16 +969,16 @@ class AmethystAppFunctions {
}
/**
* Sends a NIP-17 direct message to [recipient]. The message is
* gift-wrapped (kind:1059) per NIP-59 — only the recipient (and
* the signed-in user, who keeps their own copy) can decrypt it.
* Send a direct message to a user on Nostr. Use when the user asks
* "DM [X] on Nostr", "send a Nostr message to [user] saying [Y]",
* "message [npub] on Nostr", or any other "send a private message"
* intent in a Nostr context.
*
* Recipients without a published kind:10050 DM-inbox list fall back
* through NIP-65 read relays then bootstrap relays. If you want the
* stricter NIP-17 behavior — refuse to send when no kind:10050 is
* available — read the recipient's profile via [getProfile] first
* and check yourself; this verb defaults to permissive so Gemini
* users don't see confusing failures from an unfamiliar spec rule.
* The message is gift-wrapped (kind:1059) per NIP-59 — only the
* recipient (and the signed-in user, who keeps their own copy)
* can decrypt it. Recipients without a published kind:10050
* DM-inbox list fall back through NIP-65 read relays then
* bootstrap relays.
*
* @param recipient npub (`npub1…`) or 64-character hex pubkey.
* @param text the message body. Cannot be blank; capped at 8000
@@ -1002,6 +1053,269 @@ 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".
*
* Builds the NIP-57 kind:9734 zap request and fetches a BOLT11
* invoice from the recipient's Lightning service. Returns the
* invoice — the user pastes it into a Lightning wallet to settle.
* (NWC auto-pay is a separate verb, not yet exposed.)
*
* 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.
*
* @param user npub (`npub1…`) or 64-character hex pubkey of the
* zap recipient.
* @param sats amount to zap, in whole sats. Capped at 1,000,000
* sats. Default 21.
* @param comment optional message to attach to the zap. Capped at
* 280 characters.
*/
@AppFunction(isDescribedByKDoc = true)
suspend fun zapUser(
appFunctionContext: AppFunctionContext,
user: String,
sats: Long = 21,
comment: String? = null,
): 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)
val client = Amethyst.instance.client
// Pull the recipient's kind:0 — needs lnAddress to receive the zap.
val metadata =
account.cache
.checkGetOrCreateUser(recipientPub)
?.metadataOrNull()
?.flow
?.value
?.info
?.let { extractLnAddressFromMetadata(it) }
?: fetchProfileForZap(client, account, recipientPub)
?: throw AppFunctionInvalidArgumentException(
"No kind:0 metadata for $user — 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.",
)
val zapRequest =
ZapActions.buildUserZapRequest(
signer = account.signer,
recipientPubkey = recipientPub,
amountMillisats = ZapActions.satsToMillisats(cappedSats),
inboxRelays = account.nip65RelayList.inboxFlow.value,
comment = trimmedComment,
zapType = LnZapEvent.ZapType.PUBLIC,
)
val invoice = fetchInvoiceOrThrow(lnAddress, cappedSats, trimmedComment, zapRequest)
return ZapResult(
recipientNpub = NPub.create(recipientPub),
recipientPubkeyHex = recipientPub,
recipientDisplayName = displayNameOf(recipientPub),
lnAddress = lnAddress,
amountSats = cappedSats,
comment = trimmedComment,
invoice = invoice,
zapRequestId = zapRequest.id,
)
}
/**
* Zap a specific Nostr note (NIP-57 event zap). Use when the user
* asks "zap this Nostr post", "tip the author of [event id]",
* "send sats for that Nostr note about [X]", or "boost this Nostr
* post with sats".
*
* Honors NIP-57 zap-split tags — a post with multiple `zap` tags
* produces one invoice per recipient, proportional to weight, so
* a multi-party collab post pays everyone correctly. Returns one
* BOLT11 invoice per recipient; user pays each in a Lightning
* wallet to complete the zap.
*
* @param eventId 64-character hex id of the note to zap. Must be
* in the local cache — get it via [getNotesByUser] /
* [getRecentFromFollows] / [searchByHashtag] / [searchNotes]
* first.
* @param sats total amount to zap, in whole sats. Capped at
* 1,000,000.
* @param comment optional message attached to every zap request.
* Capped at 280 characters.
*/
@AppFunction(isDescribedByKDoc = true)
suspend fun zapEvent(
appFunctionContext: AppFunctionContext,
eventId: String,
sats: Long = 21,
comment: String? = null,
): ZapEventResult {
if (eventId.length != 64) {
throw AppFunctionInvalidArgumentException("eventId must be 64-character hex")
}
val cappedSats = sats.coerceIn(1L, MAX_ZAP_SATS)
val trimmedComment = comment.orEmpty().trim().take(MAX_ZAP_COMMENT_LENGTH)
val account = Amethyst.instance.sessionManager.loggedInAccount() ?: throw notSignedIn()
requireInProcessSigner(account.signer)
val note =
account.cache.getNoteIfExists(eventId)
?: throw AppFunctionInvalidArgumentException(
"Event $eventId not in local cache. Fetch it via getNotesByUser or " +
"getRecentFromFollows first, or open the note in Amethyst.",
)
val event =
note.event
?: throw AppFunctionInvalidArgumentException(
"Event $eventId is referenced locally but its content hasn't been observed yet.",
)
val client = Amethyst.instance.client
val totalMsats = ZapActions.satsToMillisats(cappedSats)
// Lookups for the split resolver — first try the local cache,
// then fall back to a one-shot network drain.
val lookupLnAddress: suspend (HexKey) -> String? = { pk ->
account.cache
.checkGetOrCreateUser(pk)
?.metadataOrNull()
?.lnAddress()
?: fetchProfileForZap(client, account, pk)
}
val lookupInboxRelays: suspend (HexKey) -> Set<NormalizedRelayUrl> = { pk ->
account.cache
.checkGetOrCreateUser(pk)
?.inboxRelays()
?.toSet()
.orEmpty()
}
val requests =
ZapActions.buildEventZapRequestsForSplits(
signer = account.signer,
zappedEvent = event,
totalAmountMillisats = totalMsats,
senderInboxRelays = account.nip65RelayList.inboxFlow.value,
lookupLnAddress = lookupLnAddress,
lookupInboxRelays = lookupInboxRelays,
comment = trimmedComment,
zapType = LnZapEvent.ZapType.PUBLIC,
)
if (requests.isEmpty()) {
throw AppFunctionInvalidArgumentException(
"No payable recipients — neither the author nor any zap-split recipient has a usable Lightning address.",
)
}
val invoices =
requests.map { req ->
val shareSats = req.amountMillisats / 1000
val result =
runCatching {
fetchInvoiceOrThrow(
lnAddress = req.recipient.lnAddress,
sats = shareSats,
comment = trimmedComment,
zapRequest = req.request,
)
}
ZapInvoice(
recipientNpub = req.recipient.pubkey?.let { NPub.create(it) },
recipientPubkeyHex = req.recipient.pubkey,
recipientDisplayName = req.recipient.pubkey?.let { displayNameOf(it) },
lnAddress = req.recipient.lnAddress,
weight = req.recipient.weight,
amountSats = shareSats,
invoice = result.getOrNull(),
invoiceError = result.exceptionOrNull()?.message,
zapRequestId = req.request.id,
)
}
return ZapEventResult(
zappedEventId = eventId,
requestedSats = cappedSats,
billedSats = invoices.sumOf { it.amountSats },
comment = trimmedComment,
invoices = invoices,
)
}
/** Read lnAddress out of an already-resolved UserMetadata. */
private fun extractLnAddressFromMetadata(info: com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata): String? = info.lnAddress()
/**
* Cache miss path for zap recipient profile lookup. Drain the
* recipient's NIP-65 outbox / our home relays for their kind:0;
* returns the lnAddress directly so callers don't have to re-parse
* the metadata blob.
*/
private suspend fun fetchProfileForZap(
client: com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient,
account: com.vitorpamplona.amethyst.model.Account,
pubkey: HexKey,
): String? {
val relays =
account.cache
.checkGetOrCreateUser(pubkey)
?.outboxRelays()
?.toSet()
?.ifEmpty { account.homeRelays.flow.value }
?: account.homeRelays.flow.value
if (relays.isEmpty()) return null
val filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(pubkey), limit = 1)
return client
.fetchAll(
filters = relays.associateWith { listOf(filter) },
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
).mapNotNull { it as? MetadataEvent }
.maxByOrNull { it.createdAt }
?.contactMetaData()
?.lnAddress()
}
/**
* LNURL-pay round-trip: resolves the LN address to a callback URL,
* posts the zap request, returns the BOLT11 invoice. Uses
* Amethyst's roleBasedHttpClientBuilder so the request honors the
* user's Tor / money-routing preferences.
*/
private suspend fun fetchInvoiceOrThrow(
lnAddress: String,
sats: Long,
comment: String,
zapRequest: com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent,
): String {
// Compute the LNURL-pay endpoint so we can ask the privacy-aware
// HttpClient builder for the right OkHttpClient for that host.
val endpointUrl =
LightningAddressResolver(httpClient = okhttp3.OkHttpClient()).assembleUrl(lnAddress)
?: throw AppFunctionInvalidArgumentException("Couldn't resolve LN address '$lnAddress' to an LNURL-pay URL.")
val client = Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForMoney(endpointUrl)
val resolver = LightningAddressResolver(httpClient = client)
val result =
resolver.fetchInvoice(
lnAddress = lnAddress,
milliSats = ZapActions.satsToMillisats(sats),
message = comment,
zapRequest = zapRequest,
)
return when (result) {
is LightningAddressResolver.Result.Success -> result.invoice
is LightningAddressResolver.Result.Error ->
throw AppFunctionInvalidArgumentException("Lightning service rejected the zap: ${result.message}")
}
}
/**
* Reject the call when the active signer can't sign in-process —
* NIP-55 external signers (Amber) need a foreground activity to
@@ -1204,6 +1518,16 @@ class AmethystAppFunctions {
* with what `cli/Context.publish` uses.
*/
private const val PUBLISH_TIMEOUT_SECS = 15L
/** Upper bound on a single zap. Anything above this is almost
* certainly a typo; bail out instead of letting Gemini bill
* the user a million sats by accident. */
private const val MAX_ZAP_SATS = 1_000_000L
/** LN providers typically reject longer comments — capping at
* 280 keeps us under the most aggressive ceilings while still
* fitting a tweet-length thank-you note. */
private const val MAX_ZAP_COMMENT_LENGTH = 280
}
}
@@ -1534,3 +1858,78 @@ class SendDmResult(
/** One entry per gift-wrap delivery. */
val deliveries: List<DmDelivery>,
)
/**
* Result of [AmethystAppFunctions.zapUser]. Carries the BOLT11 invoice
* the user needs to pay in their Lightning wallet — this verb doesn't
* auto-pay (NWC integration is a separate, not-yet-exposed verb).
*/
@AppFunctionSerializable(isDescribedByKDoc = true)
class ZapResult(
/** 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. */
val lnAddress: String,
/** Amount actually requested (after capping). */
val amountSats: Long,
/** Comment attached to the zap (truncated to 280 chars). */
val comment: String,
/** BOLT11 invoice the user pastes into a Lightning wallet. */
val invoice: String,
/** Hex event id of the signed kind:9734 zap request. */
val zapRequestId: String,
)
/**
* Per-recipient BOLT11 invoice for an event zap. Multiple invoices
* appear when the zapped note carries NIP-57 zap-split tags.
*/
@AppFunctionSerializable(isDescribedByKDoc = true)
class ZapInvoice(
/** Bech32 npub of the recipient, or null when the split tag carried
* only an LN address with no pubkey. */
val recipientNpub: String?,
/** Hex pubkey of the recipient, or null when only an LN address was given. */
val recipientPubkeyHex: String?,
/** Best-effort display name from the cache, when the recipient is known. */
val recipientDisplayName: String?,
/** LN address the invoice was fetched from. */
val lnAddress: String,
/** Relative weight in the zap split — 1.0 for unweighted recipients. */
val weight: Double,
/** This recipient's share of the total in whole sats. */
val amountSats: Long,
/** BOLT11 invoice, or null when the Lightning provider failed
* (see [invoiceError] for the reason). */
val invoice: String?,
/** Failure reason from the Lightning provider when [invoice] is null. */
val invoiceError: String?,
/** Hex event id of this recipient's kind:9734 zap request. */
val zapRequestId: String,
)
/**
* Result of [AmethystAppFunctions.zapEvent]. Total billed sats may
* differ from requested by a few sats due to whole-sat rounding in the
* splits — same drift the foreground UI has.
*/
@AppFunctionSerializable(isDescribedByKDoc = true)
class ZapEventResult(
/** Hex event id of the note being zapped. */
val zappedEventId: String,
/** Total sats the caller asked for (capped, post-validation). */
val requestedSats: Long,
/** Sum of per-recipient sats actually billed across all invoices. */
val billedSats: Long,
/** Comment attached to every zap request. */
val comment: String,
/** One invoice per recipient — multiple entries when the note has
* NIP-57 zap-split tags. Pay each one in a Lightning wallet to
* complete the zap; invoices with non-null [ZapInvoice.invoiceError]
* couldn't be fetched and won't go through. */
val invoices: List<ZapInvoice>,
)