SendDialog: switch to Dialog+Card with X close, inline copiable error
messages, button resets to "Pay Invoice" on error for retry.
LightningAddressResolver: return error body from callback responses so
server error messages (e.g. "Recipient wallet error") surface to user
instead of generic "Failed to fetch invoice". Also check "message"
field in addition to "reason" for error extraction.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.
On resizing CDNs the imeta `x` (post-resize hash) can differ from the
`ox` (original hash) embedded in the URL. The bridge previously preferred
`explicitHash` over the URL's sha for "authoritative casing", but the
upstream file on `xs` is named after the URL's sha, not the imeta hash.
For URLs like https://image.nostr.build/<ox>.png with imeta x=<post-resize>
the cache would request /<x>.png and 404 on miss.
Always use the sha parsed from the URL path; drop the explicitHash
parameter. `extractSha256FromUrlPath` already lowercases, so the casing
concern is moot.
When a kind-23195 event arrives signed by someone other than the wallet
service we sent the request to, we now count it on the pending entry and
leave the entry in place so the legitimate reply can still resolve. But
if no legitimate reply arrives and the 30s timeout fires, the user used
to see a generic "Wallet request timed out" — indistinguishable from
"the wallet is just slow", even when an active attacker was forging
replies and dropping the real ones.
Carry the per-request spoof count through to the timeout error message:
- NwcPaymentTracker.PendingRequest gains an AtomicInteger spoofAttempts.
onResponseReceived increments it on WrongAuthor.
- New tracker method spoofAttemptsFor(requestId) reads the count.
- Account exposes nwcSpoofAttempts() and cleanupNwcRequest() so the
UI doesn't need to reach into LocalCache.
- Account.sendNwcRequestToWallet now returns the request event id so
callers can identify the pending entry.
- WalletViewModel.launchTimeout takes a () -> HexKey? provider and
fetches the spoof count when the timeout fires. The error becomes
"Wallet request timed out — N replies were rejected because they
were signed by an unexpected key. Your relay may be untrusted."
Also calls cleanupNwcRequest on timeout to avoid leaking the entry.
Silent on the happy path: a forged reply followed by the real one does
not trigger any user-facing message — the spoof count is discarded with
the matched entry.
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.
The previous commit dropped `authors` and `#p` from the relay subscription
filter to match Primal's interop shape. Without those, the relay will
deliver any signed kind-23195 event that carries our request id in `#e`,
so an attacker who can observe the request on the relay could forge a
"response" with their own keypair: Amethyst would happily derive a shared
secret from `event.pubKey` (the attacker), decrypt the payload, and
display attacker-controlled balance/transaction data. Even worse,
`paymentTracker.onResponseReceived` removed the pending entry on first
match — so the legitimate wallet reply that followed was silently dropped.
Move the author check from the relay layer into NwcPaymentTracker:
- `registerRequest` now requires the expected wallet-service pubkey
(read from the request's `p` tag). LocalCache extracts it during
`consume(LnZapPaymentRequestEvent)` and refuses to register if the
request has no `p` tag.
- `onResponseReceived` takes the response author and returns a sealed
MatchResult of NoMatch / WrongAuthor / Matched. A WrongAuthor result
leaves the pending entry in the map so the legitimate response can
still resolve it.
- Android LocalCache and DesktopLocalCache both adopt the new API and
log a warning on suspected spoof attempts.
End-to-end the response is still encrypted under the per-connection shared
secret, so this is a second layer of defence rather than the only one,
but matching the author keeps a forged kind-23195 from consuming the
pending slot and DoSing the legitimate reply.
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.
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.