Kinds 0 (profile), 40/41 (channel create/metadata) and 31990 (app handler)
store their data as JSON in content. Implement SearchableEvent on them by
parsing the JSON (via the existing UserMetadata/ChannelData/AppMetadata
accessors) and indexing only the meaningful fields — names, bio/about, and
the addresses people search by: nip05 email, lightning addresses
(lud06/lud16), and website/picture/banner URLs. This avoids indexing the
JSON keys and structural punctuation that raw-content indexing would add.
Updates the FsSearchTest "non-searchable" case to use an unknown kind, since
MetadataEvent is now searchable, and adds SearchTest coverage for profile
and channel JSON fields (name, about, email, lightning, URL).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
Reverts the event_fts rowid-alignment refactor and the background reindex
that it required. Aligning the FTS rowid with event_headers.row_id was a
schema change, which forced a v2->v3 migration to rebuild the index from
~all cached events — and on large caches that reindex was the expensive,
risky part (slow startup, all-or-nothing transaction, resumability and
malformed-row concerns). The cleanup it bought (not tokenizing the numeric
foreign key into the index) isn't worth that cost.
Restores the original design: event_fts keeps its dedicated
event_header_row_id column, queries join on it, DATABASE_VERSION stays 2,
and there is no FTS migration or reindex at all.
Kept: the newly searchable event kinds (they implement SearchableEvent and
work unchanged with the original table) and their test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
The v2->v3 upgrade previously rebuilt the entire full-text index inside
the migration transaction. With a large cache (e.g. 100k events) that
blocked every DB operation behind a single long transaction at startup:
the app appeared frozen, risked an ANR if reached on the main thread, and
— because it was all-or-nothing with the version bumped only on success —
a crash, kill, or one malformed cached row could roll everything back and
retry from scratch on every launch (worst case: an unrecoverable boot loop)
while the WAL ballooned.
Decouple the reindex from the migration:
- The migration now only recreates the empty FTS table and writes a
persistent `fts_reindex` marker holding a progress cursor, then bumps the
version. It is cheap and atomic.
- A background coroutine (Dispatchers.IO, cancelled on close()) backfills the
index from event_headers in small committed batches via useWriter, so live
relay inserts/queries interleave between batches instead of waiting.
- Backfill is idempotent (INSERT OR IGNORE), resumable (cursor persists, so a
kill resumes on next launch), and resilient (a row that fails to parse/index
is skipped while the cursor still advances — no stuck retries).
Search is merely degraded (partial results) until the backfill finishes,
never blocked. Adds a test covering backfill + marker clearing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
Several event kinds carry human-readable text (titles, summaries,
descriptions, names, free-text content) but were never added to the
full-text search index. Implement SearchableEvent on them so their
natural-language fields become searchable, while keeping non-prose data
(hex ids, URLs, relay hints, hashtags, geohashes, JSON config) out of the
index.
Kinds added:
- Classifieds (30402): title + summary + content
- Calendar (31924) + date/time slots (31922/31923): title + summary + content
- Community Definition (34550): name + description + rules
- Live Activities (30311): title + summary + content
- Meeting Space (30312): room + summary
- Status (30315): content
- Picture (20) and Video (NIP-71, all variants): title + content
- Goal (9041): summary + content
- Torrent (2003): title + content
- Git Repository (30617): name + description
- Git Pull Request (1618): subject + content
- Git Patch (1617): content
- Badge Definition (30009): name + description
- Emoji Pack (30030): title + description
- Feed Definition (31890): title only (content is JSON config)
JSON-content kinds (profile, channel, app handler) are intentionally left
out for now since they require parsing the content JSON to extract only
the natural-language fields. Existing v2->v3 FTS reindex repopulates the
index for already-cached events of these kinds on upgrade.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
The FTS table declared event_header_row_id as a regular full-text column,
which means the numeric foreign key was tokenized into the searchable
index — a bare MATCH could match an event by its internal row id, and the
column wasted index space.
Drop the dedicated column and instead align the FTS table's implicit
rowid with event_headers.row_id at insert time, joining on it (rowid
joins are also the fastest possible). This works across fts3/4/5.
Also make FullTextSearchModule.drop() remove its trigger explicitly so
the module is self-contained, and add a v2->v3 migration that rebuilds
the FTS index in place from event_headers, preserving the cached events.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
ZapPollEvent.indexableContent() concatenated a List onto a String, so
String.plus(Any?) appended the list's toString() — leaking literal "[",
"]" and ", " separators into the full-text index
(e.g. "Best color?[\nOption: Red, \nOption: Blue]"). Build the string
explicitly so only the natural-language poll descriptors are indexed,
matching the buildString style used by the music events.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFdWREvyvixRXnmNXNzmmN
Adds two user-facing teardown options to the Cashu wallet settings:
- "Stop receiving nutzaps": replaces kind:10019 with an empty event (the
durable signal, honored by every relay since it's a replaceable-event
replacement) and then NIP-09 deletes it (best-effort, since deletions are
optional on Nostr). The wallet and balance are untouched.
- "Delete wallet": withdraws the nutzap advertisement as above, then NIP-09
deletes the kind:17375 wallet definition. Held kind:7375 proofs are not
deleted (the ecash still exists at the mint), with a UI warning that any
remaining balance / unredeemed nutzaps may become unrecoverable.
The on-disk backups of kind:17375 / kind:10019 are cleared when those events
are deleted, so a relaunch doesn't resurrect a deleted wallet from settings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXRAunSJS2dBx7B79qTMew
The NIP-31 "alt" summary for kind:1 notes was built with msg.take(50),
which counts UTF-16 code units. When the 50th unit landed between the two
halves of an astral character (e.g. the 🫡 emoji, U+1FAE1), it left a lone
surrogate at the end of the alt tag.
A lone surrogate is unencodable as UTF-8: it is kept in memory while the
event id is hashed (so the external signer signs that id), but it is
replaced by '?' the moment the event is serialized to a relay. Every relay
then recomputes a different id and rejects the event as having an invalid
id — making the affected note impossible to post.
Add a surrogate-aware String.takeKeepingSurrogatePairs() helper and route
TextNoteEvent's alt summary and the clink OfferClient description trim
through it. Adds regression tests covering the reported note and the helper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HTHsaW6FVjvPqnrSGiT5ee
MeltProcessor (the "Redeem received cashu token → my Lightning address"
button) was hand-coded against the deprecated pre-v1 Cashu API (POST /melt
and POST /checkfees with {pr, proofs}). Those endpoints are gone on CDK and
other modern mints, and the path never accounted for NUT-02 per-input fees,
so it failed on fee-charging keysets the same way the wallet melt did.
Route it through the same NUT-05 CashuMintOperations the NIP-60 wallet uses:
requestMeltQuote + meltProofs. A probe quote at the full token value reveals
the LN fee_reserve, to which we add inputFeeFor(proofs) before fetching the
real invoice for (total − fees) and melting. No change is requested — there
is no wallet to hold leftover proofs, so the unused reserve stays with the
mint, matching the legacy behavior.
Supporting changes in CashuMintOperations:
- meltProofs gains requestChange (default true) so the redeem path can melt
without minting orphan change outputs.
- inputFeeFor(proofs) exposes the per-keyset NUT-02 fee for invoice sizing.
Also drops the dead empty melt(...) overload that was a stub with a TODO.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Ffjz3doZ5CtFtAWSpvmqR
meltToLightning selects proofs covering amount+fee_reserve and, on
overshoot, swaps them down to exactly that before melting. But the melt
mints its inputs on the active keyset and the mint then charges its own
NUT-02 input fee on them — which the swap-down target didn't include. On a
fee-charging mint (mint.coinos.io active keyset = 100 ppk) the melt was
left a sat short and threw "Inputs total X < required Y", so fixing the
per-keyset swap fee alone just moved the failure from the swap to the melt.
Reserve activeKeysetInputFeeFor(required) on top of amount+fee_reserve for
both proof selection and the swap-down target so the subsequent melt has
room for its input fee.
Also fix the reported fee in MeltCompleted: the pre-paid swap "keep" was
split off before the melt and never spent, so subtract it instead of
counting the whole selected total minus change (which overstated fees by
the keep amount in the swap path).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Ffjz3doZ5CtFtAWSpvmqR
When melting/swapping Cashu proofs, the wallet computed the NUT-02 input
fee from the mint's currently-active keyset (`keyset.inputFeePpk`) for
every input. But NUT-02 charges the fee per the keyset each input proof
was minted under, which can be an inactive, rotated-out keyset with a
different fee.
On mint.coinos.io the old keyset (004f7adf2a04356c) charges 0 ppk and the
active keyset (007311aa2fa58cc8) charges 100 ppk. A wallet holding proofs
on the old keyset reserved a fee the mint never takes, leaving the swap
outputs one sat short — surfacing as "Mint Error (HTTP 400), inputs 84 -
fees 0 vs output (83) are not balanced" when sending over Lightning.
Fee is now `ceil(sum(input_fee_ppk_i) / 1000)` over each input's own
keyset, read from /v1/keysets (which lists inactive keysets too, unlike
/v1/keys). Applied in swap, swapToLocked, and meltProofs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Ffjz3doZ5CtFtAWSpvmqR
Add in-playlist track management to the music playlist composer: each track in
the working list shows its artwork/title/artist with move-up, move-down and
remove controls. The list is seeded from the loaded event when editing and
published in its new order on save. Adding new tracks still happens via the
per-song "Add to playlist" sheet.
- quartz: MusicPlaylistEvent.edit() now takes the ordered track list and resets
the playlist's music-track `a` tags to it (preserving any non-track `a` tags,
the d tag, custom hashtags and other metadata). Add MusicPlaylistEventEditTest
covering reorder, removal, visibility switch, cover/description clearing and
tag preservation.
- amethyst: NewMusicPlaylistViewModel gains the working track list plus
moveTrackUp/moveTrackDown/removeTrackAt; NewMusicPlaylistScreen renders the
editable track section; new string resources.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013oofoSH7eMXrs2TCU4uncS
Replace the name-only "new playlist" dialog with a full-screen create/edit
composer reachable via a dedicated route (Route.NewMusicPlaylist).
The composer surfaces a cover-image upload (same Blossom/NIP-96 pipeline as the
music-track composer), plus title, short description, long-form notes, a
public/private toggle and a collaborative toggle. An edit affordance now appears
on the user's own playlist cards.
- quartz: add MusicPlaylistEvent.edit() — updates the composer-owned metadata
while preserving the track `a` tags and every other tag of the prior version.
- amethyst: NewMusicPlaylistViewModel + NewMusicPlaylistScreen (create/edit/
delete); extract the shared cover-picker/placeholder/progress-banner into
MusicComposerUploadUi so the track composer reuses them; FAB now navigates to
the route; add edit icon on owned playlist cards; new string resources.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013oofoSH7eMXrs2TCU4uncS
Make every module read its release version from gradle/libs.versions.toml
instead of carrying its own literal, so a release bump is a single-file edit.
- Move the Android versionCode out of amethyst/build.gradle.kts into the
catalog as `appCode`; amethyst reads it via libs.versions.appCode.get().
- quartz publishes with version = libs.versions.app.get().
- geode generates a BuildConfig.VERSION from the catalog (new
generateVersionFile task) and RelayInfo.VERSION reads it, so the NIP-11
software version tracks releases.
- Update the root build comment to point at the appCode entry.
Version-neutral: everything still resolves to the current 1.12.0 / 448.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds foreground auto-detection of finished workouts on Android via Google
Health Connect — the single aggregator every Android health source funnels
into (Samsung Health/Galaxy Watch, Google Fit, Fitbit, Garmin, Strava), the
same Android path RUNSTR uses. On opening the Workouts screen, Amethyst scans
Health Connect for sessions the user hasn't handled and surfaces a banner that
opens the existing workout composer pre-filled, ready to publish as a NIP-101e
WorkoutRecordEvent (kind 1301).
- HealthConnectManager reads ExerciseSessionRecord + aggregated distance,
calories, heart rate, steps and elevation, mapping each to DetectedWorkout.
- ExerciseTypeMapper maps Health Connect activity types to NIP-101e verbs.
- HealthConnectStore remembers handled sessions per account so each is
offered once; 7-day foreground lookback, no background service.
- WorkoutSuggestions banner: connect prompt (on-demand permission request,
never on cold start) or detected-workout rows on the Workouts screen.
- Route.NewWorkout carries optional pre-fill; NewWorkoutViewModel publishes the
richer metrics (heart rate, steps, elevation, start time) with
source=health_connect (new SourceTag constant in quartz).
- androidx.health.connect:connect-client (Apache-2.0) + read-only health
permissions and the required privacy-rationale manifest entries.
Design notes in amethyst/plans/2026-06-16-health-connect-workout-detection.md;
background ~15-min polling + notification left as a documented future seam.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
Before Tor finishes bootstrapping, the relay pool dialed every Tor-routed
relay against the not-yet-listening SOCKS proxy. On a cold start this was
~580 doomed dials (all "SOCKS: Connection refused") concentrated in the
seconds before Tor went Active, churning sockets/CPU and inflating each
relay's backoff. The cost scaled with bootstrap latency, and the same
storm recurred on every network switch (which resets and re-bootstraps Arti).
Add an optional WebsocketBuilder.canConnect(url) gate (defaults to true,
so other implementors are untouched), checked at the top of
BasicRelayClient.connect() before the mutex/onConnecting/build — so a
gated relay opens no socket, fires no listener events, and grows no
backoff. The Android builder gates Tor-routed relays on
torManager.isSocksReady(); RelayProxyClientConnector already reconnects
them with ignoreRetryDelays=true the instant Tor flips to Active, so they
dial as soon as the transport is usable.
Measured on-device: pre-ready doomed Tor dials 581 -> 0 across cold starts
and WiFi<->Mobile switches; clearnet connections stay untouched and Tor
relays self-heal once Tor is Active.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A relay that accepts the WebSocket handshake and then immediately resets
the connection (e.g. essayist.decentnewsroom.com) defeated the exponential
reconnect backoff.
The RelayStat.lastConnectAt / lastIncomingAt fields added in #3186 were
written on every connect and every incoming relay message (a TimeUtils.now()
call plus a volatile store on the Android hot path) but never read.
Captures the verified current behavior (author-keyed DeletionIndex, no
wrap→rumor cascade, accidental seal-id blocking) and the agreed design:
recipient special case in hasBeenDeleted, recipient field on HostStub,
and the reverse-lookup live cascade in LocalCache.
https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo
Searching for an app name (e.g. "Amethyst") was returning every event
published through that client, because the local-cache note search
matched the search term against all tag values including the NIP-89
["client", ...] tag. Skip the client tag when matching tag values in
findNotesStartingWith.
https://claude.ai/code/session_01YMs6aXuvs5NaYjzyPH6Zqj
Surfaces relays unresponsive for 7+ days across the user's NIP-65 (10002),
DM (10050), and Search (10007) relay lists. A non-modal banner appears
above feed columns (and above the single-pane content) whenever the
classifier finds anything; tapping it opens an anchored Popup with one
row per unhealthy relay and per-row Remove / Open Dashboard / Snooze 7d
actions plus a banner-level "Snooze all 7d".
Quartz
- RelayStat gains best-effort lastConnectAt + lastIncomingAt timestamps
(epoch seconds, 0 = never observed). RelayStats listener pushes them
on onConnected / onIncomingMessage. Durable per-relay history lives
outside quartz in the commons RelayHealthStore.
Commons (new commons/relays/health/ package)
- classifyRelayHealth() pure function with the v1 gates:
* first-run grace (don't flag for 7d after firstScanAt)
* offline grace (don't flag if no relay anywhere has responded)
* Tor-mode skip (relay timing is intentionally lossy through Tor)
* per-relay snooze (snoozedUntil > now)
* 10006 (blocked) excluded from detection but still part of the
multi-list Remove action
- RelayHealthStore (account-scoped, supervised scope, 5s debounced
persist, 60s ticker for snooze expiry).
- RelayHealthListener wires the quartz lifecycle into the store.
- RelayHealthPersistence interface (no expect/actual — single impl per
platform via injection).
- RelayListMutator interface + RelayRemovalResult sealed type.
- Shared UnhealthyRelayBanner (errorContainer @ 50% alpha) and
UnhealthyRelayRow (static outlined tag chips, no ripple) composables.
- 8 classifier unit tests covering each gate + multi-list membership.
Desktop wiring
- PreferencesRelayHealthPersistence (java.util.prefs.Preferences, per
account via 8-char pubkey prefix).
- DesktopRelayListMutator runs the 4 sign-and-broadcast jobs in
parallel via async/awaitAll so a slow NIP-46 bunker doesn't multiply
latency by 4.
- Banner placed in DeckColumnContainer + SinglePaneLayout, store +
listener + per-account scan trigger wired in Main.kt's MainContent.
Scope: Desktop only for v1. Android wiring is intentionally not in
this PR — the commons module is platform-neutral and ready for Android
to follow whenever someone wants to pick it up.
Redesign the three payment cards rendered in the middle of a post
(Lightning invoice, CLINK Offer, Cashu token) around a shared PaymentCard
scaffold that follows the wallet screens' Material3 idiom: tonal card,
icon + label header with a copy action, centered headline amount, and a
full-width themed Pay/Redeem button (no more hardcoded white text or
7sp mint lines).
Descriptions were not being rendered at all:
- BOLT-11: LnInvoiceUtil only decoded the amount from the HRP. Add
tagged-field parsing (description 'd', expiry 'x', timestamp) with
BOLT-11 spec-vector tests; the invoice card now shows the memo and
flags expired invoices (Pay disabled). Desktop card shows it too.
- Cashu: V3 'memo'/'unit' and V4 'd'/'u' were parsed then dropped.
CashuToken now carries them; the card shows the memo and no longer
mislabels non-sat units (usd/eur cents formatted as decimals).
- CLINK Offers: the card now shows who gets paid (avatar + name from
the pointer's pubkey, tappable to the profile).
https://claude.ai/code/session_019VuZ4y3ij6Ly4VVExE1W1W
- Profile 'Apps' section now mirrors the Badges component: header with
count and a Settings icon (own profile only) that opens a new
management screen at Route.ProfileAppRecommendations.
- New ProfileAppRecommendationsScreen lists known kind 31990 app
definitions (recommended first) with toggles that publish/remove the
per-kind 31989 recommendation events, backed by a new relay
subscription for the user's 31989s and recent 31990 candidates.
- Account gains recommendApp/unrecommendApp with mutex-serialized
read-modify-write per d-tag, mirroring the profile-badges flow.
- Profile recommendations render as logo+name pills instead of bare
35dp icons; in-post app definition cards now show platform
availability (web/android/ios), handled event kinds as chips, and a
Recommend/Recommended button.
- Quartz: AppDefinitionEvent.platformLinks() reader and
AppRecommendationEvent.buildFromTags() to rebuild a 31989 while
preserving other apps' tags; round-trip tests included.
https://claude.ai/code/session_015dX5vWqvXUYD8rzPYX8vTB
Delivery metadata no longer lives on quartz event classes. The mutable
host var on @Immutable events (with its dual @Transient annotations) is
gone, and any event kind can now be a rumor without subclassing anything
— kind-14 chats and kind-1 private replies use one mechanism.
- commons RumorHosts: rumor id → delivering envelope (the kind-1059
wrap normally, a bare kind-13 seal otherwise), populated by the
gift-wrap ingestion pipeline from the publicNote threaded through the
handlers (the seal's host pointer was never needed)
- Note.toNEvent cites the envelope for ANY rumor — this also fixes
kind-1 private replies, whose nevent previously exposed the private
rumor id (kind-14s were already wrap-cited)
- Account: rumorHost() reads the index; relay computation refuses
seals, inner DM messages, and unsigned rumors explicitly
- LocalCache: deleteWraps → deleteEnvelopes (also removes the seal
layer the old host-chain walk missed); removeIfWrap and chat-history
pruning read the index; index entries are dropped with their rumor
- quartz: SealedRumorEvent and BaseDMGroupEvent extend Event directly;
GiftWrapEvent.unwrap no longer injects host stubs; WrappedEvent
deleted
https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo
Quartz: new experimental/fitness/workout package shaped like nip88Polls —
WorkoutRecordEvent with per-tag classes (exercise, duration, distance,
elevation, calories, steps, heart rate, splits, strength sets/reps/weight,
source, workout_start_time), TagArrayBuilder/TagArray extensions, lax
RUNSTR-dialect parsing (unit defaults, HH:MM:SS or raw seconds), and
EventFactory + LocalCache registration. Covered by fixture tests.
Amethyst: new Workouts feed (drawer entry, route, follow-list top bar,
per-relay filter assemblers mirroring the Pictures feed) with a + FAB
opening a manual workout composer that publishes canonical kind-1301
events. Workout cards render stats chips and also display inside threads
via NoteCompose. Adds fitness Material Symbols glyphs and regenerates the
subset font.
https://claude.ai/code/session_01Kpx53UEeJqqR7CASzMu6GB
All CLINK tests failed on iosSimulatorArm64 with IllegalArgumentException
because OptimizedJsonMapper on native dispatches through
KotlinSerializationMapper, whose fromJsonTo/toJson type lists did not
include the CLINK payload DTOs (Jackson handles them reflectively on
JVM/Android, which is why only iOS failed).
Adds hand-written kotlinx serializers for OfferRequest/OfferResponse/
OfferReceipt, DebitRequest/DebitResponse, and ManageRequest/ManageResponse,
mirroring Jackson behavior: ManageResponse.details coerces a lone object
into a one-element list (ACCEPT_SINGLE_VALUE_AS_ARRAY) and
OfferRequest.payer_data round-trips as a free-form JSON object.
Covered by a JVM test driving KotlinSerializationMapper directly and
cross-checking against Jackson, since the native path shares this code.
https://claude.ai/code/session_01SevV4fUCumKZ1UscSz85vS
Lightning payment targets already route into the Send Payment screen;
this extends the same treatment to bitcoin targets. Tapping a profile's
bitcoin payment-target chip (or its pay action in the wallet-button
dialog) now opens the Send Payment screen with the on-chain rail locked
to that announced address, paid directly from the user's NIP-BC Taproot
wallet — falling back to the external bitcoin: URI when the chain
backend is missing or the address isn't a payable native-segwit mainnet
address.
- quartz: SegwitAddress.scriptPubKeyFor/isPayableMainnetAddress;
OnchainZapBuilder.buildToScripts core shared by the pubkey paths.
- commons: OnchainZapSender.sendToAddress — plain wallet send with the
same fund-safety signing contract but no kind:8333 receipt (the
destination isn't pubkey-derived, so none is possible); the signing
block is now a single shared helper across send/sendSplit/sendToAddress
and Success.receiptEventId is nullable for receipt-less sends.
- amethyst: Account.sendOnchainToAddress; Route.SendPayment gains
btcAddressOverride; a shared inAppPaymentRouteFor() decides which
payment targets the user's wallets can pay in-app (used by both the
target chips and the payment-targets dialog).
- Send Payment screen: with an address override the on-chain rail shows
the target address, hides the message field (no receipt to carry it),
explains that no zap receipt is published, and dispatches the plain
address send.
https://claude.ai/code/session_01UERRsbDoRPz46Qx5HCXgAa