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.
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
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
- Extract PaymentTargetsDialog from PaymentButton so the reactions row
can render the same QR/Copy/Pay layout instead of the legacy two-row
M3ActionRow per target.
- PayReaction now subscribes via EventFinderFilterAssemblerSubscription
and observes the PaymentTargetsEvent reactively, so targets actually
load when the wallet icon is tapped.
- Correct the QrCode2 codepoint (U+E00A) and re-subset the bundled
Material Symbols Outlined font so the glyph is no longer a tofu box.
https://claude.ai/code/session_01JtJuvSYMusKiDMWo7N8Aj8
The onchain wallet's Taproot address is derived from the account's Nostr
pubkey, so anyone with the npub can see its balance and transaction
history on-chain. Surface that fact directly on the card with a tappable
"(i) Public" chip that opens a dialog explaining the privacy implication
and recommending private (non-Nostr) channels for funding and draining.
Each target now renders in a single row with the payment method and
address on the left and three trailing icon buttons: QR (opens a dialog
showing the raw address as a QR code), Copy (clipboard + toast), and
Pay (existing payto:// intent). Adds QrCode2 to MaterialSymbols.
https://claude.ai/code/session_01JtJuvSYMusKiDMWo7N8Aj8
Self-audit of the previous MLS reply commit surfaced four concrete
improvements:
1. Push-notification cold-start replies now thread reliably. The
receiver previously rebuilt the parent inner event by looking it up
in LocalCache; in a cold-process broadcast that cache hasn't been
re-hydrated yet (Account.restoreAll runs async on init), so the
q-tag silently dropped. Carry the parent's inner event id AND
author pubkey through the Intent extras and feed them straight to
MarmotManager.buildTextMessage, which now takes (eventId, author)
instead of a full Event. The cold-start reply is now always
threaded, not just the warm-cache case.
2. marmotGroupRelays() is no longer duplicated. The receiver was
reimplementing what AccountViewModel had as a private fun (which
itself was used 9× inside the VM). Lifted to Account so headless
callers can reach it without spinning up a ViewModel; both sites
now share one implementation.
3. Dropped the dead editFromDraft / draftId plumbing. Added for route
symmetry with NIP-17 but Marmot has no draft persistence, so the
parameter rode all the way through MarmotGroupChatView only to be
ignored under @Suppress("UNUSED_PARAMETER"). Per CLAUDE.md, don't
pre-emptively abstract; reinstate when drafts actually land.
4. MarmotGroupMessageComposer no longer has default-param `remember`
blocks. There's exactly one caller and it always passes both
messageState and replyTo — the defaults were just noise.
The in-chat send path also captures (id, pubKey) under the replyTo
guard before launching the send coroutine, so a slow send + a user-
cleared reply state can't race into a partially-threaded message.
No protocol or behavioral change for the warm-cache happy path; the
threading improvement is observable only on cold-start push-reply.
The "Add to phone calendar" icon I added on the calendar detail screen
top bar mapped to MaterialSymbols.EventAvailable (\\uE614), but the
checked-in TTF was a subset built before that codepoint existed in
MaterialSymbols.kt — so the third icon from the right rendered as the
.notdef placeholder.
Regenerated via tools/material-symbols-subset/subset.sh. The subset is
now 214 codepoints (up from 213); file size unchanged at 420K.
DAL extraction (commons/src/jvmAndroid/.../model/nip52Calendar/):
- CalendarSortKeys, CalendarAppointmentView, MonthGridBars, IcsExport now
live in commons so desktop and the future CLI can consume them. Package
changed to com.vitorpamplona.amethyst.commons.model.nip52Calendar.
- IcsExportTest moved to commons/jvmTest so it can see the `internal`
escapeText helper without exposing it as public API.
- Feed-filter classes (CalendarAppointmentsFeedFilter,
CalendarCollectionsFeedFilter) stay in amethyst — they depend on Account
and LocalCache. The ViewModels stay for the same reason; lifting them
requires moving Account/LocalCache too, out of scope here.
- A few smart-cast call sites needed local bindings because cross-module
properties don't support implicit smart-cast.
Accessibility:
- Each month-grid cell announces a full content description ("Wednesday,
January 15, 2025, 2 events, today, selected") via mergeDescendants so
TalkBack reads the cell as one item with role=Button.
- Week-strip cells get the same treatment with role=Tab.
- Header title now announces both the title and "jump to today" so the
affordance is discoverable.
- The expanding FAB describes itself as a toggle, and each sub-FAB as
the concrete create action.
Inline-FQN cleanup pass:
- CalendarEventDetailScreen (already in a prior pass), CalendarCollectionsView,
NewCalendarEventScreen, NewCalendarCollectionScreen: hoisted
fully-qualified androidx.compose / com.vitorpamplona references into
proper imports per the codebase style.
All 56 calendar tests still pass (48 in amethyst + 8 IcsExport in commons).
- New IconButton in the detail screen opens the system event composer
(Google Calendar / Samsung / iCloud) pre-filled with title, range,
location, and description via Intent.ACTION_INSERT on CalendarContract.
Falls back to the .ics share path if no calendar app is installed.
- Replaced the per-cell event-dot row with horizontal bars that span the
cells a multi-day event covers. Bars are laid out by a greedy
lowest-lane assignment so overlapping events stack rather than collide;
cells removed their horizontal padding so adjacent bars merge into one
uninterrupted line.
- Bars round their ends only at the event's actual start/end (or at week
boundaries), so a 3-day conference reads as one continuous pill across
the row.
- Added MonthGridBarsTest (7 tests) covering single/multi-day, lane
collision, longer-event tiebreak, and the empty/ghost edge cases.
Tapping reply on a Marmot/MLS (kind:445) message in the Notifications
screen previously fell through routeReplyTo()'s `else` branch and opened
the generic public-comment composer (Route.GenericCommentPost). Sending
it would publish a plaintext kind:1111 that references the encrypted
inner event id, leaking that the user has decrypted that group message.
Mirror the NIP-17 pattern (Route.Room carries replyId, draftId,
draftMessage; the same chat screen renders the "replying to" quote
above the input) for MLS:
- routeReplyTo() now detects MarmotGroupChatroom in note.inGatherers,
matching how routeFor() at line 67 already finds the parent group, and
returns Route.MarmotGroupChat(groupId, replyId = note.idHex).
- Route.MarmotGroupChat gains message/replyId/draftId fields.
- MarmotGroupChatView resolves the replyId into a Note, shows
DisplayReplyingToNote above the composer, wires onWantsToReply for
in-chat replies, and threads the parent inner event through
AccountViewModel.sendMarmotGroupMessage into
MarmotManager.buildTextMessage, which now adds a NIP-18 q-tag on the
inner kind:9 (the same convention ChatEvent.reply() uses).
Push notifications: notifyGroupMessage previously passed
chatroomMembers=null, so the inline Reply action was never attached for
MLS group notifications. Add a parallel MARMOT_REPLY_ACTION wired with
the group id + parent inner event id; NotificationReplyReceiver loads
the account, rebuilds the parent inner event from LocalCache (or sends
unthreaded if the cache was pruned), and publishes the reply through
the same MarmotManager path — so the inline notification reply stays
encrypted inside the group instead of taking the NIP-17 PTag fallback.
Earlier commits kept I2pType.INTERNAL as a placeholder for a follow-up
embedded daemon. We're not shipping that: I2P bootstrap on Android is
structurally minutes-long (no equivalent of Tor's hardcoded directory
authorities — NetDB peer discovery is protocol-inherent), so an embedded
router would mean a permanently-warming UX. Users who want I2P run i2pd /
Java I2P independently and point Amethyst at its SOCKS port.
Changes:
- commons I2pType drops the INTERNAL variant; parseI2pType collapses unknown
codes to OFF
- I2pManager loses its INTERNAL switch arm
- I2pSettingsDialog drops the "treat INTERNAL as OFF" mapping it used to
carry — the enum no longer has the case
- Android UI I2pSettings.resourceId drops the i2p_internal branch
- strings.xml drops the now-unused i2p_internal string
- I2pSharedPreferences hardens load: a stored "INTERNAL" from an earlier
branch is no longer a valid I2pType, so runCatching swallows the
IllegalArgumentException and the user lands on OFF
- PrivacyRouterTest fixtures use I2pType.EXTERNAL throughout
Receipts were only being checked for a valid event signature — anyone could
sign a kind:9735 and have it counted toward another user's zap totals. NIP-57
Appendix F mandates three additional checks: receipt.pubkey == LNURL
provider's nostrPubkey (MUST), bolt11 invoice amount == zap request "amount"
tag (MUST), and lnurl tag == recipient's lnurl (SHOULD).
- Adds LnZapReceiptValidator + LnurlForm in quartz commonMain (pure logic).
- Adds LnurlEndpointCache (jvmAndroid) and the LnurlEndpointResolver
interface for async lookup. The cache is primed by outbound zaps (existing
LightningAddressResolver fetches now extract nostrPubkey) and on demand for
inbound receipts when no entry is present.
- Adds OkHttpLnurlEndpointResolver in commons, wired into LocalCache via
AppModules using the existing money-tier OkHttp builder (so Tor settings
apply).
- LocalCache.consume(LnZapEvent) now: drops receipts that fail MUST checks
synchronously when the cache is warm, defers credit until async resolution
finishes on cache miss, and falls back to legacy signature-only behavior
when no resolver is wired (tests).
- LnZapRequestEvent.create() now accepts amountMillisats + lnurl; both are
threaded through Account.createZapRequestFor and emitted as tags so future
receipts can be validated against them.
21 new tests cover validator reasons, lnurl form canonicalization across
lud16/URL/bech32, and cache eviction.
`bridgeUrl` accepted the imeta `x` hash as a sufficient signal to route
a media URL through the local Blossom cache, even when the URL itself
didn't have the sha256 in its last path segment. For URLs like
https://i.nostr.build/M5AwJ.gif the upstream serves the blob under an
opaque filename, so the resulting `xs=https://i.nostr.build` hint sent
the local cache after https://i.nostr.build/<sha>.gif on miss — which
404s because the real blob isn't at that path.
Match the behaviour `bridgeProfilePictureUrl` already had: require
`extractSha256FromUrlPath(url)` to succeed before bridging. The imeta
hash is still preferred for canonical casing when present, but is no
longer enough on its own.
Tapping the Bitcoin card on the wallet screen now navigates to a new
OnchainTransactionsScreen that lists transactions touching the account's
Taproot address, mirroring the NWC transactions view.
- OnchainBackend gains getTxsForAddress(address, afterTxid) returning
BitcoinAddressTx rows (netValueSats, confirmations, blockHeight,
blockTime, counterparty addresses). EsploraBackend implements it via
GET /address/{addr}/txs and /address/{addr}/txs/chain/{last_seen}
for pagination; CachingOnchainBackend passes through.
- OnchainTransactionsViewModel loads the address from the account
signer + LocalCache.onchainBackend, paginates, and for each chain
row scans LocalCache for an OnchainZapEvent with a matching txid so
the UI can render the Nostr counterparty (sender pubkey for
incoming, p-tagged recipient for outgoing).
- ALL / ZAPS / NON-ZAPS filter chips reuse the existing
TransactionFilter enum. Mempool rows are flagged "Pending" in
bitcoin-orange.
Nowhere (https://github.com/5t34k/nowhere) encodes whole mini-sites in
a URL fragment, so the OpenGraph preview path returns nothing useful and
hostednowhere.com 403s scrapers. Detect URLs on hostednowhere.com and
nowhr.xyz that carry a fragment as a NowhereLinkSegment and render them
through a NowhereLinkCard that labels the tool type (Event, Store,
Message, ...) and opens the URL in the browser on click.
Drop the per-feature 3-way picker — no splitting clearnet traffic between Tor
and I2P at the same time. Both daemons can still run side-by-side so .onion
and .i2p hidden services stay reachable independently, but only one transport
carries clearnet traffic at a time.
Routing model:
- .onion → Tor required; Blocked when Tor is OFF (no clearnet fallback)
- .i2p → I2P required; Blocked when I2P is OFF (no clearnet fallback)
- clearnet → preferredClearnetTransport (NONE/TOR/I2P) picks the active
transport; that transport's own per-feature toggle decides this request;
otherwise Direct
Commons:
- Add PrivacyRoute sealed type { Direct, Tor, I2p, Blocked(BlockReason) } so
fail-closed has somewhere to land — callers must surface Blocked instead of
silently leaking over clearnet
- PrivacySettings drops `features`, adds preferredClearnetTransport
- I2pSettings gains imagesViaI2p / videosViaI2p / urlPreviewsViaI2p /
profilePicsViaI2p / nip05VerificationsViaI2p / moneyOperationsViaI2p /
mediaUploadsViaI2p — mirrors TorSettings; only effective when I2P is the
preferred clearnet transport
- PrivacyRouter rewritten to the new model
- Delete FeatureTransportChoices and TransportChoice
- Keep FeatureRole as the per-request hint that selects which toggle to read
Tests: PrivacyRouterTest rewritten to cover the new outcomes, including
both-daemons-running clearnet preference, fail-closed for .onion / .i2p, and
that the non-preferred transport's toggles have no effect on clearnet.
The :ammolite module contained no production Kotlin/Java sources (just
a manifest, build.gradle, and proguard stubs) and no module in the
codebase imports com.vitorpamplona.ammolite.*.
Removes:
- ammolite/ directory (5 files)
- :ammolite project include in settings.gradle
- implementation project(':ammolite') from :amethyst
- androidTestImplementation project(':ammolite') from :benchmark
- :ammolite:testDebugUnitTest from CI workflow and pre-push hook
- -keep class com.vitorpamplona.ammolite.** rules from
:amethyst, :commons, and :desktopApp proguard files
- Stale references in CONTRIBUTING.md, CLAUDE.md, and the
gradle-expert skill dependency-graph doc
Small build-graph win: one fewer module to configure, compile, lint,
and spotless-check on every build, and one fewer unit-test target in
both CI and the local pre-push hook.
URLs like https://nostr.build/i/nostr.build_<sha>.jpg embed a 64-char
hex in the filename but aren't BUD-01 Blossom blobs — the last path
segment must be exactly <sha256> or <sha256>.<ext>. The previous regex
matched the embedded hash and rewrote the request to the local cache
with xs=https://nostr.build/i, which 404s on miss because the real blob
lives at /i/nostr.build_<sha>.jpg, not /i/<sha>.
Switch both extraction sites (MediaUrlContentExt and the OkHttp
interceptor) to a matchEntire regex that anchors the sha at the start
of the segment with at most a .<ext> suffix.
A Comment event (kind 1111) scoped to an external identifier (`I` tag)
previously rendered as a bare text post and landed in the Home "New
Threads" feed. Treat it as a reply to that external scope: it now shows
in the conversations feed and renders a typed chip (hashtag, geohash,
url, or generic) above the comment text.
https://claude.ai/code/session_01ArHkNXu1ANrVGZAyMWg4Xu
The big one: the source package was named `nipBCOnchainZaps/build/`, which
the repo .gitignore (`build/`) silently matched — so OnchainZapBuilder.kt
(the main source, not just its test) was NEVER committed. The pushed branch
did not compile; the pre-commit hook only checks the working tree, so this
went unnoticed. Renamed the package `build` -> `builder` (a source package
must never be named `build`) and updated all references; the recovered files
are now actually tracked.
Re-audit findings on the previous fix commit:
- CachingOnchainBackend.txCache was unbounded -> memory leak in a long
session. Added a bounded cache (maxCachedTxs, oldest-entry eviction).
- OnchainZapSender still trusted the signer's returned PSBT for witness
UTXOs and tap internal keys (used to compute the sighash + the verified
output keys). Now it copies ONLY the PSBT_IN_TAP_KEY_SIG records back onto
the PSBT we built, so a signer can contribute signatures and nothing else;
verification and finalization run entirely on our own PSBT.
Test coverage added:
- OnchainZapBuilderTest: confirmed-UTXO filter — unconfirmed UTXOs excluded
by default, spendable only with allowUnconfirmed, confirmed preferred.
- CachingOnchainBackendTest: confirmed-tx cached forever, unconfirmed
re-fetched after TTL, not-found never cached, tip/fee TTL, bounded
eviction.
All quartz / commons jvmTest suites pass; quartz android + amethyst compile.
CRITICAL
- PsbtSignatureVerifier: independently verifies every key-path signature in a
PSBT (BIP-340 sig over the BIP-341 sighash, against the tweaked output key).
- OnchainZapSender now rejects a signer that returns a different transaction
than it was asked to sign (byte-compares the unsigned tx) and verifies all
signatures before broadcasting — closes a substitution attack where a
malicious/buggy external signer could redirect funds.
HIGH — break test circularity
- Pin the full BIP-341 keyPathSpending input-0 witness: signing the vector
sighash with the vector tweaked key reproduces the vector signature
byte-for-byte (also pins BIP-340 nonce determinism).
- Pin TaprootAddress.fromPubKey + SegwitAddress.encodeP2TR against all seven
BIP-341 wallet-test-vector P2TR mainnet addresses.
- EsploraBackendTest: JSON-parsing coverage for /tx, /address/{}/utxo, the
mempool.space and blockstream fee formats, and schema-fallback.
MEDIUM
- OnchainZapBuilder filters to confirmed UTXOs by default (allowUnconfirmed
opt-in) and signals BIP-125 RBF (nSequence 0xFFFFFFFD).
- CachingOnchainBackend: TTL-caching decorator (confirmed tx forever,
unconfirmed/tip/fees short TTL) so a feed of onchain zaps doesn't fan out
into one HTTP request per event. Wired in AppModules.
- OnchainZapVerifier computes real confirmation depth from the chain tip and
asserts the backend echoed the requested txid.
- EsploraBackend falls back to the standard Esplora /fee-estimates endpoint
(blockstream.info) when /v1/fees/recommended 404s.
LOW
- BitcoinTransaction.parse caps input/output/witness-item counts to stop a
hostile varint from triggering a giant pre-allocation.
- OnchainZapEventTest: asserts kind:8333 on-the-wire tag structure against the
NIP-BC spec.
- alt tag now includes the amount ("Onchain zap: N sats"), matching the spec
example.
All quartz / commons / androidHostTest suites pass; amethyst compiles.
Adds RoomZapsStateTest mirroring RoomReactionsStateTest (grouping,
eviction, room-wide keying, idempotent snapshots, dedup). Also drops
the unused RoomZapsAggregator.isEmpty(), pins the zap chip's content
color to white since BitcoinOrange is theme-independent, and corrects
a few doc comments that overstated component/timer sharing.
https://claude.ai/code/session_01EW11kUdiEYPuPti7vtD2AR
onReactionEventGroupsByTargetAndEvictsOnTick asserted the pre-change
targetPubkey grouping (recentReactions.value[bob]); the aggregator
now groups by sourcePubkey so the chip rises from the reactor's
avatar. Renamed to ...GroupsBySender and assert under alice's key.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reaction-side rework after extended testing on 2026-05-13:
* Promoted users weren't seeing speaker controls in their own
Amethyst — `StageControlsBar` gates on `isOnStage && ui.onStageNow`
and `ui.onStageNow` was stuck at false (never reset back to true
after some earlier path flipped it). Added a LaunchedEffect in
NestActivityContent that mirrors `isOnStageMe → ui.onStageNow`
whenever it becomes true, symmetric to the auto-stop effect.
* Reactions overlay sat inside AvatarAndBadges' wrap-content Box,
so adding/removing the chip shifted the inner Box's centre and
the role badges drifted. Lifted the overlay out to be a sibling
of AvatarAndBadges inside the outer fixed-size Box; badges now
stay anchored regardless of chip presence or animation state.
* Reaction grouping was by `targetPubkey` (NIP-25 p-tag) — emojis
showed on the speaker being reacted to, not the reactor. Switched
RoomReactionsAggregator to group by `sourcePubkey`. AudienceGrid
threads `reactionsByPubkey` so an audience reactor sees their own
emoji float from their audience-tab avatar too.
* Reaction chip animation: progress was an `Animatable.value` that
Compose wasn't refreshing in the layout-consuming layer (visible
bug: chip "blinked" with no movement). Replaced with a manual
`withFrameNanos` loop writing to a `MutableFloatState` read inside
a `graphicsLayer { … }` lambda — frame-clock animation that works.
Each kind-7 is its own chip keyed by event-id (no more
groupBy-content collapsing same-emoji bursts into one shared chip
that restarts on every arrival). Chips stack at a fixed-size 30 dp
Box with the emoji centred, so the X position is invariant under
glyph width. Multiple concurrent chips overlap at the right-bottom
corner in a `Box(BottomEnd)` (newest on top) rather than sliding
leftward in a `Row`.
* Bottom-drawer `RoomReactionPickerSheet` (hard-coded 6 emojis, no
NIP-30) replaced by a forked `RoomReactionPopup` that reuses
`ReactionChoicePopupContent` (same NIP-30 custom-emoji support,
same user-configured reaction set) but with two semantic deviations
for live audio rooms: empty `toRemove` so all buttons stay
"fresh", and the click handler signs+broadcasts a fresh kind-7
template directly instead of going through `Account.reactTo` —
which delegates to `ReactionAction.reactTo(note, …)` and
short-circuits on `note.hasReacted(by, reaction)`. The bypass
lets the user fire the same emoji repeatedly during a moment.
* Chip rendering matches NoteCompose: `RenderReactionContent`
handles `:shortcode:url` via `InLineIconRenderer` + `CustomEmoji`,
`"+"`→❤️, `"-"`→👎, anything else as Text.
Tests updated for sender-grouped aggregator semantics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit bundles several issues surfaced while testing host actions
inside an audio room.
NestActivity didn't mount `DisplayErrorMessages`, so every toast emitted
by nest code (promote, demote, kick, force-mute, errors) queued into a
StateFlow whose only collector lives in MainActivity. Mounted it inside
NestActivity.setContent so toasts now render in front of the room UI —
same way the leave-confirmation AlertDialog already does.
Host actions used to fire a synchronous "Promoted X" toast regardless of
whether `signAndComputeBroadcast` actually completed. Silent signer
failures (TimedOut, ManuallyUnauthorized, CouldNotPerform, etc. — all
Log.w-only in launchSigner) were invisible from the host's POV. Replaced
with a coroutine-bound failure toast that surfaces the exception class
+ message; success is implicit via the UI update.
The real cause of "promoted user stays in audience tab" turned out to be
stale presence: a kind-10312 emitted before the role grant (with
onstage=0) was pinning the freshly-promoted speaker to the audience tab.
buildParticipantGrid now takes a `roleGrantSec` parameter (the
kind-30312 created_at) and treats presence as authoritative only when
strictly newer. Pinned with two new tests covering both the
stale-ignore and fresh-respect cases. The leave-stage-on-another-client
flow keeps working because that emits a fresh onstage=0.
RoomParticipantActions.rebuild now uses
`(original.createdAt + 1L).coerceAtLeast(now())` so a same-second
promote→demote can't tie-break the wrong way under NIP-01's lowest-id
rule.
EditNestSheet's bottom row got squashed when the keyboard appeared —
the form fields couldn't shrink, so the Save/Cancel/Close row took the
hit. Split into a scrollable form column + sticky action row, wrapped
the outer column in imePadding.
SpeakerReactionOverlay was driving drift on a 100 ms `delay` loop over
the 10 s eviction window — produced 0.16 dp per tick (visibly stepped)
and the chip barely moved before disappearing. Replaced with
`Animatable.animateTo` on Compose's frame clock and a 6 s duration so
the chip pops, lingers visibly, then drifts up and fades.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the Phase A.2 quartz send-side foundation into a working end-to-end
send: build → sign → broadcast → publish kind:8333.
commons/onchain/OnchainZapSender — stateless orchestrator. Loads the
sender's UTXOs, builds the unsigned PSBT via OnchainZapBuilder, signs it
through NostrSigner.signPsbt, finalizes + broadcasts via OnchainBackend,
then publishes the kind:8333 receipt through an injected publish callback.
Per-stage failures surface as OnchainZapSendResult.Failure (with the
broadcast txid preserved when only the receipt publish failed) instead of
throwing; CancellationException always propagates.
Account.sendOnchainZap — thin wrapper binding the account's signer, the
LocalCache.onchainBackend, and signAndComputeBroadcast into the
orchestrator.
amethyst wallet UI:
- OnchainZapSendDialog — collects recipient npub (or a fixed recipient when
launched from a note's zap menu), amount, fee tier (slow/normal/fast from
the backend's fee estimates), and an optional comment; runs the send and
shows progress + success/failure.
- OnchainSection — the Bitcoin card on the wallet screen gains a "Send"
button next to "Copy address" that opens the dialog for a profile zap.
Tests: OnchainZapSenderTest covers the happy path (receipt references the
broadcast txid, recipient, and amount), insufficient-funds failure at the
build stage, broadcast failure (no txid leaked), and publish failure (txid
preserved for retry).
Pending: Phase B (NIP-55 sign_psbt Intent) — external/remote signers still
throw UnsupportedMethodException; note-zap-menu entry point can reuse
OnchainZapSendDialog's recipientPubKey/zappedEvent parameters once wired.
Implements the send half of the onchain zaps plan: a minimal, fund-safe
BIP-174 PSBT pipeline plus the OnchainZapBuilder and `NostrSigner.signPsbt`.
Every cryptographic step is validated against the BIP-341 wallet test
vectors.
nipBCOnchainZaps/psbt/:
- BitcoinIO: little-endian Bitcoin consensus byte reader/writer (u16/u32/u64,
compact-size varint, var-bytes).
- BitcoinTransaction: OutPoint / TxIn / TxOut / transaction model with legacy
and BIP-144 segwit serialization, segwit-aware parsing, and witness-stripped
txid. Validated against the genesis coinbase tx and the BIP-341 9-input
unsigned tx.
- TaprootSigHash: BIP-341 SigMsg + TapSighash tagged hash for key-path spends.
All six base sighash types plus both ANYONECANPAY variants verified against
the seven BIP-341 keyPathSpending vectors.
- Psbt: BIP-174 container subset (global unsigned-tx, per-input witness-utxo /
tap-internal-key / tap-key-sig / sighash-type, per-output tap-internal-key).
Unknown records round-trip verbatim. Typed accessor extensions.
- PsbtSigner: signs the key-path P2TR inputs a given private key controls —
derives the BIP-341 tweaked key, computes the all-inputs sighash, produces a
BIP-340 Schnorr signature. Leaves inputs the key does not control untouched.
- PsbtFinalizer: moves tap-key-sigs into the witness stack, yields a
broadcastable transaction.
nipBCOnchainZaps/build/:
- OnchainZapBuilder: largest-first coin selection + unsigned-PSBT assembly for
a NIP-BC zap. Sender spends from / changes back to the single Taproot address
derived from their Nostr pubkey; recipient output pays the recipient's
derived address. Dust-aware change handling, InsufficientFundsException,
self-zap and dust-amount guards.
nipBCOnchainZaps/taproot/:
- TaprootAddress.tweakSecretKey: BIP-341 taproot_tweak_seckey (key-path-only),
including the odd-y internal-key negation. Validated against the BIP-341
vector's tweakedPrivkey.
Secp256k1Instance: new `privKeyNegate` primitive across commonMain expect +
jvm / android / native actuals.
Signer hierarchy — new `NostrSigner.signPsbt(psbtHex): String`:
- NostrSignerSync / NostrSignerInternal: real implementation.
- NostrSignerWithClientTag: delegates to the wrapped signer.
- NostrSignerRemote (NIP-46) and NostrSignerExternal (NIP-55): throw
SignerExceptions.UnsupportedMethodException — the bunker command and the
Android Intent contract are Phase B, and depend on signer-app support.
Tests: 31 new tests across the psbt/build/signers packages, anchored on the
BIP-341 wallet-test-vectors so the tweak, sighash, signature, and finalized
transaction are all checked against an authoritative source.
Still pending: Phase B (NIP-55 sign_psbt Intent) and Phase D (send UI +
broadcast + publish kind 8333).
F4A is AAC audio in an MP4 container (Adobe's variant of .m4a).
Register the extension in the media URL list so it routes to the
player, and map it to audio/mp4 so ExoPlayer picks the MP4 extractor.
https://claude.ai/code/session_01EuB46tfwBxtvRNDz9Ju99J
Phase C of the onchain zaps plan (amethyst/plans/2026-05-14-onchain-zaps.md).
Plumbs the Quartz receive foundation from Phase A.1 into the Android app so
incoming kind 8333 zaps show up in note totals and the wallet screen.
Subscription / fetch path (kind 8333 rides existing zap filters, no new
assemblers per the plan):
- FilterRepliesAndReactionsToNotes: OnchainZapEvent.KIND in
RepliesAndReactionsKinds (#e on notes)
- FilterUserProfileZapReceived: OnchainZapEvent.KIND in
UserProfileZapReceiverKinds (#p on profiles)
- UserProfileZapsViewModel: kinds = LnZap + Onchain inline
- FilterNotificationsToPubkey: OnchainZapEvent.KIND in SummaryKinds
- NotificationFeedFilter / NotificationDispatcher: kind 8333 in
NOTIFICATION_KINDS
- FilterMessagesToLiveStream / FilterGoalForLiveActivity: kind 8333
alongside 9735
Display path (Note.zapsAmount fold-in — no UI changes needed downstream):
- commons/model/Note.kt: new `onchainZaps: Map<String, OnchainZapAmount>`
(keyed by txid for `(txid, target)` dedup); `addOnchainZap(txid, sats,
confirmed)` mutator; `updateZapTotal` now also sums confirmed onchain
sats into `zapsAmount`. Pending entries are tracked but excluded from
the aggregate total per the NIP-BC spec.
- commons/model/OnchainZapAmount.kt: new data class for per-tx state.
- LocalCache: new `onchainBackend: OnchainBackend?` slot; new
`consume(OnchainZapEvent)` handler that runs the same sig-verify and
computeReplyTo pipeline as `consume(LnZapEvent)`, then kicks off async
verification on `applicationIOScope`. Self-zaps are rejected
synchronously per the spec. Verification failures, mempool-only txs,
and confirmed txs all route to `Note.addOnchainZap` with the correct
flag. Without a backend wired, events are still cached (so subscriptions
see them) but skip the total contribution.
- LocalCache.computeReplyTo: new `is OnchainZapEvent ->` branch that
enumerates `e`/`a` targets — profile-only zaps return an empty list and
flow through profile zap queries instead.
Wallet UI:
- Wallet screen now renders a "Bitcoin" Card above the existing NIP-47
NWC wallet list. Single card (one onchain wallet per Nostr identity,
derived from the pubkey). Shows the bc1p taproot address + Copy button.
Balance, recent incoming zaps, and tap-to-detail come in later phases.
Settings + wiring:
- AccountSettings: new `onchainEsploraEndpoint: MutableStateFlow<String>`,
default mempool.space, plumbed for a future settings UI.
- AppModules: builds a single shared `EsploraBackend` at app init using
the role-based money-flavored OkHttp client (Tor-aware), installs it on
`LocalCache.onchainBackend` so verification can run.
Secp256k1Instance: new `pubKeyTweakAdd` was added in Phase A.1.
No PSBT, no signing, no broadcasting — send-side (Phase A.2 + D) is still
pending.
Subscribes to kind-9735 zap receipts tagged with the room's a-pointer,
feeds them into the nest chat ledger (so RenderChatZap surfaces them
the same way live streams do) and into a sliding-window aggregator
that drives a floating ⚡ chip over the targeted participant's avatar
— mirrors the existing reaction overlay's animation cadence so both
streams visually feel like one system.
The button itself wraps NoteCompose's zapClick / ZapAmountChoicePopup
/ ZapCustomDialog defaults against the room's AddressableNote, so the
amount-choice popup, custom-amount dialog and multi-payable routing
all behave like the standard note ⚡ button.
https://claude.ai/code/session_01EW11kUdiEYPuPti7vtD2AR