- cover CodePoints helpers and Channel.relays() equal-count behaviour
- Two new test files in commons/src/commonTest/, both run under :commons:jvmTest.
PR #3047 enabled iosArm64 + iosSimulatorArm64 on :commons and added
:commons:compileKotlinIosSimulatorArm64 as a CI gate, but the Phase 2
migration was incomplete — JVM-only APIs survived in commonMain and
several expect declarations had no iOS actual. Every main CI run since
the merge failed at "Compile Commons for iOS".
Migrations in commonMain
- Dispatchers.IO: add `import kotlinx.coroutines.IO` to 16 files, matching
the quartz/NostrClient.kt pattern (kotlinx-coroutines 1.11 exposes IO on
Native via this import; no shim needed).
- synchronized {}: replace with the existing KmpLock + withLock in
EOSECache, AcceptedGamesRegistry, EventDeduplicator, ThumbHashDecoder,
PeerSessionManager. Restructure two PeerSessionManager methods that
late-init vals from inside the lock — withLock returns a tuple now.
- Unicode code points: drop java.lang.Character / String.codePointAt /
String.offsetByCodePoints. Add commons/util/CodePoints.kt with surrogate
-pair-aware KMP helpers; rewrite EmojiCoder + EmojiUtils against them.
- Byte<->String: encodeToByteArray() / decodeToString() / concatToString()
in EmojiCoder, Base83, BlurHashEncoder, RobohashAssembler,
LongFormPublishAction (drops Charsets / String(CharArray) / toByteArray
no-arg).
- Math.round → Double.roundToLong in BlurHashEncoder.
- String.format → Compose Resources stringResource(res, vararg) overload
in LoadingState (FeedErrorState).
- toSortedSet → sortedByDescending { }.mapTo(LinkedHashSet) in Channel —
preserves the descending-by-relay-count iteration order callers depend
on.
- Comparator<T>: kotlin.Comparator on Native takes non-null T. Align
CreatedAtComparator / CreatedAtComparatorAddresses to compare(a, b) and
drop dead null checks in CreatedAtIdHexComparator.
iOS actuals (commons/src/iosMain/)
- WeakReference: switch from typealias to explicit `actual class`. The
expect param is `referent` (matches java.lang.ref); kotlin.native.ref.
WeakReference uses `referred`, so typealias fails the expect/actual
name-match check on Native. Add @file:OptIn(ExperimentalNativeApi).
- PlatformImage: functional IntArray-backed actual (used by BlurHash and
ThumbHash decoders at runtime); Phase 3 will swap to CGImage.
- ChessDismissedGamesStorage: in-memory only; NSUserDefaults wiring lands
with iosApp in Phase 3.
- SecureKeyStorage: stub throwing SecureStorageException. Keychain
Services binding is Phase 4 per the iOS plan.
- formattedDateTime: NSDateFormatter with "yyyy-MM-dd-HH:mm:ss" + POSIX
locale + local time zone (semantically matches the JVM
DateTimeFormatter "uuuu-MM-dd-HH:mm:ss" for post-1970 timestamps).
- checkNotInMainThread: no-op (mirrors jvmMain).
- PlatformNumberFormatter: NSNumberFormatter(.DecimalStyle), with
NSNumber.numberWithLongLong to disambiguate the NSNumber(Long)
overload set.
- isDebug: false constant; iosApp can flip via Swift `DEBUG` flag later.
Verified locally
- :commons:compileKotlinIosSimulatorArm64 + compileKotlinIosArm64 green
- :quartz:iosSimulatorArm64Test green
- :commons:jvmTest + :quartz:jvmTest green (no JVM regression)
- :quartz:verifyKmpPurity + :commons:verifyKmpPurity + spotlessCheck green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-up cleanups from the audit.
Base64Image.parse: when the regex matched but the data capture group
was missing, the migrated version returned an empty ByteArray. The
original threw NPE (java.util.Base64.getDecoder().decode(null)). Both
behaviors are accidents — restore the intended contract: throw the
existing "Unable to convert base64 to image" Exception explicitly.
FeedDefinitionSerializerTest gains a serializesToExpectedWireFormat
test that pins the byte-exact JSON output for a representative
multi-field feed. The legacy-Jackson migration claimed byte-identity
but only round-trip and reverse-compat were covered. Any future change
to field ordering / null handling / number formatting now fails this
test loudly, protecting users who have saved feeds on disk and any
downstream consumer expecting the stable order.
Address bugs and gaps surfaced by an audit of the prior 14 commits.
JVM tests passed because of typealias / platform-type lenience that
won't hold on Native; these are real iOS compile / behavior issues.
BUG fixes (iOS compile failures):
- commons/.../Note.kt:899 — Iterable.sumOf { -> BigDecimal } is a
JVM-stdlib-only overload. Common stdlib ships sumOf only for
Int/Long/Double/Float/UInt/ULong. Replaced with fold(BigDecimal(0)).
- commons/.../Note.kt:889 — BigDecimal(it.event?.content): the quartz
expect-class constructor takes String non-null; JVM accepted nullable
via platform-type lenience and threw NPE caught downstream. Switched
to ?.let { content -> BigDecimal(content) }.
- commons/.../Note.kt:838 — `catch (e: java.lang.Exception)` -> `Exception`.
- commons/.../feeds/custom/FeedDefinitionBuilder.kt + FeedBuilderState.kt:
inline FQN `java.util.UUID.randomUUID().toString()` -> kotlin.uuid.Uuid.
random().toString() (Kotlin 2.0+, @OptIn ExperimentalUuidApi).
inline `System.currentTimeMillis() / 1000` -> TimeUtils.now() (already
used elsewhere in the codebase).
- commons/.../viewmodels/NestViewModelTest.kt: moved from commonTest to
jvmTest. The test imports NestViewModel + nestsclient, both of which
the prior PR moved to jvmAndroid. commonTest depends on commonMain
only, so the test would fail to compile for iosSimulatorArm64Test.
SUBTLE fixes:
- commons/.../UserRelaysCache.kt: the flow field used double-checked
locking on a non-volatile var. JMM hazard on Native (ARM weak memory
model) — outer fast-path could observe a partially-published
WeakReference. Added @kotlin.concurrent.Volatile.
- commons/.../util/UrlValidation.ios.kt: NSURL.URLWithString("http:")
returns non-null with scheme="http" and no host; JVM's URI.toURL()
rejects with MalformedURLException. Reject scheme-only network URLs
(http/https/ws/wss/ftp without a host) to match JVM behavior.
- commons/.../util/KmpLock.kt commonMain doc: corrected "NSLock" ->
"NSRecursiveLock" to match the actual iOS implementation.
verifyKmpPurity gate extended (commons + quartz):
- Adds patterns: System.currentTimeMillis, Thread.sleep, java.util.UUID,
kotlin.jvm.Synchronized, kotlin.jvm.Volatile.
- Each pattern paired with a hint pointing at the canonical KMP
replacement; the error message surfaces both.
- Skips lines that start with //, *, or /* to avoid false positives on
KDoc / migration notes.
Pre-stages the three iosMain actuals that the macOS CI run is most
likely to demand once it compiles :commons for Native (the dev
container can't extract the K/N LLVM toolchain to validate locally).
- KmpLock.ios.kt: NSRecursiveLock — mirrors the ReentrantLock
semantics the jvmAndroid actual exposes (reentrant per-thread).
- WeakReference.ios.kt: actual typealias to kotlin.native.ref.
WeakReference<T> — same constructor + get(): T? shape as the
jvmAndroid typealias to java.lang.ref.WeakReference<T>.
- UrlValidation.ios.kt: NSURL.URLWithString with an explicit scheme
check, since NSURL is more permissive than JVM's URI.toURL() and
accepts scheme-less relatives that the JVM contract rejects.
Lands together so the next CI run's failure mode (if any) is more
informative than "iosArm64 unresolved reference" three times over.
Phase 2 task 10 of the iOS plan — flip on iOS targets for :commons.
Gradle dep resolution is fully green for iOS; actual Kotlin/Native
compilation runs on the macOS CI job (the dev container in which this
was authored can't extract the K/N LLVM toolchain).
Dep reshuffle to match what's actually KMP-available:
- commonMain: kept project(":quartz"), Compose Multiplatform, coil-compose,
androidx-collection, kotlinx-collections-immutable, kotlinx-serialization-json,
compose components-resources, androidx-lifecycle-viewmodel,
androidx-lifecycle-runtime-compose. These all publish iosArm64 +
iosSimulatorArm64 variants per `.module` inspection.
- jvmAndroid (NEW location): project(":nestsClient") (JVM+Android-only),
coil-okhttp (JVM-only), markdown-commonmark / markdown-ui /
markdown-ui-material3 (the RenderMarkdown.kt consumer is already in
jvmAndroid), and androidx-lifecycle-viewmodel-compose (AndroidX publishes
android + jvmStubs + linuxx64Stubs variants — no iOS, so the viewModel()
Composable helper stays JVM-bound until we either swap to the
org.jetbrains.androidx.lifecycle variant or accept a platform-specific
ViewModel access pattern on iOS).
- libs.versions.toml: adds androidx-lifecycle-viewmodel catalog entry.
- New intermediate source set iosMain → both iosArm64Main and
iosSimulatorArm64Main depend on it (clean place for iOS-only actuals
when KmpLock, WeakReference, etc. get their iOS implementations).
- .github/workflows/build.yml: test-quartz-ios job now also runs
:commons:compileKotlinIosArm64 + :commons:compileKotlinIosSimulatorArm64.
Three changes that bring commons/commonMain to zero java.* imports
(down from 18 at the start of Phase 2).
- EventListMatchingFilter, NoteListMatchingFilter: moved to jvmAndroid.
Both use ConcurrentSkipListSet + SortedSet for ordered concurrent
iteration, and their only consumer is LocalCache in the Android app.
iOS-time we can revisit if a KMP ordered concurrent set is needed.
- Note.kt's BigDecimal: switch import from java.math.BigDecimal to
quartz's existing expect/actual com.vitorpamplona.quartz.utils.BigDecimal.
BigDecimal.ZERO -> BigDecimal(0); BigDecimal.valueOf(longVal) ->
BigDecimal(longVal) (the expect class already has the Long
constructor). NoteOnchainZapTest gets the same treatment.
- Adds two top-level extensions in quartz commonMain (separate
BigDecimalOps.kt file to avoid the duplicate-JVM-classname collision
with the existing BigDecimal.kt actuals):
operator fun BigDecimal.plus(other: BigDecimal)
operator fun BigDecimal.minus(other: BigDecimal)
Lets += / + / - continue to work on commonMain BigDecimal values.
Commons/commonMain is now structurally iOS-ready as far as the
java.* import audit can tell. Remaining iOS work: actually flip on
the iOS targets, see what UI / dep transitives break, and address.
Fourth verb extraction alongside FollowActions / SearchActions /
ZapActions. Closes the largest remaining amy-expert "thin assembly"
violation in cli/.
Two pieces moved out of cli/.../DmCommands.kt into commons:
* DmActions.resolveDmRelays applies the strict-kind:10050 → NIP-65-
read → bootstrap fallback policy the in-app flow uses. Returns a
DmRelaySet with a typed RelaySource (KIND_10050 / NIP65_READ /
BOOTSTRAP / NONE) so callers can surface the source — amy emits
it on stdout, a future Gemini adapter could mention it in the
assistant response.
* DmActions.buildTextDm / buildFileDmReference are thin wrappers
over NIP17Factory.createMessageNIP17 / createEncryptedFileNIP17
that build the kind:14 / kind:15 template and gift-wrap in one
call. Matches the FollowActions / ZapActions builder shape.
amy's DmCommands is now genuinely thin assembly: requireUserHex,
flag plumbing, call DmActions, render JSON. The 583-line file shrank
slightly and — more importantly — no longer carries NIP-17 logic
the rest of the codebase needs to look at.
Receive-side decrypt loop (3 lines of unwrapAndUnsealOrNull) stays in
amy; too small to extract and tightly coupled to amy's per-relay
attribution.
10 new tests for DmActions: strict/permissive fallback chain, null
recipient lists, RelaySource enum stability, and a smoke test that
buildTextDm produces a kind:14 with the right wrap count (sender +
recipient).
Clears the two java.net.* importers from commons/commonMain.
- Adds expect fun isValidUrl(url: String?): Boolean in
commons/commonMain/util/. The jvmAndroid actual preserves the
exact JVM semantics (URI.toURL() + the same 3 catch arms);
iOS actual will use NSURL when the target lands.
- RichTextParser.isValidURL becomes a thin wrapper around
isValidUrl. Keeps the existing static call site so callers in
the Android app and Desktop need no change.
- UrlInfoItem.kt (link-preview model that wraps URI) moves to
jvmAndroid; its only consumers are the Android link-preview
pipeline (HtmlCharsetParser, UrlPreviewState, UrlPreviewCard),
which already live outside commonMain.
commons/commonMain is now down to 3 java.* importers: Note
(BigDecimal) and the two SortedSet-based observables.
Clears the remaining easy iOS blockers in commons/commonMain by
relocating files whose underlying feature isn't iOS-ready yet, rather
than fabricating expect/actuals we won't need until that feature ships.
- NestViewModel + ActiveSubscription: depend on :nestsClient (audio
rooms — Phase 5 per the iOS plan). Moved as-is; both already lived
in a jvmAndroid-shaped package.
- HtmlCharsetParser: depends on java.nio.charset.Charset, used only
by the Android link-preview pipeline (no Desktop / iOS consumer
today).
- RenderMarkdown: depends on com.halilibo.richtext.* — needs iOS
artifact verification before it can return to commonMain (tracked
for Phase 3).
- MediaContentModels.kt is split:
* URL-based models (BaseMediaContent, MediaUrlImage/Video/Pdf,
EncryptedMediaUrlImage/Video) stay in commonMain — pure KMP, no
java.io.File reference.
* Locally-cached variants (MediaPreloadedContent, MediaLocalImage,
MediaLocalVideo) move to a new MediaLocalContent.kt under
jvmAndroid — they hold a java.io.File and call .exists().
After this PR commons/commonMain has 5 remaining java.* importers
(Note's BigDecimal, the two SortedSet observables, URL parsing in
RichTextParser + UrlInfoItem). Down from 18 at the start of Phase 2.
Clears the last of the JVM-only synchronization annotations from
commons/commonMain so the model layer can compile on iOS. 15
methods across 4 files migrated.
- @Synchronized -> KmpLock.withLock { } with one per-instance syncLock
field per class. Original semantics preserved: @Synchronized on
methods of the same class synchronized on `this`, and a single
per-instance KmpLock gives the same exclusion.
* Channel.kt: addRelaySync, createOrDestroyFlowSync
* Chatroom.kt: addMessageSync, removeMessageSync
* MarmotGroupChatroom.kt: placeholderNote, addMessageSync,
restoreMessageSync, removeMessageSync, clearAllMessagesSync
* Note.kt: innerAddZap, innerAddOnchainZap,
innerRemoveOnchainZapForSource, innerAddZapPayment, addRelaySync,
createOrDestroyFlowSync
- Note.kt's @Volatile fields: now use kotlin.concurrent.Volatile
(KMP) instead of kotlin.jvm.Volatile (JVM-only) via explicit
import. Volatile semantics preserved on every target.
NestViewModel.kt also uses @Volatile (and the nestsClient project
dep); that file moves to jvmAndroid in a separate PR as planned
(audio rooms is Phase 5).
Closes the remaining items from the comparative review of the extracted
actions against the in-app Amethyst flows. All small, all surfaced by the
review.
* amy follow now stamps the relay hint on new contact-list `p` tags.
Best-effort read from the target's cached kind:10002 advertised
relay list (first writeRelaysNorm). Mirrors User.bestRelayHint() —
follows added via amy no longer have empty relayUri.
* amy search user now dedups by pubkey (sorted newest-first) instead
of by event id, matching the App Functions adapter. Multiple relays
surfacing different kind:0 revisions for the same author collapse
to one hit.
* AmethystAppFunctions.searchProfiles captures the active account AND
the relay client at function entry, then never touches sessionManager
or Amethyst.instance again during the drain. Closes the account-
switch race surfaced in the review.
* FollowActions / SearchActions / ZapActions kdoc now lists the
caller-side responsibilities each builder leaves to the consumer
(publish, writeable check, relay hint, pseudo-kind filtering,
LN round-trip, receipt verification, etc.). Documents the design
rather than letting it leak through reviews.
Address review feedback: the project already has LargeCache (in quartz,
with jvmAndroid/appleMain/linuxMain actuals) as its KMP concurrent-map
abstraction — it's used pervasively in the model layer. Adding stately
duplicated that capability with an external dep.
- Comparable-key maps switch to LargeCache:
* ChessEventCollector.moves (String key)
* ChessEventCollectorManager.collectors (String key)
* ChessRelayFetchHelper.events (String key)
* ChessRelayFetchHelper.relayEventCounts: LargeCache<NormalizedRelayUrl,
AtomicInt> with getOrCreate { AtomicInt(0) }.addAndFetch(1) — replaces
the stately .block { compute } increment idiom. getOrCreate is atomic
via ConcurrentSkipListMap.putIfAbsent so all threads end up
incrementing the same AtomicInt instance.
* ChessLobbyLogic.recentlyLoadedGames (String key)
- The SubscriptionManager pair (MutableComposeSubscriptionManager,
ComposeSubscriptionManager) keeps a plain mutableMapOf — T :
MutableQueryState is generic and not Comparable, so LargeCache's
ConcurrentSkipListMap backing would ClassCastException at put time.
Concurrency comes from a KmpLock-guarded map.
- Set-shaped uses switch to KmpLock + mutableSetOf:
* ChessEventCollector.processedEventIds
* ChessRelayFetchHelper.eoseReceived
* ChessLobbyLogic.dismissedGameIds + seenEventIds (the bounded LRU
keeps insertion-order eviction; mutableSetOf returns LinkedHashSet
on every KMP target).
- UserRelaysCache.flow's lock: stately Lock -> KmpLock.
Adds expect class KmpLock() with jvmAndroid actual that wraps
ReentrantLock. iOS actual (NSLock) will land with the iOS target.
Mirrors the WeakReference pattern from the previous PR.
Drops stately-concurrent-collections 2.1.0 from libs.versions.toml and
commons/build.gradle.kts (no remaining consumers).
The previous ZapActions.buildEventZapRequest signed a single zap request
to a single recipient. Notes carrying NIP-57 zap-split tags, NIP-53
live-activity host tags, or NIP-89 app-definition metadata expect the
payment to be distributed across multiple parties — so `amy zap event`
silently overpaid one party and underpaid the rest. The correctness
review on the action-set flagged this as the only real bug in the
extracted verbs; this commit fixes it.
* ZapSplitResolver — new commonMain object mirroring the resolution
order in ZapPaymentHandler.kt (splits > live-activity hosts > app
metadata > author fallback). Pure logic; pubkey→LN-address lookup
is passed in as a suspend lambda so amy reads from its file store
and Android reads from LocalCache, no shared cache-coupling.
* ZapActions.buildEventZapRequestsForSplits — high-level helper that
composes the resolver with per-share LnZapRequestEvent signing.
Each request's `relays` tag unions sender + author + recipient
inbox relays so the kind:9735 receipt routes to every interested
party (matches signAllZapRequests in the Android handler).
* amy zap event — rewired to the split-aware path. JSON output now
enumerates each recipient with its share, LN address, request id,
and BOLT11 invoice (or per-recipient invoice_error). Profile zaps
(amy zap user) keep the simple single-recipient path since they
have no split tags.
Tests: 12 new cases — LN-address splits, weighted pubkey splits, author
fallback, drop-silently-on-missing-LN, relay unioning, share rounding.
All 41 action tests green; both Android flavors compile.
Phase 2 of the iOS plan — clears the java.lang.ref.WeakReference
blocker from commons/commonMain. Four model files migrated; one
additional sync primitive replaced.
- Adds expect class WeakReference<T : Any> in
commons/commonMain/util/, with a jvmAndroid actual that typealiases
to java.lang.ref.WeakReference. iOS actual will typealias to
kotlin.native.ref.WeakReference when the target is added.
- Channel / Chatroom / MarmotGroupChatroom: the WeakReference(null)
initializer relied on platform-type nullability of
java.lang.ref.WeakReference's constructor. With T : Any in the expect
class, fields become nullable (WeakReference<...>? = null) and the
.get() callsites become ?.get(). Behaviorally equivalent.
- UserRelaysCache: same WeakReference migration, plus the
synchronized(this) double-checked-locking idiom is replaced with
co.touchlab.stately.concurrency.Lock + withLock (KMP).
kotlin.synchronized is JVM-only; Lock comes in transitively via
stately-concurrent-collections already added in the previous PR.
Model-layer @Synchronized usage in Channel/Chatroom/MarmotGroupChatroom/
Note (also JVM-only) is a separate iOS blocker and a separate PR.
Phase 2 of the iOS plan — clears the ConcurrentHashMap blockers from
commons/commonMain. Five files migrated (the four flagged in the
initial audit + ChessLobbyLogic, which used fully-qualified inline
java.util references that the import-based audit missed).
Adds co.touchlab:stately-concurrent-collections 2.1.0 — a small,
mature KMP library that provides ConcurrentMutableMap /
ConcurrentMutableSet with semantics equivalent to ConcurrentHashMap /
ConcurrentHashMap.newKeySet on every Kotlin target. The .block { }
helper covers the compound-update paths (ChessRelayFetchHelper's
per-relay event-count compute, ChessLobbyLogic's bounded-LRU dedup).
- ComposeSubscriptionManager + MutableComposeSubscriptionManager:
ConcurrentHashMap -> ConcurrentMutableMap
- ChessEventCollector + ChessEventCollectorManager: map and Set
- ChessRelayFetchHelper: in-function event/relay state
- ChessLobbyLogic: replaces dismissedGameIds (synchronizedSet),
recentlyLoadedGames (ConcurrentHashMap), seenEventIds (bounded LRU
using LinkedHashSet via Collections.synchronizedSet + synchronized {}).
seenEventIds keeps insertion-order eviction semantics because
mutableSetOf returns LinkedHashSet on every KMP target.
Phase 2 of the iOS plan — two of the ~9 small migrations to clear
java.* imports out of commons/commonMain.
- ChessLobbyState: the AtomicLong stateVersionCounter only existed to
bump a MutableStateFlow<Long>. MutableStateFlow.update is itself
atomic, so the counter is redundant — replaced with
_stateVersion.update { it + 1 }. Removes the dep and simplifies the
code.
- SigningState (GlobalSigningStatus): AtomicInteger is doing real
cross-thread coordination. Migrated to kotlin.concurrent.atomics.
AtomicInt (KMP stdlib). The common-API method names differ from
AtomicInteger — addAndFetch(±1) / store(0) instead of
incrementAndGet / decrementAndGet / set.
Phase 2 of the iOS plan — first of ~9 small migrations to clear the
java.* imports out of commons/commonMain. Replaces java.util.Base64
with kotlin.io.encoding.Base64 (stdlib, KMP-clean). The two callers
(Android Base64Fetcher, Desktop DesktopBase64Fetcher) use the public
parse() signature only, which is unchanged.
Also documents the full Phase 2 audit in
amethyst/plans/2026-05-24-ios-support.md: out of 335 commonMain files,
21 are real iOS blockers grouped into ~10 small mergeable PRs. The
remaining 183 androidx.compose users and 7 androidx.lifecycle users
already map to JetBrains Compose Multiplatform / AndroidX KMP and need
no work.
The one Jackson holdout in commons/commonMain. Migrating it unblocks
the iOS purity gate for :commons (Phase 1 of the iOS plan).
- Rewrites FeedDefinitionSerializer with the kotlinx.serialization JSON
tree API (JsonObject / JsonArray / JsonPrimitive). Wire format is
byte-identical, so users' existing on-disk custom-feed definitions
keep deserializing — covered by a new parsesLegacyJacksonOutput test
that pins a hand-written Jackson-shaped JSON blob.
- Adds :commons:verifyKmpPurity (mirrors the one in :quartz) and wires
it into the CI lint job alongside :quartz:verifyKmpPurity.
- Pulls in kotlinx-serialization-json as a commonMain dep; the
serialization plugin was already applied on :commons.
Third verb extraction alongside FollowActions / SearchActions, scoped
to event building so the action stays target-agnostic (commonMain,
no JVM/Android coupling).
* buildUserZapRequest / buildEventZapRequest wrap the two
LnZapRequestEvent.create overloads with a uniform call shape and
sensible defaults (PUBLIC zap, no LNURL, no poll).
* extractLnAddress pulls lud16 (preferred) or lud06 from a kind:0
metadata event, returning null when neither is set.
* satsToMillisats covers the sats→msats conversion that every
caller would otherwise duplicate.
Wires up amy zap user|event as the first consumer. The Lightning
round-trip (LNURL fetch + invoice retrieval) goes through the existing
LightningAddressResolver in commons/jvmAndroid; the BOLT11 invoice is
printed but not auto-paid since amy has no NWC wallet wired up yet.
Introduce SearchActions alongside FollowActions as the second of the
shared "verbs" usable by amy CLI and a future Android App Functions
adapter for Gemini.
* searchProfilesFilter / searchNotesFilter build the relay-side
Filter with the NIP-50 `search` field set; blank queries return
null so callers don't issue unconstrained searches that relays
would reject anyway.
* resolveSearchRelays picks the caller's kind:10007 list when
configured (decrypting NIP-44 private entries via the signer) and
falls back to DefaultSearchRelayList — the same set the Android UI
uses when the user has no list of their own.
Wires up amy search user|note as the first consumer.
Introduce commons/.../actions/FollowActions as the canonical, non-UI
entry point for NIP-02 kind:3 mutations. Accepts pubkeys as HexKey
rather than the Compose-bound User model, so callers without a cache
(amy CLI, future Android App Functions adapter for Gemini, automation
scripts) can drive follow/unfollow directly.
Kind3FollowListState.follow/unfollow now delegate to FollowActions,
preserving the existing Account.follow(user) signature on Android.
Behavior is unchanged for UI callers.
Wires up amy follow/unfollow as the first consumer — fetches the
freshest kind:3 from outbox relays before mutating so concurrent
follows from another client are preserved.
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.