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.
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.
Addresses the 15 findings from the high-effort code review on top of the
optimistic-attach fix. Notable behavior changes:
- Per-source removal: `Note.removeOnchainZapForSource(txid, pubkey)` only
drops an entry whose source matches, preventing a spoofed kind:8333 with
the same txid but a bystander recipient from erasing a legitimate
CONFIRMED entry. Rejected (txid, sender) pairs are recorded so a fresh
event id from the same attacker no longer re-flickers into the gallery.
- Sender-only optimistic attach: only the user's own outgoing zap (relay ==
null path) gets the optimistic UNVERIFIED entry. Incoming zaps render
only after on-chain verification, so an attacker-controlled `amount` tag
can't briefly mislead viewers. `claimedSats` is clamped >= 0.
- Reverification across every screen: the chain-tip poller moves from the
thread screen into `LocalCache.onchainTipHeightFlow` (lazy, shared,
WhileSubscribed). The onchain-zap gallery itself drives reverification
whenever it composes with non-CONFIRMED entries — covers home feed,
notifications, profile, channel and single-note views. The gallery
observes the tip flow and the note's zap state, so new arrivals while
the gallery is on screen are picked up too.
- Verifier fan-out + parallelism: re-arrivals skip the verifier launch
when every target note already holds a CONFIRMED entry for the txid.
`reverifyOnchainZapsForNote` now runs verifier calls in parallel,
capped by a 4-permit semaphore.
- Monotonic upgrade based on explicit `OnchainZapStatus.level` instead of
`ordinal`, with a unit test locking the order. Same-level entries with a
larger `verifiedSats` are accepted so a stale indexer estimate isn't
permanent.
- Cancellation propagation: `catch (Throwable)` rethrows
`CancellationException` in `verifyAndUpgradeOnchainZap` so screen-scoped
callers tear down cleanly.
- Memory visibility: `Note.onchainZaps` is `@Volatile` since the
reverification driver reads it on Main while the IO scope writes.
Outgoing onchain zaps never appeared in the sender's thread view because
LocalCache.consume(OnchainZapEvent) ran the chain verifier milliseconds
after the broadcast — before the backend's indexer had picked up the
transaction. The resulting TX_NOT_FOUND rejection skipped addOnchainZap,
and the duplicate guard blocked re-verification when the same event
later echoed back from relays.
Attach kind:8333 entries optimistically as UNVERIFIED with the claimed
amount so the sender sees their zap on the thread immediately, then
upgrade to PENDING/CONFIRMED as the chain catches up. Hard rejections
(zero-paid-to-recipient, missing tags) drop the entry; transient
TX_NOT_FOUND keeps it UNVERIFIED for a later retry. ThreadScreen now
re-verifies non-confirmed entries on view and again whenever the chain
tip advances.
The OnchainZapSendDialog promised a sticky bottom Send button, but the
inner scrollable Column had no weight and the outer Column did not fill
the sheet height. In split mode, SplitsRecipientSection grew the content
past the sheet viewport and pushed the Send button off-screen.
Add ChannelMessageEvent.KIND (NIP-28 kind 42) to the Notifications feed's
allow-list so channel messages that p-tag the user surface in the in-app
Notifications screen, alongside text-note mentions and DMs.
The relay subscription already requests kind 42 with #p (see
FilterNotificationsToPubkey.NotificationsPerKeyKinds), and push notifications
already route ChannelMessageEvent through notifyMention. Only the in-app
feed filter was rejecting them at the kind check; the existing
tagsAnEventByUser gate handles the per-kind "is this for me" rule via the
BaseNoteEvent branch (reply-to-me, cited-author, citation-in-content).
ReactionsRow zap popup
- Merge the Lightning and on-chain chips into a single FlowRow so all
amounts wrap together instead of stacking on two lines.
- Drop the on-chain row's own Tune (settings) button; the Lightning
row's Tune is the single entry point to the settings screen.
UpdateZapAmountDialog
- Reorder sections: Quick Zap Amounts, Zap Privacy, Quick On-chain Zap
Amounts, Nostr Wallet Connect — privacy now sits next to the
Lightning amounts it actually applies to, and on-chain (which has no
privacy concept) is grouped further down.
OnchainZapSendDialog
- Move the preset amount chips above the sats text field in
AmountSection so the user sees the quick picks first and the
free-form input second.
Defaults
- DefaultOnchainZapAmounts is now [10_000] (just one chip) so a fresh
install / first-upgrade shows 4 chips total in the popup (3
Lightning defaults + 1 on-chain default), not 6. kotlinx
serialization defaults handle the back-compat for existing users
who never set their on-chain choices explicitly.
Migrating the mention TextFields to BasicTextField(state) routed them
through ThinPaddingTextField, which uses TextFieldDefaults.DecorationBox
(filled, surfaceVariant container). That replaced the original
OutlinedTextField look — visible rounded border + transparent inside —
with a filled gray rectangle. On ForwardZapTo the change was especially
visible since the original outlined border was the only visual cue that
the search row was an input.
Add OutlinedThinPaddingTextField, a sibling of ThinPaddingTextField that
wraps the same TextFieldState pipeline with
OutlinedTextFieldDefaults.DecorationBox + Container. Same thin padding,
same onTextChanged/inputTransformation/outputTransformation surface, but
genuinely outlined (notched label, transparent inside).
- ForwardZapTo: switch to the new component, drop the
focused/unfocusedIndicatorColor=Transparent overrides — defaults give
the proper outlined border the original OutlinedTextField had.
- EditPostView.MessageField: switch to the new component and use
OutlinedTextFieldDefaults.colors(focusedBorderColor=Transparent,
unfocusedBorderColor=Transparent) to keep the inner border invisible
so the existing 1dp Modifier.border(surface, RoundedCornerShape(8.dp))
remains the only visible frame, matching the original.
The lead-time chip label "%1$d min" pairs a count with a noun, which
inflects in Slavic / Baltic languages. Switch it from <string> to
<plurals> in the default locale and in the three locales that already
have a translation (pl-rPL, zh-rCN, hu-rHU), and update the single
caller in CalendarReminderSettingsScreen to use pluralStringResource.
The abbreviation "min" / "min." / "perc" / "分钟" doesn't decline with
the count in any of these specific languages, but the resource shape
is now correct for future locales that do (e.g. Russian, Ukrainian).
Mocks a kind-3 ContactListEvent with nine p-tags and renders
DisplayContactList in a ThemeComparisonColumn so the card can be
inspected in the IDE preview pane.
Captures the rule we just hit twice in a row (hashtag-limit + calendar
count): any count-bearing string must be <plurals>, every locale must
include the CLDR categories it actually uses, and don't hardcode "1" in
quantity="one" items. Loads automatically whenever a file under res/ is
edited.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Taproot address is derived from the Nostr pubkey, so copying it out
is the moment a user is about to share an account-linked, publicly
auditable wallet. Gate the Copy button on the Onchain card with the same
explanation shown by the Public chip, and offer a "Don't show again"
opt-out persisted in the existing UI preferences DataStore.
The NIP-52 calendar feature shipped two count-bearing strings as <string>
resources ("%1$d events" and "Starts in %1$d minutes"), which forces the
wrong noun form in Slavic languages where the declension depends on the
threshold integer. Convert both to <plurals> in the default locale and in
zh-rCN/pl-rPL/hu-rHU (the locales that already have translations), and
update the 4 callsites — 3 Composables use pluralStringResource and the
CalendarReminderWorker uses the pluralStringRes helper.
pl-rPL and hu-rHU keep only the "other" quantity, preserving existing text
without regression; proper CLDR fan-out (one/few/many for Polish, one for
Hungarian) will come from Crowdin or a native translator.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a NoteCompose render for kind-3 ContactListEvent showing a label with
follow count and a row of the first 6 user avatars (with a "+N" overflow
chip). Tapping the row opens a new ContactListUsersScreen that lists every
followed user with the same UserCompose row used on the search screen.
From an independent audit + my own pass, addressing concrete issues:
OnchainZapSendDialog
- Fee estimate fetch now retries with bounded backoff (4 tries, 1/2/3s
spacing) instead of giving up after one attempt. Covers two real
boot races: LocalCache.onchainBackend not yet wired at first
composition, and a flaky feeEstimates() call. Without retry the
Send button stayed permanently disabled.
- SplitsRecipientSection now indexes preview shares by pubkey once
via remember(previewShares) { associateBy { ... } } instead of an
O(N²) firstOrNull lookup per split row.
- belowDustShares is now wrapped in remember(previewShares) so it
doesn't re-filter the list on every recomposition.
- canSend now also requires resolvedRecipient != senderPubKey in
single-recipient mode, so the user can't tap Send when the only
fallback recipient is themselves (would fail at the builder's
"cannot zap yourself" check).
- formatWeight no longer prints "50.0%" for whole-percent shares —
trailing ".0" is stripped (was a Double->String artifact).
OnchainZapSplitter
- Added distributeUnchecked(): same allocation as distribute() but
never throws on dust; returns every share so the UI preview can
render the full shape in one pass. distribute() (used by the
build/send path) still throws via DustRecipientException so the
real send keeps its dust gate.
- Added check(remainder < splits.size) before the remainder loop to
pin the invariant that bounds remainder.toInt() and the k % size
defensive mod.
- Test for distributeUnchecked.
ReactionsRow / ReusableZapButton / ZapCustomDialog
- baseNote.toEventHint<Event>() is now wrapped in remember(baseNote)
in all three dialog launchers so it's not allocated on every
parent recomposition.
The legacy VisualTransformation paired with TextField(value, onValueChange)
goes through Compose Foundation's LegacyCursorAnchorInfoBuilder, which
throws "endOffset must be greater than startOffset" when the IME has
requested CURSOR_UPDATE_MONITOR and the field's visible bounds momentarily
collapse to zero width. The OutputTransformation API paired with
BasicTextField(state: TextFieldState) routes through a different,
non-legacy cursor anchor info controller and does not hit that bug.
Migrate the two remaining VisualTransformation call sites
(EditPostView.MessageField and ForwardZapTo) to the OutputTransformation
+ TextFieldState path. This requires:
- IZapField: forwardZapToEditting becomes TextFieldState; the
TextFieldValue-shaped updateZapForwardTo() callback is replaced by
onForwardZapTextChanged() which reads the current state.
- 8 ViewModels (ChannelNewMessage, ChatNewMessage, NestNewMessage,
NewProduct, LongFormPost, NewPublicMessage, ShortNotePost,
CommentPost): clear via clearText() instead of reassigning a new
TextFieldValue; rewrite onForwardZapTextChanged() to read from
forwardZapToEditting directly.
- ForwardZapTo: switch from OutlinedTextField to ThinPaddingTextField
with MentionPreservingInputTransformation +
UrlUserTagOutputTransformation.
- EditPostViewModel: message becomes TextFieldState; updateMessage()
becomes onMessageChanged() (the state updates itself); the in-place
replaceCurrentWord/insertUrlAtCursor TextFieldState overloads
replace the value-based ones.
- EditPostView: MessageField now uses ThinPaddingTextField with the
OutputTransformation. Also drops a stale commented-out subject row
that still referenced the legacy transformation.
- Delete UrlUserTagTransformation and its androidTest (no callers
remain).
- Remove MainThreadCrashGuard and its hook in Amethyst.onCreate: the
legacy cursor-anchor-info crash surface is gone with the remaining
mention TextFields, so the framework workaround is no longer
earning its keep.
Adds a "Send on-chain instead" link button at the bottom of
ZapCustomDialog. Tapping it opens OnchainZapSendDialog with the entered
amount and message prefilled, the note as zappedEvent, and the same
split-detection / dust-preview / fee-tier picker that the Reactions
zap chip uses.
This also fixes the participant zap path in nests audio rooms
(ParticipantHostActionsSheet long-press → "Zap") since that menu item
opens ZapCustomDialog under the hood — fixing the custom dialog covers
both entry points transitively.
OnchainZapSendDialog gains a `prefillComment: String = ""` parameter so
the message field carries over from the LN dialog.
Poll-note zaps (FilteredZapAmountChoicePopup) intentionally stay
Lightning-only — the poll_option vote is encoded in the kind:9734 zap
request and counted via kind:9735 receipts; kind:8333 on-chain
receipts have no poll_option analog and the count infrastructure
doesn't index them. This is a protocol design constraint, not a UI
oversight.
Coverage audit summary — every user-facing zap initiation point now
has on-chain support except the poll-voting path:
- ZapReaction (reactions row) ✅
- ZapAmountChoicePopup (popup chips) ✅
- ZapCustomDialog (custom amount + message) ✅ (this commit)
- ReusableZapButton (Zap the Devs + DVM buttons) ✅
- NestActionBar (audio room) ✅
- ParticipantHostActionsSheet (audio room participant) ✅ (transitive)
- ChatMessageCompose / live-activity headers (use ZapReaction) ✅
- FilteredZapAmountChoicePopup (poll votes) ⚠️ LN-only by protocol
ReusableZapButton now passes the user's on-chain zap amount choices to
ZapAmountChoicePopup and renders OnchainZapSendDialog when a chip is
tapped. The dialog receives the release note as the zappedEvent, so
the existing split detection picks up the kind:1 release notes' zap
splits and pays the dev team via one Bitcoin tx with N receipts.
The donation card is the canonical multi-recipient on-chain zap flow —
release notes are tagged with weighted splits across the team, the
sender's own pubkey gets filtered out, and the per-recipient share
preview shows live as the amount is typed.
Other callers of ReusableZapButton (DVM zap buttons, etc.) get the
on-chain row automatically since it's driven by the user's settings;
empty on-chain amounts list hides the row, matching prior behavior.
Audit findings from an independent code review:
- HIGH: When the user zaps their own post (a common flow), every split
that included the post author put the sender on the recipient list,
and OnchainZapBuilder.buildSplit refused the whole tx with "cannot
zap yourself". Fix: new OnchainZapSplitter.prepare() filters the
sender's pubkey out of the splits before they reach the builder.
- HIGH: NIP-57 lets the same pubkey appear in zap-split tags more than
once (additive weights). buildSplit rejected duplicate recipients.
Same prepare() helper merges duplicates by summing weights, in
first-seen order.
- HIGH: The dialog's live preview only showed amounts for recipients
whose share was BELOW dust (because DustRecipientException only
carries belowDust). Fix: parent composable computes shares with a
zero dust threshold for the preview, gating the Send button on a
separate belowDustShares check so the user can see all amounts and
can't tap Send into a guaranteed BUILDING-stage failure.
- MEDIUM: OnchainZapSendResult.Failure didn't carry the ids of
receipts that successfully published before a partial-publish
failure. Added publishedReceiptEventIds: List<HexKey>.
- LOW: useSplits state was keyed by zappedEvent reference; re-emitted
bundles would silently reset the toggle. Now keyed on the event id.
Tests added:
- splitter: prepare() drops sender, merges duplicates, filters
non-positive weights; floating-point weights (0.1 + 0.2) sum exactly
- builder: buildSplit produces N recipient outputs + 1 change at index
N, conserves sats, rejects duplicates and below-dust shares
- sender: sendSplit publishes one receipt per recipient sharing the
txid with correct per-recipient amount; partial-publish failure
carries the broadcast txid and the ids of receipts that did publish
When pickRelaysToLoadUsers exhausts every candidate tier (outbox / hints /
index / search / connected / common) for a user without finding kind 10002,
it returns an empty map and the REQ closes. A late-published relay list
from that user would never reach the UI for the rest of the session
(unless EOSEAccountFast's LRU evicts the entry).
Compute the set of users we couldn't place this pass and add a permanent
fallback filter on DefaultIndexerRelayList + DefaultSearchRelayList. The
filter is deterministic (sorted authors, set-based relays) so the relay
client de-duplicates it across passes, keeping the REQ open without
re-sending — any future kind 0 / kind 10002 publication will be pushed
to us live.
Offline relays are subtracted via failureTracker.cannotConnectRelays.
Extends NIP-BC onchain zaps to honor a note's NIP-57 zap-split tags: one
Bitcoin transaction pays every pubkey-based recipient atomically, and
one kind:8333 receipt is published per recipient (each receipt carries
the recipient's pubkey + sat share and shares the same i:<txid>).
quartz / OnchainZapBuilder
- new buildSplit(recipients = listOf(pubkey to sats), ...) produces a
PSBT with one output per recipient + optional change output
- existing build(...) now delegates to buildSplit; coin selection and
change-vs-dust logic are unchanged for the single-recipient path
commons / new OnchainZapSplitter
- distribute(totalSats, splits, dustThreshold) does the weighted
integer-math allocation, dropping the rounding remainder onto the
largest-weight recipient first so the per-recipient sats sum exactly
to totalSats
- throws DustRecipientException if any share lands below dust; the
caller surfaces that as a build-stage failure before the tx is built
- unit tests cover equal weights, fractional weights, remainder
distribution, dust rejection, and input-order preservation
commons / OnchainZapSender.sendSplit
- mirrors send() but takes the precomputed shares, builds via
buildSplit, and publishes N receipts using the same txid; if one
receipt publish fails the broadcast txid + already-published receipt
ids are surfaced in the Failure result
amethyst / Account.sendOnchainZapWithSplits
- thin wrapper that hands off to OnchainZapSender.sendSplit using the
signer's pubkey
amethyst / OnchainZapSendDialog
- detects pubkey-based zap splits on the zappedEvent and, when present,
defaults to split mode: a SplitsRecipientSection renders one row per
recipient with weight % and live per-recipient sats preview
- lnAddress-only splits are filtered out (no pubkey -> no Taproot
address); a short note tells the user how many recipients were
skipped
- the send button label switches to "Send X sats, N ways"; an opt-out
button lets the user fall back to single-recipient mode
- on send: shares are recomputed via OnchainZapSplitter; below-dust
configurations surface as a BUILDING-stage failure before signing
Compose Foundation's LegacyCursorAnchorInfoBuilder.addCharacterBounds
computes a TextRange(startOffset, endOffset) over the visible portion of
a TextField when the IME has requested CURSOR_UPDATE_MONITOR. During a
layout transition (sheet animation, keyboard insets settling, etc.) the
visible region can momentarily collapse so startOffset == endOffset, and
MultiParagraph.fillBoundingBoxes throws "endOffset must be greater than
startOffset". This is a framework bug we can't fix from app code, and
it crashes the whole process from an onGloballyPositioned callback
during dispatchDraw.
Install a main-thread crash guard at app startup that wraps Looper.loop()
and swallows only this very specific signature (IllegalArgumentException
with the exact message AND a stack frame in LegacyCursorAnchorInfo* /
MultiParagraph* / AndroidParagraph / TextLayout). Anything else
re-throws into the default uncaught handler so UnexpectedCrashSaver
still records real bugs. The IME just misses a cursor update on the
swallowed frame — no user-visible impact.
The Blossom entry for nostrcheck.me was dropped in bcd2bc06
("Removes nip96 and updates Blossom recommendations", v1.06.0,
Feb 2026) along with the NIP-96 cleanup. This re-adds only the
Blossom endpoint.
Adds a second row of bitcoin-orange chips to the zap amount popup that
opens the existing OnchainZapSendDialog prefilled with the chosen amount
and the note's author + zappedEvent. The on-chain stack (kind 8333,
OnchainZapSender, Account.sendOnchainZap, OnchainZapSendDialog) already
existed end-to-end; only the entry point from the reactions row and the
on-chain preset amounts were missing.
Settings:
- AccountZapPreferencesInternal: new onchainZapAmountChoices (defaults
10k/50k/250k sats); back-compat via kotlinx.serialization defaults
- AccountSyncedSettings / AccountSettings / Account.updateZapAmounts:
wired through end-to-end alongside the existing Lightning list
- UpdateZapAmountDialog: new "Quick On-chain Zap Amounts" section with
its own chip list and add-amount field; the existing zap-privacy
dropdown stays Lightning-only since on-chain has no zap type
Reactions UI:
- ZapAmountChoicePopup now collects both choice lists and offers an
on-chain row in addition to the Lightning one
- New on-chain callback opens OnchainZapSendDialog with the prefilled
amount; the existing dialog gains a prefillAmountSats parameter and
now reads the on-chain preset list for its own quick-pick chips
- Other zap entry points (ReusableZapButton, NestActionBar,
FilteredZapAmountChoicePopup) are unchanged: on-chain row defaults
off so they keep their existing Lightning-only behavior
The bundled Material Symbols font ships only the codepoints referenced
from MaterialSymbols.kt. Without regenerating the subset, newly added
icons render as tofu at runtime. Document this as a mandatory step in
CLAUDE.md so agents pick it up automatically.