User-facing strings only — "Napplet(s)" → "nApplet(s)" and the static-site
label → "nSite". Code identifiers, resource keys, CLI verbs (amy napplet/nsite),
and unrelated profile "Website" labels are untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Three security hardenings from the nsite/napplet review:
1. Launch-token identity binding. The broker no longer trusts the identity +
declared capabilities sent on each IPC message from the (less-trusted)
:napplet process. The main process now mints a random token at launch
(NappletLaunchRegistry), hands only that to the sandbox, and resolves it back
to the trusted identity/declared set. A compromised sandbox can act only as
the napplet it was launched as — closing cross-napplet coordinate spoofing
(storage + permission ledger).
2. Stop leaking private lists. identity.getMutes/getBlocked read the decrypted
flow, which includes the user's PRIVATE mutes/blocks. Return only the events'
public tags (MuteListEvent.publicMutes / PeopleListEvent.publicUsersIdSet).
3. Make grants visible + anti-phishing chrome. A persistent trusted sandbox bar
(shield + name + tap for "what it can access") the applet can't draw over, and
a live toast when a granted publish/upload/payment runs — so an allow-always
grant can't act silently. Also keeps the host out from under the system bars.
Findings + residual risks (session-scoped grants, per-origin resource consent)
tracked in amethyst/plans/2026-06-22-napplet-nsite-security.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
When a napplet issued several consent-gated calls at once (the common case: it
reads relays + storage + identity on load), each launched a NappletConsentActivity
concurrently. The host can only show one, so the rest were delivered to the
single-top activity and silently dropped — their broker calls hung forever
(storage stuck pending; a subscription's consent lost, yielding 0 events).
Gate the consent-prompt path behind a Mutex on the (per-account, reused) broker
so prompts queue one at a time. After taking the lock, re-read the ledger so a
sibling request for the same capability honors the just-recorded grant instead
of prompting again. Only the prompt is serialized — execute() and already-granted
paths stay parallel. Per-use capabilities (payments) still re-prompt every time.
Adds a regression test asserting 5 concurrent same-capability requests yield
exactly one prompt and never two dialogs at once.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
NappletHostActivity runs in the isolated `:napplet` process, which early-returns
from Amethyst.onCreate to stay key-free and so never initializes the
compose-resources Android context. `Res.readBytes` then threw
MissingResourceException, crashing the host 100% on launch (the napplet feature
could never open).
Read shell.html/shim.js straight from the APK assets (where compose-resources
packages them) via the Activity context instead of the suspending Res accessor.
NappletWebContract now exposes the relative paths + RESOURCE_ASSET_ROOT so the
paths stay single-sourced.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
NappletHostActivity is edge-to-edge by default on recent Android, so the
sandboxed applet/nsite content drew under the status and navigation bars.
Pad the WebView by the system-bar + display-cutout insets so the content
sits in the safe area.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Redesign the shared StaticWebsiteCard (used by the feed AND the napplets browse
screen) to look like an app entry instead of a manifest dump: square app icon
(with a colored monogram fallback), name, a NAPPLET/WEBSITE type label, a short
description, and an Open button. The technical details users don't care about —
declared capabilities, Blossom servers, source URL — move behind a tap-to-expand
"What it can access" disclosure; capabilities are still re-confirmed at the
consent prompt when actually used and remain fully manageable in the permissions
screen.
Add an `icon` tag (NIP-5A/5D) end-to-end:
- quartz: IconTag + siteIcon() accessor/builder, NappletManifest.icon(), and an
icon param on all four site/napplet build() factories (+ round-trip test).
- amy: `--icon URL` on `nsite/napplet publish`, surfaced in the publish output.
- card: renders the icon via Coil, monogram fallback when absent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Replace the bespoke NappletCard with NoteCompose — the same path the main feed
uses for kind 15129/35129 events. This reuses the author header, the shared
StaticWebsiteCard (title/description/source/servers/capability list + Open button
wired to NappletLauncher), and the standard reaction bar (reply/boost/like/zap),
instead of a second hand-rolled card that could drift from the feed and was
missing NoteCompose's timestamp, hidden-user handling, zap-amount menus, etc.
The new top bar + follow-list filter (defaultNappletsFollowList / matchAuthor)
are genuinely new and kept as-is.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
The Napplets browse screen now matches the other feed screens:
- Top bar gains a follow-list FeedFilterSpinner (drawer/back · filter · search +
manage-permissions), persisted in account settings as defaultNappletsFollowList
and applied via a new Account.liveNappletsFollowLists author-matcher so you can
scope the list to a people set (All/Follows/custom).
- Each row is a rich card: author avatar + name, title, description, the declared
capability chips, and the standard reaction bar (reply/boost/like/zap) wired to
the canonical cache Note — so napplets get the same social actions as any event.
Follow-list plumbing mirrors the existing categories (AccountSettings field +
change fns, LocalPreferences persist/load, FollowListPrefs). LoggedInUserPictureDrawer
is now internal so the new NappletsTopBar can reuse the drawer opener.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
When publisher.close() acquires the gate lock before registerAnnounceBidi,
the announce bidi list is empty and no Ended message is sent. Fix by writing
Ended + finishing the bidi in registerAnnounceBidi when publisherClosed is
already true, mirroring what close() does for bidis it owns at close time.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015t3bsJruGerz53yafZyp9n
Add `amy napplet list <author>` and `amy nsite list <author>`: fetch the
author's root + named manifests (15129/35129 for napplets, 15128/35128 for
nsites), keep the latest per identifier, and emit a summary of each (kind, d,
title, description, path count, servers, requires/aggregate, event id,
created_at). Thin assembly over ctx.drain + the quartz manifest accessors.
Harness README notes `amy napplet list` for enumerating what you've published.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Add `amy nsite serve` and `amy napplet serve <author> [--d ID] [--port N]`:
fetch the manifest and serve its content over a local HTTP server, resolving
each request through quartz StaticSiteResolver (blob downloaded from Blossom
and sha256-verified per request, same as the device host) with SPA fallback to
index.html. Lets you open a published site/napplet in a browser to confirm it
loads and routes. (Static content only — a napplet's window.napplet.* runtime
still needs the Amethyst host; documented in the command + harness README.)
Implemented as thin cli glue (StaticSiteServe) over the resolver + commons
BlossomClient + the JDK HTTP server.
Also retire tools/napplet-test/publish.sh now that `amy napplet publish` is the
single source of truth; the harness README documents publish + serve via amy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Add `amy nsite publish <dir>` and `amy napplet publish <dir>` so a static-site
or napplet directory can be shipped to Nostr in one command, building on the
new CLI/Blossom infrastructure.
- commons (jvmMain) StaticSitePublisher: the reusable "upload a tree" half —
walks a directory (or single file), content-addresses each file, BUD-02
signed-uploads it via BlossomClient, and maps it to an absolute web path
(/index.html, /assets/app.js, …). Returns the NIP-5A path→sha256 tags.
- cli StaticSitePublish: thin shared flow — uploads via the commons publisher,
hands the path tags to a kind-specific builder, signs with the account key,
and broadcasts. nsite builds 15128/35128 (+ x aggregate); napplet builds
15129/35129 (aggregate + requires already added by the quartz builder).
- nsite/napplet `publish` verbs wired into their routers.
Test harness README now recommends `amy napplet publish tools/napplet-test`,
keeping publish.sh as a no-amy fallback. Unit test covers the path mapping;
cli + commons build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
A self-contained napplet (tools/napplet-test/index.html) that calls every
window.napplet.* API and renders each result on screen, for verifying the
NIP-5D host end to end on a real device — including the new
identity.getList/getZaps/getBadges, identity.onChanged, keys.onAction, and
resource.bytes nostr: paths.
publish.sh uploads it to a Blossom server (BUD-02) and publishes the NIP-5D
named-napplet event (kind 35129) via nak; README documents the flow and a
per-feature verification checklist. Tooling only — no app code or deps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Upstream reworked kind:10019 to advertise the account's NIP-65 inbox (read)
relays as nutzap-receiving relays (was outbox). Realign amy:
`cashu wallet create` now defaults nutzapRelays to a new
Context.nip65ReadRelays() (kind:10002 read relays, falling back to outbox)
instead of outboxRelays(), so amy's kind:10019 matches the Android wallet's
again. The event's publish destination (anyRelays / sendLiterallyEverywhere)
is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Re-introspected the real nak binary: 34 functional commands. Fixes the
stale count — adds nsite to full (amy has NsiteCommands), corrects missing
to 7, and clarifies group/nip29 is an intentional MLS/Marmot divergence,
not a gap. Drops the stale "validate" cheap-win (key validate shipped).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
The advertised `relay` tags in our own kind:10019 now copy the NIP-65
inbox relay list only, rather than inbox + DM. Inbox relays are the
canonical "where others reach me" set, so they are the natural default
for "where to send me nutzaps". DM relays stay in the wallet's inbound
subscription as a safety net, but don't belong in the public kind:10019.
The publish destination of the kind:10019 event itself is unchanged — it
still broadcasts to our outbox via sendLiterallyEverywhere; only the relay
tags inside the event changed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHcZ2gv8ro9Q2bEiTSiHD8
Complete the NIP-65 outbox-model alignment for NIP-61: the `relay` tags in
our kind:10019 are, per spec, the relays where the recipient *reads*
incoming token events — i.e. inbox-side relays others publish to. We were
advertising our outbox (write) relays there.
Both publish paths (initial wallet creation in CashuWalletViewModel and
P2PK key rotation in CashuWalletState.recreateNutzapKey) now advertise the
union of our NIP-65 inbox + DM relays. This mirrors the relay set the
wallet subscribes to for inbound kind:9321, so senders following our
kind:10019 publish exactly where we listen.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHcZ2gv8ro9Q2bEiTSiHD8
Inbound NIP-61 nutzaps (kind:9321) are messages other people send *to*
the user, so per the NIP-65 outbox model they must be read from the
user's inbox-side relays, not their outbox. The Cashu subscription used a
single relay set (outbox) for both the user's own NIP-60 events and
inbound nutzaps, so a sender following NIP-61 correctly (publishing to
the relays advertised in the recipient's kind:10019, or to the
recipient's NIP-65 inbox) could be missed.
Split the subscription relay sets per filter:
- own NIP-60 wallet/token/history events keep reading from outbox,
where the user published them (needed to restore on a fresh device);
- inbound kind:9321 nutzaps now read from the union of the user's
NIP-65 inbox + DM relays + the `relay` tags in the user's own
kind:10019. The last one is NIP-61's source of truth for "where to
send me nutzaps" and may be written by another client to a relay set
unrelated to our NIP-65 lists, so we listen there too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHcZ2gv8ro9Q2bEiTSiHD8
Update the amy docs to reflect the new command surface:
- cli/README.md — add the Cashu (NIP-60/61), Relay management (NIP-86
admin), and Run-a-relay (serve) sections; document fetch's nip19/nip05
code mode and key validate.
- cli/DEVELOPMENT.md — add cashu.json to the on-disk layout and pin the
command-family --json contracts (cashu keys/error codes + pointer to the
cashu plan, admin {relay,method,result}, serve startup object).
- .claude/skills/amy-expert/SKILL.md — extend the "where things live" tree
with AdminCommand/ServeCommand/cashu/, the commons/cashu + relayManagement
shared modules, and the allowed :geode dependency.
- .claude/CLAUDE.md — note cli may depend on :geode (for serve), never on
:amethyst/:desktopApp.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Bind a napplet's registered keyboard/command actions to real hardware-key
combos and fire them back as keys.action pushes, so napplet.keys.onAction
actually triggers (previously registration was acked but never fired):
- protocol: RegisterAction / ActionRegistered carry the key combo (binding,
from the SDK's action.defaultKey); the codec decodes defaultKey and echoes
binding in the result; encodeKeysAction push envelope added.
- broker: registerAction returns the honored binding (still no key access for
the applet; KEYS stays a declared-only, no-prompt capability).
- NappletKeyActions (host): a registry that parses combos like "Ctrl+Shift+S"
/ "Cmd+Enter" / "F2" and matches them against KeyEvents.
- NappletHostActivity: binds an action only after the broker authorizes it
(from the keys.registerAction.result), unbinds on keys.unregisterAction, and
overrides dispatchKeyEvent to turn a matching combo into a keys.action push
via the shell bridge. Unmatched keys fall through to the WebView, so the
applet's own text inputs keep working. Touch-only devices simply never match.
shim already passed the full action (incl. defaultKey) and wired onAction to
the keys.action push, so no shim change was needed. Conformance test now
covers the defaultKey decode + binding round-trip; all napplet suites green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Wire napplet.identity.onChanged end to end so an applet is notified when the
active user's public key changes (account switch / connect / disconnect):
- shim: onChanged registers a handler and opens a watch (identity.watch) on the
first handler; closing the last one stops it (identity.unwatch). identity.changed
pushes are dispatched to the handlers with the new pubkey.
- router: identity.watch (gated on the IDENTITY declaration) / identity.unwatch
become WatchIdentity / UnwatchIdentity outcomes — a push subscription, like
relay.subscribe, that never reaches the broker.
- NappletIdentityWatch (host): collects the active account's pubkey from the
session manager and pushes identity.changed on each subsequent change (the
current value is dropped — the applet already has it via getPublicKey). Torn
down on unwatch and on service destroy.
- codec: encodeIdentityChanged push envelope.
Router unit tests cover watch (declared/undeclared) and unwatch; commons tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Fill in the identity reads that previously fell through to Unsupported, and
add nostr: resolution to resource.bytes — both reading from what Amethyst
already has locally, matching the @napplet/nap shapes.
identity (AccountIdentityReader):
- getList(listType): public tag values (e/a/p/t/word/r/emoji) of the user's
NIP-51 replaceable list of that type (bookmarks 10003, pins 10001, mute
10000, interests 10015, communities 10004, channels 10005, emojis 10030).
- getZaps: ZapReceipt[] {eventId, sender, amount, content?} from kind-9735
receipts p-tagging the user in the cache.
- getBadges: Badge[] {id, name?, description?, image?, thumbs?, awardedBy}
from kind-8 awards p-tagging the user, resolved against their kind-30009
definitions in the cache.
resource.bytes (NappletResourceFetcher):
- nostr: URIs (NIP-19) resolve to the referenced event's JSON
(application/json). nembed carries it inline; note/nevent/naddr resolve
from the cache then a bounded relay fetch; npub/nprofile resolve the
author's kind-0. Still no direct network for the applet.
The wire codec already routed these (generic IdentityRead + identityResultField,
ResourceBytes), so no protocol change was needed. Commons napplet tests stay green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
- `key validate PUBKEY` — nak's `key validate`: parse an npub or 64-hex
pubkey and report {valid, pubkey, npub}; never errors on bad input
(reports valid:false) so scripts branch on the field.
- `fetch <nevent|naddr|nprofile|npub|note|nip05>` — code mode that resolves
relays the way the Android app opens a shared link: the relay hints
embedded in the nip19 code UNION the author's NIP-65 write (outbox)
relays, draining the author's kind:10002 on a cache miss. This is nak's
`fetch` (nip19-hint resolution); filter mode is unchanged.
Verified: key validate accepts fiatjaf's npub/hex and rejects garbage +
bad-checksum npubs; `fetch <npub>` drained the author's kind:10002,
queried their actual advertised write relays, and returned kind:0.
nak parity: 23 full / 3 partial / 6 missing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
The two largest Android napplet files mixed many concerns. Decompose each
into focused collaborators, behavior-preserving (both napplet test suites
stay green):
NappletBrokerService (641 -> 195 lines) is now a thin IPC shell — Messenger
transport, per-account broker cache, lifecycle — delegating to:
- gateways/AccountNappletGateways: the account -> NappletBroker adapter that
wires all six gateway impls (relay publish/query, consent, wallet/NWC,
resource, identity, upload).
- gateways/NappletResourceFetcher: data:/https:/blossom: resource fetching
with the Tor-aware OkHttp client (sha256-verified blossom blobs).
- gateways/AccountIdentityReader: identity.* reads -> JSON (public data only).
- NappletConsentSummary: NappletRequest -> localized consent dialog text.
- NappletLiveSubscriptions: the live relay subscription registry (open/close/
closeAll, single-EOSE latch, per-sub client for correct teardown).
NappletHostActivity (483 -> 336 lines) keeps the Activity lifecycle, WebView
hardening, and the shell<->broker bridge; the resource edge moves to:
- NappletContentServer: serves the shell + verified blobs (CSP headers, SPA
fallback, shim injection) and owns the disk-cached blob HTTP client.
No wire or policy change; all decode/consent/exec still flows through the
shared NappletRequestRouter + NappletBroker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Two new nak-parity commands:
- `amy admin RELAY METHOD [args]` — NIP-86 Relay Management API over NIP-98
HTTP auth. Full method set: ban/unban + allow/unallow pubkey, ban/allow
event, allow/disallow kind, block/unblock IP, change name/description/icon,
and all list-* queries. Reuses quartz's Nip86Client (request build + NIP-98
auth + parse) and the Nip86Retriever HTTP path — extracted from amethyst to
commons/jvmAndroid so amy and the Android relay-management screen share it.
- `amy serve [--host --port --path --db --admin]` — runs a Nostr relay by
embedding geode (the standalone Ktor relay on quartz's relay-server code).
In-memory by default (ephemeral, like nak serve); --db FILE for SQLite. The
account's own pubkey is always an admin, so `amy admin` works against it out
of the box. cli gains a :geode dependency (geode depends only on :quartz) and
kotlinx-serialization-json (to render NIP-86 JSON results).
Verified end-to-end: `amy serve` + `amy admin ws://127.0.0.1:PORT
supported-methods|change-name|ban-pubkey|list-banned-pubkeys` round-trip
cleanly over real HTTP + NIP-98 against the live geode relay.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Two shared extractions so the future desktop host reuses the exact same
sandbox and feed UI as Android, with no chance of drift:
Shared web contract:
- Move shell.html + shim.js into commons composeResources
(files/napplet/), read via Res.readBytes on any platform.
- New NappletWebContract (commons/commonMain) single-sources the whole
web contract: the shell/shim loaders plus the internal origin/host/URLs
and both Content-Security-Policies (SHELL_CSP, APP_CSP). The Android
host preloads the bytes in onCreate and reads every origin/CSP constant
from NappletWebContract instead of its own duplicated constants and
assets.open() calls.
Shared feed card:
- New StaticWebsiteCard (commons/.../ui/note) renders the inert NIP-5A /
NIP-5D preview card: self-contained with commons compose-resource
strings, LocalUriHandler for links, and inlined card chrome. It takes
an isNapplet flag and an onOpen launch slot, so it never executes applet
code itself.
- amethyst's note/types/StaticWebsite.kt becomes thin event->card
adapters that supply the sandboxed onOpen launch.
Card strings move to commons strings.xml. Both napplet test suites
(commons jvmTest + amethyst) stay green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Move the decode → broker → encode orchestration out of Android's
NappletBrokerService.handleMessage and into a pure, transport-free
NappletRequestRouter in commons/jvmAndroid. It returns a small Outcome
(Ignore / Reply / OpenSubscription / CloseSubscription / Push) that each
host acts on, so the Android service and the future desktop host share
the routing brain and can't drift on wire behavior.
The service now resolves the broker and dispatches on the Outcome,
supplying only the Messenger transport and the live relay subscription.
openLiveSubscription takes the decoded filters from the router instead of
re-decoding the payload, and the now-redundant process() is removed.
Unit-tested in commons/jvmTest (NappletRequestRouterTest).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Two behavioral divergences from the Android wallet, found while auditing
for parity:
- Publish targets: Amethyst sends every cashu event via
sendLiterallyEverywhere (all the user's relays). amy published only to
outboxRelays(); switch to anyRelays() (outbox + inbox + keypackage), the
CLI's closest analog, so the wallet lands on the same broad relay set.
- Nutzap relays on create: Amethyst always advertises the account's outbox
relays in kind:10019 (so senders publish nutzaps where the user reads).
amy defaulted to none; default to outboxRelays() unless --relay overrides.
Also align cashuSnapshot()'s store query with commons'
CashuWalletFilterAssembler exactly (six authored kinds by authors=[pk],
inbound nutzaps by #p) so amy projects the same event set the app
subscribes to. Verified the emitted kind:10019 now carries relay/mint/
pubkey tags identical to NutzapInfoEvent.build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
The notifications feed is dominated by MultiSetCards, each rendering a gallery
of up to 30 author avatars. Profiling a loaded account showed the per-author
work on the main thread, not the GPU, as the scroll-jitter driver.
Three feature-preserving cuts:
- Hoist account-global reads out of the per-author avatar. The auto-play-gif
setting and the logged-in follow set were collected once *per author* (~60
redundant Flow collectors / coroutine launches per card). They are now
collected once per gallery and passed down via LocalAuthorGalleryRenderContext.
- Dedupe the per-author metadata subscription. Each avatar fired
UserFinderFilterAssemblerSubscription twice (via observeUserPicture +
observeUserContactCardsScore); both observers gained a `subscribe` flag so the
gallery subscribes once per author.
- Replace the per-event DateTimeFormatter day-bucketing in convertToCard with a
LocalDate.toEpochDay() Long key (identical grouping, no Instant/ZonedDateTime/
String allocation per reaction/zap/repost), and hoist the ZoneId lookup.
Measured before/after on a Samsung SM-T220 (loaded account, identical scripted
scroll, dumpsys gfxinfo): Slow-UI-thread events ~13 -> ~5 (~60% fewer),
95th-pct frame ~52ms -> ~38ms, janky frames ~13% -> ~10%. GPU timings unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the offline Cashu command tier to amy, all driven by the shared
commons wallet code so amy exercises the same path as the Android app:
amy cashu wallet create [--mint URL] [--mints a,b] [--privkey HEX] [--relay r1,r2]
amy cashu wallet show
amy cashu wallet export-key
amy cashu wallet destroy
amy cashu mint ping URL (stateless)
amy cashu mint info URL (stateless)
amy cashu balance [--mint URL]
- create/destroy reuse commons CashuWalletOps.publishWalletEvents /
deleteWallet; show/balance reuse the CashuWalletReader projection over
the local event store; mint ping/info hit quartz's MintHttpClient.
- Context gains cashuOps() (wired to the file NUT-13 counter store + a
per-run seed cache) and cashuSnapshot(); DataDir gains cashu.json.
- Extraction D: CashuKeysetCounterStore contract in commons +
FileCashuKeysetCounterStore (atomic ~/.amy/<account>/cashu.json).
PRs 4 of cli/plans/2026-05-28-cashu-cli.md (extractions A–D + offline
tier). receive/send/maintenance/mint-rec + interop harness still pending.
Verified end-to-end against mint.minibits.cash and live relays.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Add a pure, stateless CashuWalletReader in commons that projects a stream
of NIP-60/61/87 events into a WalletSnapshot (wallet/nutzap-info events,
decrypted mints, unspent token entries, history, pending quotes, nutzaps,
recommendations, plus balance + per-mint balances).
Android's CashuWalletState keeps its incremental dirty-tracking and
StateFlow plumbing but now delegates the two tricky computations —
del-rollover over decrypted tokens (computeUnspent) and the
destroyed/expired pending-quote filter (computePending) — to the shared
reader instead of carrying its own copies. amy will call project() once
per command over its event store.
Extraction C of cli/plans/2026-05-28-cashu-cli.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Move the NIP-60/61 wallet orchestration layer (CashuWalletOps + its
result types: TokenEntry, MintQuoteStarted, MintCompleted, MeltCompleted,
SendTokenCompleted, RedeemCompleted, NutzapSent, RestoreOutcome,
MigrationResult, CreatedWallet, describeMintError) out of amethyst into
commons/jvmAndroid/cashu/ops.
The class already had zero Android dependencies — it takes signer,
publish, okHttpClient, secretFactory, and the NUT-13 counter callbacks as
constructor params. It lands in the jvmAndroid source set (not commonMain)
because it composes quartz's jvmAndroid CashuMintOperations/MintHttpClient
and uses ConcurrentHashMap. Both Android (amethyst) and the JVM CLI (amy)
can now drive the exact same wallet code path.
This is Extraction B of cli/plans/2026-05-28-cashu-cli.md. Android
callers (CashuWalletState, the wallet ViewModels, AccountViewModel)
updated to the new package; no behavioral change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
Set the desktopApp up to host napplets/nsites by maximizing the shared
core and documenting the edge it must build.
- Moved NappletProtocolJson (the wire codec) from amethyst to
commons/jvmAndroid (package ...commons.napplet.protocol), next to the
NappletRequest/Response types it marshals. It depends only on quartz +
kotlinx.serialization + java.util.Base64 (Android 26+/JVM), so a future
desktop host marshals through the identical object — request/result/push
shapes can't drift between platforms. amethyst host/service/tests updated
to import it; tests stay in amethyst and still exercise it.
- Added desktopApp/plans/2026-06-21-napplet-desktop-host.md: what's already
shared (broker, protocol, codec, resolver, the shell.html/shim.js web
contract), what desktop must build (KCEF/JCEF engine, custom-scheme
serving, isolation, transport, gateways, UI), the decisions to make, a
security-parity checklist, and recommended further extractions
(NappletRequestRouter, shared web assets, the inert feed card).
commons:jvmTest and the amethyst napplet suite pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Code-audit pass over the napplet subsystem (see
plans/2026-06-21-napplet-code-audit.md):
Correctness:
- Live subscriptions now store the exact INostrClient that opened them, so
teardown unsubscribes from the right account even after an account
switch (previously leaked on the original account).
- Multi-relay subscriptions emit a single relay.eose via an eoseSent
latch, instead of one per relay (the SDK expects one).
Performance:
- The broker is cached per account (reference identity) instead of rebuilt
on every request.
- The blob OkHttpClient is cached and reused (keyed by Tor port) for
connection pooling, instead of a new client per fetch.
Refactor / docs:
- Moved the 105-line injected shim from a Kotlin string constant to
assets/napplet/shim.js (loaded once like shell.html); corrected stale
onChanged / subscribe comments.
Deferred (documented with rationale): cross-relay event dedup,
background pause/resume of live subs, request ordering, and the
recommended NappletProtocolJson -> commons/jvmAndroid move.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Implements the remaining tractable conformance follow-ups:
- Live subscription tail: relay.subscribe now opens a real
client.subscribe whose SubscriptionListener streams relay.event
(stored + live), relay.eose, and relay.closed pushes keyed by subId,
instead of a one-shot snapshot. relay.close unsubscribes (tracked in
liveSubs, torn down in onDestroy). The broker only authorizes the
subscription (RELAY consent) and returns Subscribed; the host owns the
live stream. Shim dispatches relay.closed too.
- Multi-filters: relay.query/subscribe honor every filter in filters[],
not just the first (decodeFilterList; gateway query(List<Filter>);
queryEvents unions across filters; max limit applied).
- resource.cancel: accepted at the host edge as a no-op Done.
Conformance tests extended (multi-filter decode, relay.closed push);
commons:jvmTest and the amethyst napplet suite pass.
Still open: identity getList/getZaps/getBadges + onChanged (shapes
underspecified), the keys.action push (needs a host trigger UI), the
resource nostr: scheme, inc + the niche domains, and on-device
verification.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
Fill the KindNames registry from 145 to 280 entries so every event kind
quartz defines a class for has a canonical English label + NIP. Adds the
whole NIP-90 DVM request/response set, NIP-29 relay groups, NIP-60/61
Cashu wallet + nutzaps, NIP-43 relay members, WebRTC calls (NIP-AC),
marketplace (NIP-15), git PRs/state (NIP-34), CLINK, NIP-51 curation
sets, NIP-85 assertions, Marmot MLS events, and more.
For the handful of kind numbers shared by multiple classes, the registry
keeps one canonical entry (e.g. CashuToken over the deprecated nip61
TokenEvent, ExternalIdentities over GalleryList, ReleaseArtifactSet over
SoftwareRelease). Kind 1 stays "Notes" rather than the bounty value-add
helper that reuses it.
Also point `amy nip`'s Nostr fallback at quartz's canonical NipText kind
30817 (NipTextEvent) alongside the wiki and long-form kinds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY