nostr-protocol/nips#2332 adds optional ["block", …] and ["proof", …] tags
on kind:8333 that ship the SPV proof inline. The data-model side is already
shipped in Quartz (BlockTag, ProofTag, OnchainZapEvent.block()/.proof() and
the TagArray helpers), but no production code path produces or consumes
either tag yet.
amethyst/plans/2026-05-14-onchain-zaps.md — onchain zaps plan:
- Update the "Chain backend" decision bullet to flag the spec change and
the two production gaps (send-side emit, receive-side consume).
- Add a dedicated "Inline SPV proofs" section covering: spec status and
the merkle-proof encoding ambiguity flagged back to the PR; per-layer
status matrix (shipped vs gap, with file locations); send-side
two-publish design (Design B — keep instant receipt, add post-confirm
republish with block+proof; dedupe by (txid, target)); receive-side
fast-path with fall-through on proof failure (never hard-reject);
phased delivery G.1–G.7 with effort estimates (~3–4 d after S1 ships
and spec encoding lands).
- Add the two new pending items to the existing "What's still pending"
list to keep that section authoritative.
quartz/plans/2026-05-08-local-headers-explorer.md — headers-explorer plan:
- Rewrite §19 (follow-up onchain-zap verification) to reflect the spec
change. Original section assumed we'd need BIP-37 merkleblock or
full-block fetch over P2P; with the proof inline none of that
infrastructure is required. Estimate collapses from 10–15 d to ~3–4 d.
- Point §19 at the full implementation plan in the onchain-zaps file,
keeping S1 focused on OTS while the follow-up details live with the
rest of the NIP-BC work.
Every consensus-relevant layer of the planned headers explorer (BlockHeader80
parser, DifficultyTarget compact↔target, CalculateNextWorkRequired retarget,
MedianTimePast, header validator end-to-end, P2P wire codecs, reorg/chain
selection, OTS proofs) is pinned to upstream test vectors committed under
quartz/src/commonTest/resources/bitcoin/, matching the existing
nip44.vectors.json / bip39.vectors.json / mls/*.json pattern.
The single highest-value test is a nightly differential check asserting
LocalHeadersBitcoinExplorer.blockHash(h) == OkHttpBitcoinExplorer.blockHash(h)
for every height in [checkpoint, tip] — any consensus drift surfaces as a
disagreeing height.
Maps cleanly onto the existing Phase 1/3/4/5/7/9 work, adding ~5–7
engineer-days total to the plan budget. No new top-level phase needed.
Lock the design choices from the 2026-05-19 review against the intervening
2026-05-14 onchain-zaps work:
- Move the module from quartz/.../nip03Timestamp/bitcoin/ to a sibling
quartz/.../bitcoin/ package so the headers explorer, header validator,
peer pool and store can be reused by the future onchain-zap merkle-proof
work without an inverted import path.
- Use androidx.sqlite + BundledSQLiteDriver for HeaderStore, matching the
existing SQLiteEventStore. Schema lives in commonMain via IModule. Drops
the hand-rolled flat-file + sidecar height index.
- Drop the bundled headers blob. Ship a single hardcoded PinnedCheckpoint
constant; first-run sync starts from the checkpoint and pulls forward
over P2P. APK growth: 0 bytes.
- Pre-checkpoint OTS heights fall through to OkHttpBitcoinExplorer via
BitcoinExplorerEndpoint (shared with the onchain-zap EsploraBackend).
Strict-mode users get an explicit error instead of a network call.
- Mark trustless NIP-BC onchain-zap verification as out of scope and
capture it as a follow-up plan (BIP-37 merkleblock or full-block fetch
on top of this stack).
Resolves open questions Q1, Q2 and Q4 from the original plan; Q3 (Quartz
public API vs internal) left open for Phase 0.
The playback notification's tap target (MediaSession.setSessionActivity)
was only bound from MediaSessionCallback.onAddMediaItems, which fires
when the in-app MediaController calls setMediaItem. GetVideoController's
warm-pool fast path skips setMediaItem when the acquired ExoPlayer was
retained with the same mediaId, so the new MediaSession was left with
no session activity and tapping the notification did nothing.
Bind the PendingIntent in newSession() from the acquired player's
current MediaItem extras, and share the construction with onAddMediaItems
via a single helper.
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.
When a filter (e.g. Non-Zaps) excluded every loaded transaction, the
empty-state composable replaced the whole screen body — including the
filter chip row — so the user couldn't switch back to All or Zaps
without leaving the screen. Expose `hasAnyTransactions` from the
ViewModel so the screen distinguishes "no chain rows at all" from "no
rows for this filter": the chips stay rendered as long as any
transaction has loaded, and the LazyColumn shows a per-filter empty
message inline beneath them. Also drop the bc1 address header from the
list since the screen title already identifies the wallet.
Two related fixes to the thread quick-action popup:
- ThreadFeedView.FullBleedNoteCompose declared a `modifier` parameter
that NoteMaster used to attach `combinedClickable(onLongClick = showPopup)`,
but the body built a fresh `Modifier.fillMaxWidth().padding(top = 10.dp)`
for its root Column and discarded the incoming modifier. Long-press on
the root note in thread view never fired. Spread the incoming modifier
onto the Column.
- LongPressToQuickAction emitted the Popup as a sibling of the content
with no wrapping layout. The Popup's `parentLayoutCoordinates` then
resolved to the enclosing LazyColumn, so `alignment = Alignment.Center`
centered the menu on the whole list (visually middle of the screen)
instead of the long-pressed card. Wrap content + Popup in a Box so the
popup's parent bounds match the note card.
Disabling individual sub-passes (method/specialization/returntype,
method/marking/static) was not sufficient. The interaction between
multiple optimization passes causes IncompatibleClassChangeError in
kmp-tor's AsyncFs.of() at launch. Shrink (dead code removal) stays
ON for the size win; only optimize is disabled.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The smoke test discovered that ProGuard's method/marking/static pass
converts kmp-tor's AsyncFs.of() from instance to static. The JVM
then throws IncompatibleClassChangeError at launch because callers
still use invokevirtual.
Also updates smoke-test-desktop.yml trigger: runs on any PR touching
desktopApp/ (not just build config files).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reverts the intentional breakage from d0a6bbc96. The smoke test
proved it catches the crash: removing java.management from jlink
modules causes a fatal IncompatibleClassChangeError in kmp-tor
at launch (the module is needed for ManagementFactory + tor runtime).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit should FAIL the release-deb-launch CI job, proving the
smoke test works. Will be reverted in the next commit.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
dpkg -i exits non-zero when the post-install script fails even with
--force-all. Allow the error with || true, then verify the binary
was actually extracted to /opt before proceeding.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
jpackage's post-install script calls xdg-desktop-menu which fails on
GitHub Actions runners with "No writable system menu directory found".
Menu registration is irrelevant for smoke testing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fixes#2819 — v1.08.0 .deb crashed on Ubuntu with ManagementFactory
error because the build had no jlink modules() declaration. Current
main already has the fix; this PR adds CI to prevent regressions.
- Add compose.desktop.uiTestJUnit4 dependency
- DesktopLaunchSmokeTest: renders LoginScreen, asserts title text
- build.yml: xvfb for Linux leg so UI test runs on every PR
- smoke-test-desktop.yml: builds release .deb, installs it, launches
under xvfb, verifies process stays alive 10s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two improvements to onchain history attribution:
1. Coalesce UI updates. The OnchainZapEvent observation in the
ViewModel now passes through .sample(500), so a relay flooding
a backlog of historical zaps in quick succession produces at
most one map update per 500ms instead of one per event. The
downstream combine + StateFlow conflation handles the rest.
2. Bound relay queries to the visible window. New
OnchainZapsFilterAssembler (SingleSubEoseManager-based) wakes
only while the screen is on top and asks the user's inbox/
outbox relays for kind-8333 events with since = the oldest
visible blockTime — incoming (p-tag = user) and outgoing
(authors = user) — so we don't drag the whole NIP-BC history
when the user only scrolled through last week. As the user
pages back, oldestBlockTime drops, the assembler re-subscribes
with a wider window.
OnchainTransactionsScreen now wires OnchainZapsFilterAssemblerSubscription
with the StateFlow-derived window, and the coordinator owns the
parent assembler so the lifecycle matches other screen subs.
OnchainTransactionsViewModel now subscribes to LocalCache via
observeEvents for kind-8333 zaps that either p-tag the user
(incoming) or are signed by the user (outgoing), merging both into
a txid -> OnchainZapEvent map. The FilterIndex inside LocalCache
narrows the fanout so we only wake on relevant events.
The list of chain rows and the zap map are now independent
StateFlows combined into the displayed views, so a zap event
arriving after the page has loaded immediately attributes the
existing row to its Nostr counterparty without a refresh.
Replaces the per-row LocalCache.notes scan from the previous commit.
The dedicated ConcurrentHashMap<txid, OnchainZapEvent> in LocalCache
didn't pay for itself at current NIP-BC volumes. The on-chain
transactions view goes back to scanning LocalCache.notes for the
matching OnchainZapEvent per row.
Without busy_timeout SQLite returns SQLITE_BUSY immediately when
BEGIN IMMEDIATE can't acquire a lock — e.g. during a WAL
auto-checkpoint or a reader briefly upgrading its snapshot — instead
of retrying. ParallelInsertTest's reader+writer test hit this ~10%
of runs. 5s matches Room's default and adds no overhead in the
uncontended case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Voice notes go through GetVideoController, so the 30s background
release timer + warm-pool reattach already applied to them. What was
missing was the immediate ON_PAUSE handler: voice didn't use
ControlWhenPlayerIsActive, so playback kept running for the full 30s
before the release timer hit and cut it off via the warm-pool pause.
Extract a small PauseControllerWhenInBackground composable from the
ON_PAUSE arm of ControlWhenPlayerIsActive and call it from VoiceTrack.
Result matches video: pause on ON_PAUSE, release at 30s, reassemble
the controller (paused, position preserved) when the user returns —
no auto-resume since voice has no autoplay setting.
PiP exemption (BackgroundMedia.isMutex) is preserved.
https://claude.ai/code/session_01RoEUbAN8ejF21Ns3eM6xad
- LocalCache now keeps a ConcurrentHashMap<txid, OnchainZapEvent>
populated in consume(OnchainZapEvent). First sender to claim a
txid wins. The on-chain transactions ViewModel switches to
LocalCache.getOnchainZapByTxid(txid), replacing the per-row scan
of notes.
- Each row in OnchainTransactionsScreen is now clickable: rows with
a matched zap event navigate to the zap's note thread; the rest
open the transaction on mempool.space in the system browser.
The 30s release timer fires on ON_PAUSE, but PipVideoActivity enters
PiP mode (which dispatches ON_PAUSE) immediately after onCreate. The
MediaController is built asynchronously and RegisterBackgroundMedia
runs only after inner() mounts, so there's a window where the timer's
BackgroundMedia.isMutex check sees a stale (or null) bgInstance and
releases a controller that was about to start background playback —
blanking the PiP window.
Add an opt-out parameter on GetVideoController and disable the timer
from PipVideoActivity. PiP IS the explicit background-playback opt-in,
so the timer doesn't apply by design.
https://claude.ai/code/session_01RoEUbAN8ejF21Ns3eM6xad