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.
Phase 4 from the signer-prompt plan, scoped to Option B (refuse NIP-55
with a typed NotSupportedException). Internal-key and NIP-46 bunker
accounts can now publish from Gemini.
New @AppFunction methods:
* postNote(text) — kind:1 short text note. Caps at 8000 chars to
catch accidentally-pasted documents; publishes to outbox relays
with per-relay ack reported.
* followUser(user) / unfollowUser(user) — kind:3 contact list
update via FollowActions. Detects already-following / not-
following and returns WriteResult.unchanged() rather than
re-publishing the same kind:3. New follows stamp the relay hint
from the target's cached kind:10002 write list, mirroring
User.bestRelayHint().
* sendDm(recipient, text) — NIP-17 gift-wrap via DmActions.buildTextDm.
Resolves per-recipient relay set through DmActions.resolveDmRelays
(permissive mode — falls back through NIP-65 read to bootstrap so
Gemini users don't trip on the strict kind:10050 rule). Returns
one DmDelivery per wrap (recipient + sender's own copy).
Signer gating — requireInProcessSigner():
* Read-only signers (npub-only login) → AppFunctionNotSupportedException
"sign in with a private key or NIP-46 bunker to publish".
* NIP-55 external signers (Amber) → AppFunctionNotSupportedException
"open Amethyst directly to complete the action". Detected via
qualified class name to avoid hard-coupling the bridge to the
nip55AndroidSigner module.
* NostrSignerInternal / NostrSignerRemote — sign in-process; the
NIP-46 round-trip already suspends through .sign(), no special
handling needed.
New @AppFunctionSerializable types:
* WriteResult — { changed, eventId?, publishedTo, rejectedBy }
* SendDmResult — { messageEventId, deliveries: List<DmDelivery> }
* DmDelivery — { recipientNpub, recipientPubkeyHex, wrapId,
publishedTo, rejectedBy, relaySource }
All 19 verbs now registered in the generated dispatcher (15 read + 4
write). app_metadata.xml updated so Gemini's tool picker pitches the
broader surface, including the NIP-55 caveat.
Every verb that returned a pubkey now also returns the best-effort
display name from the local kind:0 cache. Before this commit Gemini
could only say "you got a DM from npub1abc…" — now it can say
"you got a DM from Alice" because the LLM has the field at hand
instead of having to chain another lookup.
* NoteHit gains authorDisplayName (cache-resolved, null when the
author's kind:0 isn't local yet). Applied to every verb that
returns notes: searchNotes / getRecentFromFollows / getNotesByUser /
searchByHashtag / getMyRecentNotes / getMyMentions /
getRepliesToNote / searchArticles.
* DmMessage gains fromDisplayName + sentByMe — the latter lets
the caller distinguish "Alice said X" from "I said Y" when both
appear in the same thread snapshot.
* LiveStreamHit gains streamingUrl (was missing entirely — without
it the verb is useless, you can't watch a stream you can't open)
plus hostDisplayName.
* getProfile cache-hit path now actually populates `about` — was
silently null before because the early-return branch didn't read
it out of UserInfo. Cache-miss path was always correct.
Implementation: one `displayNameOf(HexKey): String?` helper reads from
Amethyst.instance.cache (LocalCache) — the same cache the foreground
UI uses. Zero allocations beyond the lookup, no network round-trip.
Now exposing the full read-only Nostr surface to Gemini. Seven new
@AppFunction methods on top of the previous eight:
* getMyRecentNotes(limit) — author=me filter on kind:1.
* getMyMentions(limit) — p-tag=me filter on kind:1. "Did anyone @ me?".
* getRepliesToNote(eventId, limit) — e-tag=eventId filter on kind:1.
Pair with getMyRecentNotes(1) for "did anyone respond to my last post?".
* getZapsReceived(hoursBack) — drains kind:9735 receipts addressed to
the user in the window, parses the bolt11 invoice from each, sums
sats. Returns total + zap count + unique zappers + count of
receipts whose bolt11 was unparseable.
* getRecentDms(peer?, hoursBack, limit) — NIP-17 gift-wrap drain +
unwrapAndUnsealOrNull decrypt. kind:14 text DMs only for v1 (skip
kind:15 encrypted-file headers to keep payloads bounded). Widens
the `since` filter by 2 days for NIP-59's randomised-past
created_at trick, then trims back to the requested window.
* searchArticles(query, limit) — same as searchNotes but kind:30023
long-form articles. Content snippet truncated at 2000 chars so a
book-length article doesn't blow up the AppFunctions response;
Gemini can ask the user whether to fetch the full article via a
different verb.
* getLiveStreams(limit) — NIP-53 kind:30311 with status=live (uses
quartz's 8-hour staleness guard via LiveActivitiesEvent.isLive).
Returns title, summary, host npub, start time, event id.
Plus updated res/xml/app_metadata.xml so Gemini's tool picker pitches
the full surface to users.
KSP-verified — 15 verbs total in the generated dispatcher:
getActiveAccountInfo getFollowing getLiveStreams
getMyMentions getMyRecentNotes getNotesByUser
getProfile getRecentDms getRecentFromFollows
getRepliesToNote getZapsReceived searchArticles
searchByHashtag searchNotes searchProfiles
Write verbs (post, follow, zap, sendDm) still deferred behind the
signer-prompt plan in amethyst/plans/2026-05-25-appfunctions-signer-prompts.md
— no behavior change there.
After the on-device round-trip proved the AppFunctions plumbing works,
adding the verbs that make Gemini actually useful for a Nostr user.
All read-only, no signer interaction, all build on existing actions /
Account state.
* getRecentFromFollows(limit) — "what's happening on Nostr today?"
Drains recent kind:1 from people the user follows; same relay set
the home-feed UI uses (account.homeRelays).
* getNotesByUser(user, limit) — "what did Vitor post recently?"
Accepts npub or 64-hex. Prefers the target's NIP-65 write relays
when cached, falls back to the active account's home relays.
* getProfile(user) — "who is npub1xq5...?". Cache-first via
LocalCache; falls back to a short network drain for unseen users.
Returns GetProfileResult{found, profile} so callers know whether
the user just isn't in cache or doesn't have a kind:0 yet.
* searchByHashtag(hashtag, limit) — "find Nostr posts about Bitcoin".
NIP-12 `t` tag filter, lowercased to match the client convention.
* getActiveAccountInfo() — "who am I logged in as?" Diagnostic verb
returning npub, display name, follow count, outbox + DM relay
counts. Distinguishes signed-in from signed-out via a flag rather
than a magic empty result.
Plus:
* decodeUserOrThrow helper for npub/hex parsing, throws
AppFunctionInvalidArgumentException with a typed message so
callers see "expected npub1… or 64-char hex" instead of a stack.
* TextNoteEvent.toNoteHit helper — extracted from the existing
searchNotes path to avoid duplication.
* Updated res/xml/app_metadata.xml description so Gemini's tool
picker can pitch a broader summary to the user.
KSP-verified: $AmethystAppFunctions_AppFunctionInvoker now dispatches
all eight verbs (the three from the previous commits plus these five).
After fixing the missing aggregated XML, Pixel 8 logcat still showed:
D AppFunctions: Unable to resolve AppFunctionMetadata.
Comparing against Google's FilipFan/AppFunctionsPilot sample turned up
a separate metadata pointer the system requires:
<property
android:name="android.app.appfunctions.app_metadata"
android:resource="@xml/app_metadata" />
This goes on the <application> element (not the service) and points to
an XML resource — distinct from the asset-side `app_functions.xml`
that the library auto-merges onto the service. The asset metadata
declares "here are my function ids and schemas"; the resource
metadata gives the agent a user-facing summary like "Search Nostr and
read your follows" to show users before they grant access.
Without the resource, the system can find our service and our
function list but can't resolve the descriptive metadata it shows
the user — so Gemini's tool picker stays empty.
Two new files:
* amethyst/src/play/res/xml/app_metadata.xml — short description +
displayDescription. Update when the @AppFunction surface grows.
* play AndroidManifest <property> pointing at the resource.
Also dropped our explicit <service> declaration for
PlatformAppFunctionService — confirmed via the appfunctions-service
AAR that the library auto-merges that exact entry, complete with
permission + intent-filter, so our copy was redundant.
The androidx.appfunctions-compiler runs in per-module mode by default,
emitting only the dispatcher Kotlin code. The aggregator that builds
the `app_functions.xml` + `app_functions_v2.xml` assets is gated behind
a KSP argument that was off.
Symptom on a Pixel 8 running our APK:
D AppFunctions: Unable to resolve AppFunctionMetadata.
Without the aggregated asset, the manifest's
`android.app.appfunctions` property pointed at a file that didn't
exist; the System UI couldn't enumerate our @AppFunction methods so
Gemini's tool picker never saw them.
Setting `appfunctions:aggregateAppFunctions = "true"` on the amethyst
module turns the aggregator on. Verified post-build:
assets/app_functions.xml (688 bytes — manifest pointer + ids)
assets/app_functions_v2.xml (19.7 KB — full schemas + kdoc descriptions)
Both list searchProfiles / searchNotes / getFollowing with the kdoc
descriptions Gemini will render.
Library modules (commons/quartz) would set this to "false" — only the
final app emits the aggregate. We don't currently apply the KSP plugin
in any library module, so this is the only place that matters.
Read-only surface for the Gemini bridge now covers profiles, notes, and
the active account's follow set:
* searchNotes(query, limit) — NIP-50 search over kind:1 short text
notes via SearchActions.searchNotesFilter + INostrClient.fetchAll.
Returns NoteHit list (eventId, npub, content, createdAt). alpha09
of androidx.appfunctions doesn't support List<Int> parameters, so
no caller-configurable kinds — kind:1 only for now.
* getFollowing(limit) — reads account.kind3FollowList.userList.value
(already resolved through LocalCache) and projects to FollowedUser
with display-name / nip05 / picture from cached kind:0. Reports
totalFollowing so the caller knows when limit truncated the list.
Both verbs follow the searchProfiles pattern: snapshot active account +
client at entry, never re-query sessionManager during the dispatch.
Plus amethyst/plans/2026-05-25-appfunctions-signer-prompts.md —
design plan for write verbs. Three signers (Internal / Remote /
External), three different latency + interaction models. Concrete
proposal: Internal first via postNote pilot, Remote as a follow-up,
External via PendingIntent (Option A) or NotSupportedException
(Option B — recommended for v1) depending on what bundle keys the
system shell respects. Open questions enumerated so the experiment
day is bounded.
Fourth verb extraction alongside FollowActions / SearchActions /
ZapActions. Closes the largest remaining amy-expert "thin assembly"
violation in cli/.
Two pieces moved out of cli/.../DmCommands.kt into commons:
* DmActions.resolveDmRelays applies the strict-kind:10050 → NIP-65-
read → bootstrap fallback policy the in-app flow uses. Returns a
DmRelaySet with a typed RelaySource (KIND_10050 / NIP65_READ /
BOOTSTRAP / NONE) so callers can surface the source — amy emits
it on stdout, a future Gemini adapter could mention it in the
assistant response.
* DmActions.buildTextDm / buildFileDmReference are thin wrappers
over NIP17Factory.createMessageNIP17 / createEncryptedFileNIP17
that build the kind:14 / kind:15 template and gift-wrap in one
call. Matches the FollowActions / ZapActions builder shape.
amy's DmCommands is now genuinely thin assembly: requireUserHex,
flag plumbing, call DmActions, render JSON. The 583-line file shrank
slightly and — more importantly — no longer carries NIP-17 logic
the rest of the codebase needs to look at.
Receive-side decrypt loop (3 lines of unwrapAndUnsealOrNull) stays in
amy; too small to extract and tightly coupled to amy's per-relay
attribution.
10 new tests for DmActions: strict/permissive fallback chain, null
recipient lists, RelaySource enum stability, and a smoke test that
buildTextDm produces a kind:14 with the right wrap count (sender +
recipient).
quartz already ships an extension that does exactly what
AmethystAppFunctions.drain reimplemented — subscribe with the given
filters, collect events until every relay sends EOSE / closed /
cannot-connect or the timeout elapses, unsubscribe, dedup by id,
return sorted newest-first. See
quartz/.../relay/client/accessories/NostrClientFetchAllExt.kt.
Replacing the local drain with `client.fetchAll(filters, timeoutMs)`
trims 70+ lines of subscription listener boilerplate and gives the
adapter the same behavior the rest of the codebase already trusts.
amy's Context.drain stays — it adds per-event signature verification
and persistence to the file event store (the trust boundary for amy)
that fetchAll doesn't do.
The previous wiring forced Amethyst to be `open`, added a 30-line
PlayAmethyst subclass that only implemented AppFunctionConfiguration
.Provider, and used tools:replace="android:name" in the play manifest
to swap classes. The justification was that the appfunctions runtime
discovers @AppFunction host classes via Application.appFunctionConfiguration.
Reading the KSP-generated dispatcher
($AmethystAppFunctions_AppFunctionInvoker.kt) shows that's only half
true. The invoker passes a default-construction fallback lambda when
instantiating the host class, and ConfigurableAppFunctionFactory takes
that fallback as a constructor argument. Provider is only consulted to
*override* construction — required for classes with non-default
constructors, optional otherwise.
AmethystAppFunctions has a no-arg constructor, so:
* PlayAmethyst is deleted entirely
* Amethyst goes back to `class Amethyst : Application()` (no `open`)
* Play manifest reverts to plain `android:name=".Amethyst"`, no
tools:replace gymnastics
Verified by assemblePlayDebug (APK builds clean) and the merged play
manifest still pinning the appfunctions service. If we ever add a
host class with constructor parameters (an Account-injected one, say),
we'll need to add Provider back — kdoc on AmethystAppFunctions
documents that.
Closes the remaining items from the comparative review of the extracted
actions against the in-app Amethyst flows. All small, all surfaced by the
review.
* amy follow now stamps the relay hint on new contact-list `p` tags.
Best-effort read from the target's cached kind:10002 advertised
relay list (first writeRelaysNorm). Mirrors User.bestRelayHint() —
follows added via amy no longer have empty relayUri.
* amy search user now dedups by pubkey (sorted newest-first) instead
of by event id, matching the App Functions adapter. Multiple relays
surfacing different kind:0 revisions for the same author collapse
to one hit.
* AmethystAppFunctions.searchProfiles captures the active account AND
the relay client at function entry, then never touches sessionManager
or Amethyst.instance again during the drain. Closes the account-
switch race surfaced in the review.
* FollowActions / SearchActions / ZapActions kdoc now lists the
caller-side responsibilities each builder leaves to the consumer
(publish, writeable check, relay hint, pseudo-kind filtering,
LN round-trip, receipt verification, etc.). Documents the design
rather than letting it leak through reviews.
The previous ZapActions.buildEventZapRequest signed a single zap request
to a single recipient. Notes carrying NIP-57 zap-split tags, NIP-53
live-activity host tags, or NIP-89 app-definition metadata expect the
payment to be distributed across multiple parties — so `amy zap event`
silently overpaid one party and underpaid the rest. The correctness
review on the action-set flagged this as the only real bug in the
extracted verbs; this commit fixes it.
* ZapSplitResolver — new commonMain object mirroring the resolution
order in ZapPaymentHandler.kt (splits > live-activity hosts > app
metadata > author fallback). Pure logic; pubkey→LN-address lookup
is passed in as a suspend lambda so amy reads from its file store
and Android reads from LocalCache, no shared cache-coupling.
* ZapActions.buildEventZapRequestsForSplits — high-level helper that
composes the resolver with per-share LnZapRequestEvent signing.
Each request's `relays` tag unions sender + author + recipient
inbox relays so the kind:9735 receipt routes to every interested
party (matches signAllZapRequests in the Android handler).
* amy zap event — rewired to the split-aware path. JSON output now
enumerates each recipient with its share, LN address, request id,
and BOLT11 invoice (or per-recipient invoice_error). Profile zaps
(amy zap user) keep the simple single-recipient path since they
have no split tags.
Tests: 12 new cases — LN-address splits, weighted pubkey splits, author
fallback, drop-silently-on-missing-LN, relay unioning, share rounding.
All 41 action tests green; both Android flavors compile.
First Phase 2 verb wired through to the Android App Functions runtime so
Gemini (and other system agents) can drive Amethyst.
Scope is intentionally narrow:
* One read-only verb (searchProfiles), built on top of the existing
SearchActions in commons. No write verbs yet — they need a story
for NIP-46 / NIP-55 signer prompts from a background dispatcher.
* Play channel only. appfunctions 1.0.0-alpha09 is a Google AI alpha;
F-Droid builds continue to ship without any Google AI dependencies.
Architecture:
* AmethystAppFunctions — plain Kotlin host with @AppFunction methods.
The KSP-driven appfunctions-compiler discovers them and generates
the dispatch metadata XML at build time.
* PlayAmethyst — play-only Application subclass implementing
AppFunctionConfiguration.Provider; supplies the factory the
library uses to construct the host class. Manifest replaces
android:name in the play flavor only; F-Droid keeps the unmodified
Amethyst class.
* The androidx-provided PlatformAppFunctionService is registered in
the play manifest as the bind point — Amethyst doesn't ship a
custom Service.
KSP is now a project-wide plugin (apply false at the root); applied in
amethyst/ to run the appfunctions-compiler over the play sourceSet.
Amethyst becomes `open class` so PlayAmethyst can extend it. No other
behavior change.
Third verb extraction alongside FollowActions / SearchActions, scoped
to event building so the action stays target-agnostic (commonMain,
no JVM/Android coupling).
* buildUserZapRequest / buildEventZapRequest wrap the two
LnZapRequestEvent.create overloads with a uniform call shape and
sensible defaults (PUBLIC zap, no LNURL, no poll).
* extractLnAddress pulls lud16 (preferred) or lud06 from a kind:0
metadata event, returning null when neither is set.
* satsToMillisats covers the sats→msats conversion that every
caller would otherwise duplicate.
Wires up amy zap user|event as the first consumer. The Lightning
round-trip (LNURL fetch + invoice retrieval) goes through the existing
LightningAddressResolver in commons/jvmAndroid; the BOLT11 invoice is
printed but not auto-paid since amy has no NWC wallet wired up yet.
Introduce SearchActions alongside FollowActions as the second of the
shared "verbs" usable by amy CLI and a future Android App Functions
adapter for Gemini.
* searchProfilesFilter / searchNotesFilter build the relay-side
Filter with the NIP-50 `search` field set; blank queries return
null so callers don't issue unconstrained searches that relays
would reject anyway.
* resolveSearchRelays picks the caller's kind:10007 list when
configured (decrypting NIP-44 private entries via the signer) and
falls back to DefaultSearchRelayList — the same set the Android UI
uses when the user has no list of their own.
Wires up amy search user|note as the first consumer.
Introduce commons/.../actions/FollowActions as the canonical, non-UI
entry point for NIP-02 kind:3 mutations. Accepts pubkeys as HexKey
rather than the Compose-bound User model, so callers without a cache
(amy CLI, future Android App Functions adapter for Gemini, automation
scripts) can drive follow/unfollow directly.
Kind3FollowListState.follow/unfollow now delegate to FollowActions,
preserving the existing Account.follow(user) signature on Android.
Behavior is unchanged for UI callers.
Wires up amy follow/unfollow as the first consumer — fetches the
freshest kind:3 from outbox relays before mutating so concurrent
follows from another client are preserved.
The previous attempt weighted every item, which made even Share collapse
to the left of its slice instead of pinning to the right edge.
Restore the natural-width carve-out for the last item, but gate it on
`!showCounter` — Share/Pay have no counter so they stay flush against
the right padding as before; Zap/Like/etc. become weighted when last so
the counter doesn't sprawl out to the edge and the row stays balanced.
LocalPreferences.setDefaultAccount called setCurrentAccount before
saveToEncryptedStorage. setCurrentAccount emits the new list onto the
savedAccounts MutableStateFlow, which AlwaysOnNotificationServiceManager
collects and reacts to by calling loadAccountConfigFromEncryptedStorage
for every saved account — including the just-added one. That call hit
encryptedPreferences(newNpub) before NOSTR_PUBKEY had been written, got
null, and cached the null in cachedAccounts.
cachedAccounts is a process-lifetime map, so the poisoned entry survived
the eventual disk write. Every subsequent switchUser to that account
took the cached null path, fell through to requestLoginUI(), and AccountScreen
rendered LoggedOffSetup — the onboarding screen with TOS unchecked, asking
the user to re-do the Amber handshake.
Write the per-npub file first, then seed the cache with the in-memory
AccountSettings, then publish onto the savedAccounts flow. Also stop
caching null returns in loadAccountConfigFromEncryptedStorage so any
future racy reader can't poison the cache either.
The reaction row gave every item except the last a `Modifier.weight()`,
which made the last item collapse to its natural width and hug the right
edge of the content area. With Share (icon-only) as the default last
item, all icons appeared evenly distributed.
When the user disabled Share, the last weighted slot moved to Zap. Zap
renders icon + counter, so its natural-width row took more space at the
right and pulled the rightmost icon away from where the other icons sat
(each at the left of a now-wider weighted slice), leaving the row
looking unbalanced.
Give every reaction an equal weighted slice so icons sit at the left of
their slice regardless of which reactions are enabled. The unused space
at the end of the last slice naturally provides the right-side padding
where Share used to sit.
Moves the asynchronous chain-verification side of NIP-BC onchain zaps out of
LocalCache into a dedicated OnchainZapResolver class living alongside other
NIP-specific subpackages under model/nipBCOnchainZaps/. LocalCache shrinks by
~240 lines and now owns only the synchronous event-dispatch responsibility:
loading the event, attaching the optimistic UNVERIFIED entry for the sender's
own zap, and delegating the verifier launch to the resolver.
The resolver owns:
- launchVerification(event, source, repliesTo) — async fire-and-forget
- reverifyOnchainZapsForNote(note) — used by the gallery's screen-driven loop
- onchainTipHeightFlow — shared chain-tip poller, lazy + WhileSubscribed
- verifyingEventIds / reverifyingNoteIds — in-flight de-duplication
- reverifySemaphore — parallelism cap
OnchainZapGallery now calls LocalCache.onchainZapResolver.{reverifyOnchainZaps
ForNote, onchainTipHeightFlow} directly. consume(OnchainZapEvent) passes the
already-computed repliesTo into launchVerification so the new-event path
doesn't recompute it on the verifier side.
No behavior change — all 22 onchain-zap tests still pass.
Test referenced formatSats, DEFAULT_ZAP_AMOUNTS, and ZapType which
were removed/made private in upstream merge.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rewrite SendDialog with sealed state machine that auto-detects input
type (BOLT11, LNURL bech32, lightning address). For LNURL/address:
resolves endpoint, shows amount form with min/max hint, optional
comment field, fetches invoice, then pays via NWC. Strips lightning:
URI prefix. Inline copiable errors with retry.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SendDialog: switch to Dialog+Card with X close, inline copiable error
messages, button resets to "Pay Invoice" on error for retry.
LightningAddressResolver: return error body from callback responses so
server error messages (e.g. "Recipient wallet error") surface to user
instead of generic "Failed to fetch invoice". Also check "message"
field in addition to "reason" for error extraction.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace AlertDialog with Dialog+Card pattern. Invoice created state now
shows centered amount, description, 240dp QR code, and full-width
"Copy Invoice" button. Close via top-right X button. Input form gets
full-width "Create Invoice" button.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add LaunchedEffect that rescans cache when followedUsers populates after
startup, fixing empty feed when contact list arrives after initial scan.
Remove diagnostic println from NwcPaymentHandler.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
LoginScreen's fire-and-forget save coroutine used rememberCoroutineScope
which got cancelled when the composable left composition after login.
Move saveCurrentAccount() to onLoginSuccess in Main.kt which uses the
app-level scope that survives recomposition. Fixes both nsec login and
generate-new-account flows.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Addresses the 15 issues from the second audit pass. Key changes:
- Per-event resolution flag (`Note.onchainZapResolved`) replaces the unbounded
rejection blocklist. The flag is set on terminal verifier verdicts
(Confirmed or hard-Rejected) and gates the verifier launch in `consume()`.
Travels with the Note so it clears on `removeAllChildNotes()`.
- Per-event in-flight set (`verifyingEventIds`) deduplicates concurrent
verifier launches across `consume()` echoes and `reverifyOnchainZapsForNote`
races. Solves: profile-only zaps bypassing the all-CONFIRMED guard,
Rejected entries re-firing the verifier on every echo, and the
consume()/reverify TOCTOU race.
- Per-note reverify gate (`reverifyingNoteIds`) prevents multiple visible
galleries from launching concurrent reverify passes for the same note.
- `removeOnchainZapForSource` now refuses to remove a CONFIRMED entry — only
an explicit fresh CONFIRMED replacement can change one. Prevents the
cross-target downgrade where one target's transient ZERO_VERIFIED_AMOUNT
erases a sibling target's already-confirmed entry. Also non-nullable
pubkey parameter to close the null-vs-null comparison hole.
- `innerAddOnchainZap` dedup tightened: exact structural equality skips
spurious flowSet invalidations on relay echoes, but same-level + equal
verifiedSats from a DIFFERENT source now replaces (fixes multi-signer
attribution lock-in).
- Tip flow uses explicit try/catch that re-throws CancellationException
instead of `runCatching` (same fix the previous audit applied to the
verifier). Lazy initializer falls back to a constant-null StateFlow if
`Amethyst.instance` isn't initialized yet, instead of throwing.
- Gallery driver: unconditional first-view kick (no longer waits for the
tip flow's first non-null emission), separate effect keyed on pending
entry count so a fresh UNVERIFIED arrival kicks reverify immediately
instead of waiting up to 60s for the next tip poll.
- `observeNoteZaps`'s memoization now keys on the `onchainZaps` map
reference so lightning-zap traffic on the same note doesn't churn the
onchain gallery.
- `reverifyOnchainZapsForNote` uses `supervisorScope` so a single failed
verifier doesn't cancel its siblings, and the semaphore permits bump
from 4 → 8 reduces head-of-line blocking when many galleries reverify
concurrently.
On resizing CDNs the imeta `x` (post-resize hash) can differ from the
`ox` (original hash) embedded in the URL. The bridge previously preferred
`explicitHash` over the URL's sha for "authoritative casing", but the
upstream file on `xs` is named after the URL's sha, not the imeta hash.
For URLs like https://image.nostr.build/<ox>.png with imeta x=<post-resize>
the cache would request /<x>.png and 404 on miss.
Always use the sha parsed from the URL path; drop the explicitHash
parameter. `extractSha256FromUrlPath` already lowercases, so the casing
concern is moot.
- Add kind 1 (replies) to interaction subscriptions
- Key count reads on FlowSet state for reactive updates
- Wire Quote menu item to ComposeNoteDialog with q-tag support
- Add BoostsPopup on long-press repost icon (who boosted)
- ComposeNoteDialog now accepts quoteOf param with nostr: URI pre-fill
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove premature ensureRelayConnected check — NostrClient connects
on subscribe/publish via sendOrConnectAndSync
- Fix disconnect crash: use appScope instead of rememberCoroutineScope
to survive recomposition when nwcConnection goes null
- Surface balance errors/timeouts as snackbars instead of silent swallow
- Add ensureRelayConnected helper to RelayConnectionManager
- Add Phase 2 embedded wallet research doc
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a kind-23195 event arrives signed by someone other than the wallet
service we sent the request to, we now count it on the pending entry and
leave the entry in place so the legitimate reply can still resolve. But
if no legitimate reply arrives and the 30s timeout fires, the user used
to see a generic "Wallet request timed out" — indistinguishable from
"the wallet is just slow", even when an active attacker was forging
replies and dropping the real ones.
Carry the per-request spoof count through to the timeout error message:
- NwcPaymentTracker.PendingRequest gains an AtomicInteger spoofAttempts.
onResponseReceived increments it on WrongAuthor.
- New tracker method spoofAttemptsFor(requestId) reads the count.
- Account exposes nwcSpoofAttempts() and cleanupNwcRequest() so the
UI doesn't need to reach into LocalCache.
- Account.sendNwcRequestToWallet now returns the request event id so
callers can identify the pending entry.
- WalletViewModel.launchTimeout takes a () -> HexKey? provider and
fetches the spoof count when the timeout fires. The error becomes
"Wallet request timed out — N replies were rejected because they
were signed by an unexpected key. Your relay may be untrusted."
Also calls cleanupNwcRequest on timeout to avoid leaking the entry.
Silent on the happy path: a forged reply followed by the real one does
not trigger any user-facing message — the spoof count is discarded with
the matched entry.
Replace the placeholder "N assets bundled" line with a real list of
compact rows for each `e`-tagged Software Asset in the release.
Each row loads the referenced asset event id through
`LoadAssetNote` (uses LocalCache first, falls back to
`checkGetOrCreateNote` for ids never seen) and then
`observeNoteEvent<SoftwareAssetEvent>` — which both observes the
LocalCache flow and registers the note with `EventFinder` so the
relay round-trips the missing asset event. When the asset arrives
the row recomposes with MIME, version, optional variant, size,
platform chips, and a Download link to the asset url.
The standalone `RenderSoftwareAsset` card (kind 3063 in a feed or
thread) is unchanged; this only fills out the release detail view.
filterMissingChannelsById had an inverted isEmpty() check that emitted
zero filters, so kind 40 was never requested from any relay. Channels
discovered from kind 42 messages stayed as empty stubs unless the
creator also happened to publish a kind 41 metadata update findable on
the same relay — which is why most cards in the Public Chats feed
loaded with no name or picture.
Fix:
- Drop the inverted condition; mapOfSet guarantees non-empty values, so
emit a RelayBasedFilter for every (relay, channelIds) entry.
- Widen the relay set per channel to include the user's search and
indexer relay lists. Falls back to DefaultSearchRelayList /
DefaultIndexerRelayList when those lists are empty.
- Plumb the Account through ChannelFinderQueryState so the assembler
can read the search/indexer flows. Mirrors EventFinderQueryState.