Commit Graph
2283 Commits
Author SHA1 Message Date
Claude 6fb735018c refactor(quartz/relay): drop redundant pubKey param from onAuthenticated/authorize
pubKey was always event.pubKey (the NIP-42 signer is the authenticated
identity), so the parameter was pure redundancy. onAuthenticated(event) and
authorize(event) now read event.pubKey directly; the engine still commits
cmd.event.pubKey. Removes the now-unused HexKey imports from IRelayPolicy and
PolicyStack.
2026-06-04 17:32:24 +00:00
Claude a54d9c92a7 refactor(quartz/relay): move auth identity from policy into connection scope
Authentication state (who is logged in on a connection) is connection scope,
not a policy decision. It was stored on FullAuthPolicy, which forced the
AuthScopedPolicy marker, the PolicyStack union, and a downcast in
RequestContext just to route it back out as scope.

Now the engine owns it: RelaySession holds the authenticatedUsers set behind
the (now public) requestContext; the data plane and policies read it through
RequestContext. The policy stays pure decision —
- onConnect(scope, send): a per-connection policy captures the read-only scope
  to gate on; shared singletons ignore it.
- onAuthenticated(): Boolean: the policy's vote on whether to record the
  verified pubkey (default false, so blind-accept policies never record an
  unverified identity). FullAuthPolicy runs authorize() then votes true.
- RelaySession.handleAuth performs the single, engine-side commit after the
  whole chain approves and a verifying policy votes to record.

FullAuthPolicy keeps all auth logic (challenge, accept(AuthCmd), gating,
authorize) and gains a protected authenticatedUsers accessor over the scope for
subclasses (restricted content / filter rewrite). Deletes AuthScopedPolicy,
PolicyStack.authenticatedUsers, and the RequestContext downcast.
2026-06-04 16:45:03 +00:00
Claude 6943c1a056 docs(quartz/relay): component & data-flow diagram for the auth-scope plan 2026-06-04 16:03:00 +00:00
Claude 0008f676b6 docs(quartz/relay): plan to move auth identity from policy to connection scope 2026-06-04 15:56:36 +00:00
Claude a98f5fddd2 refactor(quartz/relay): move authenticatedUsers off IRelayPolicy to a marker
Keep the universal policy interface free of NIP-42: instead of a default
authenticatedUsers on IRelayPolicy, add an opt-in AuthScopedPolicy mixin that
only FullAuthPolicy (and PolicyStack, which unions its auth-tracking members)
implements. RequestContext.authenticatedUsers resolves it via an `as?
AuthScopedPolicy` downcast, defaulting to empty — so non-auth relays carry no
auth concept, and the accessor now earns its keep by encapsulating that cast
(ctx.policy is IRelayPolicy and no longer exposes the set directly).
2026-06-04 15:36:40 +00:00
Claude 3d6acde472 feat(quartz/relay): thread a per-connection RequestContext into EventSource
Non-storage relays answer a REQ purely from filters: EventSource.events()
got no session/auth context, even though the connection's policy already
knows the authenticated pubkey(s). That walled the auth state off from the
code that produces events, forcing a shared mutable holder + hand-wired
RelaySession to build any caller-aware relay (NIP-50 search scored from the
viewer, DM-style restricted content, paid/allow-listed sets, per-connection
tenancy).

Introduce RequestContext (connectionId, authenticatedUsers, policy) and pass
it through the read path: RelaySession -> SessionBackend.query/count/
countResult -> EventSourceBackend -> EventSource. authenticatedUsers is now
exposed on IRelayPolicy (default empty, overridden by FullAuthPolicy and
unioned by PolicyStack) and read live, so a REQ after AUTH sees the freshly
authenticated pubkey(s). For richer per-connection state, downcast ctx.policy.

EventSourceServer.serve { } is now usable for auth-scoped relays without a
side channel. Adds a test proving ctx.authenticatedUsers reaches the source
after a NIP-42 handshake; updates RELAY.md.
2026-06-04 15:25:39 +00:00
Claude 1c54a877e2 refactor(quartz): split relay server package into engine / backend / policies
The relay server package had 14 top-level files. Group by concern:

- relay/server/          engine + entry points: NostrServer, EventSourceServer,
                         RelayServerBase, RelaySession, ConnectionRegistry,
                         RelayServerListener, NegSessionRegistry
- relay/server/backend/  data plane: SessionBackend, EventSource,
                         EventSourceBackend, LiveEventStore, IngestQueue
- relay/server/policies/ policy model + impls: IRelayPolicy(+PolicyResult) and
                         RelayLimits move in alongside the existing policies
- relay/server/inprocess/ unchanged

Layering is acyclic: engine -> {backend, policies} -> nip01Core.

This moves three already-published classes (IRelayPolicy, LiveEventStore,
IngestQueue), so external quartz consumers re-import — a source-only change,
no behavior change. In-repo callers (geode) updated. Full quartz + geode
suites green; RELAY.md source map updated.

https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
2026-06-03 21:44:12 +00:00
Claude 66481dac05 refactor(quartz): rename ReqResponder -> EventSource; drop JwtAuthPolicy doc
The SPI name leaned on "Req" (the NIP-01 command), which isn't self-explanatory.
Rename to read as what it is — a source of events for a query:

- ReqResponder        -> EventSource          (method respond() -> events())
- ReqResponderBackend -> EventSourceBackend   (param responder -> source)
- ReqResponderServer  -> EventSourceServer
- *Test + RELAY.md + KDoc references updated to match.

Also drop the JwtAuthPolicy snippet from RELAY.md (it was only an illustrative
doc example, never a real class); the surrounding prose still documents the
`authorize` bridge hook.

Pure rename + doc edit, no behavior change; full suite green.

https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
2026-06-03 21:09:24 +00:00
Claude c13dbad99d refactor(quartz): simplify relay server internals (no behavior change)
Cleanup pass (4 review angles), behaviour-preserving, full suite green:

- Extract RelayServerBase: NostrServer and ReqResponderServer duplicated
  connect/serve/buildPolicy/activeConnections and the connection scope verbatim
  (ConnectionRegistry only unified the bookkeeping). They now share one engine
  base and contribute only their backend + teardown; ReqResponderServer is ~10
  lines, NostrServer just its ingest/store wiring.
- HyperLogLog.leadingZeroBits: replace the hand-rolled per-byte bit loop with
  stdlib Int.countLeadingZeroBits() (- 24 for the 0..255 byte).
- LimitsPolicy.clampLimits: drop the `var changed` + throwaway-list map for a
  `none{} -> map{}` that's simpler and allocates nothing when no filter is
  clamped (the common case once maxLimit is set).
- PolicyStack: collapse the two first-non-null hook loops to firstNotNullOfOrNull.
- RelaySession.handleAuth: use OkMessage.rejected(MachineReadablePrefix.ERROR, …)
  instead of hand-writing the "error:" prefix, matching the COUNT path.

Skipped: rewriting FullAuthPolicy's pre-existing auth-required:/invalid: reason
strings to MachineReadablePrefix — unchanged context lines, out of this diff's
scope.

https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
2026-06-03 20:43:52 +00:00
Claude 9a19a0f346 refactor(quartz): remove pendingNewlyAdded — commit auth once, not commit-then-rollback
The pendingNewlyAdded field was a rollback token faked through instance state:
FullAuthPolicy.accept(AuthCmd) committed the pubkey eagerly, so a later
rejection (composed policy or a throwing hook) had to be undone, and the field
existed only to avoid dropping a pubkey that was already authenticated.

Fix the root cause — the eager commit. accept(AuthCmd) now only validates; the
pubkey is recorded in FullAuthPolicy.onAuthenticated (made final), which the
engine calls only after accept AND the whole policy chain approve the AUTH.
External-auth bridges override a new open `authorize` hook that runs before the
commit; throwing rejects the login with nothing committed to undo.

This deletes pendingNewlyAdded, IRelayPolicy.onAuthenticationFailed, its
PolicyStack override, and both rollback call-sites in RelaySession.handleAuth —
and makes the prior compose-after-reject / failed-re-AUTH cases correct by
construction (no rollback to get wrong). Tests updated to override `authorize`;
the two regression tests pass unchanged.

https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
2026-06-03 20:25:12 +00:00
Claude a21f811e87 refactor(quartz): clarity pass on relay server internals
Behaviour-preserving readability improvements (full suite green):

- LimitsPolicy: drop the <T : Command> generic reject helpers (which forced
  rejectSubId<ReqCmd>(...) call-site type args). Each check is now a
  "rejection reason or null" function (eventRejection / subscriptionRejection)
  and the accept overloads just wrap a non-null reason — the three overloads
  read almost identically.
- Extract ConnectionRegistry: NostrServer and ReqResponderServer duplicated
  the connection bookkeeping (stable-id keying, active gauge, once-only
  teardown accounting). That subtle logic now lives in one named class both
  servers delegate to; their connect()/close() shrink to the parts that
  actually differ (the backend and what else teardown closes).
- HyperLogLog.addPubKey: rename `ri` -> `registerIndex`.

https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
2026-06-03 20:15:06 +00:00
Claude 366d9435d7 fix(quartz): pre-merge review fixes — auth rollback, listener rename
Final-review findings on the relay tooling:

- Auth bypass (security): FullAuthPolicy.accept(AuthCmd) commits the pubkey,
  but handleAuth only rolled back on the hook-throw path — a policy composed
  AFTER FullAuthPolicy that rejects the AuthCmd left the connection
  authenticated behind an OK false. handleAuth now also rolls back on the
  reject path, preserving OK-true-iff-authenticated for any composition order.
- Failed re-AUTH no longer drops a prior valid auth: onAuthenticationFailed
  removes only the pubkey THIS AUTH newly added (tracked via Set.add's return),
  not one already authenticated earlier on the connection.
- Rename the server-side RelayConnectionListener -> RelayServerListener to
  avoid colliding with the existing client-side
  relay.client.listeners.RelayConnectionListener (published-API clarity).
- RelayLimits.toNip11Limitation clamps created_at bounds to Int range so a
  post-2038 epoch second can't wrap negative in the NIP-11 document.
- Add regression tests for both auth cases (reject-after-FullAuth; failed
  re-AUTH keeps prior auth).

https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
2026-06-03 19:41:55 +00:00
Claude 648a00ef63 feat(quartz): add missing NIP-11 banner field to relay info document
Nip11RelayInformation modeled `icon` but not `banner` (NIP-11's wide
promotional image, distinct from the square icon), so relays advertising a
banner couldn't round-trip it. Add the field + a round-trip test.

https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
2026-06-03 19:22:37 +00:00
Claude 37b07057b2 refactor(quartz): enforce session-level limits through policy hooks
Previously max_message_length and max_subscriptions were hard-coded in
RelaySession behind a parallel `limits` param, while the per-command limits
went through LimitsPolicy — two mechanisms, and a custom policy couldn't
influence the session-level ones.

Unify them: add two default-noop hooks to IRelayPolicy —
acceptMessage(raw) (pre-parse) and acceptSubscription(subId, openCount) —
chained through PolicyStack so they compose across multiple policies.
LimitsPolicy now implements all limit checks; RelaySession just invokes the
hooks and no longer takes a `limits` param. Servers compose LimitsPolicy
whenever `limits` is set and keep `limits` only to advertise via NIP-11.

Behaviour is unchanged (oversized -> NOTICE invalid:, sub cap -> CLOSED
rate-limited:); the enforcement now lives in the policy layer. Adds direct
hook unit tests; existing end-to-end limit tests still pass.

https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
2026-06-03 18:58:20 +00:00
Claude 70dcaa1b10 feat(quartz): relay limits as single source of truth + NIP-11 serving
A relay author no longer hand-rolls limit policies or wires NIP-11 twice.

- RelayLimits: one config that is both enforced and advertised. Pass it to
  NostrServer/ReqResponderServer and every limit is applied; toNip11Limitation()
  renders the same numbers into the NIP-11 limitation block so they can't drift.
- LimitsPolicy: per-command enforcement (max_content_length, max_event_tags,
  created_at bounds reject EVENT; max_filters / max_subid_length reject
  REQ/COUNT; max_limit clamps, default_limit fills) with invalid: prefixes.
  Servers prepend it automatically when limits declares command caps.
- RelaySession: session-level caps that a policy can't see — max_message_length
  (NOTICE before parse) and max_subscriptions (rate-limited: CLOSED on new sub).
- NIP-11 serving: Nip11RelayInformation.toJson() + CONTENT_TYPE
  (application/nostr+json); the existing model was parse-only.
- Tests for the policy, the session-level caps end-to-end, and the
  limits->NIP-11 round trip; RELAY.md Limits + Serving NIP-11 sections.

https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
2026-06-03 18:41:06 +00:00
Claude 952da685d8 feat(quartz): NIP-45 approximate COUNT — HyperLogLog construction + wire support
The HLL aggregation side (estimate/merge/encode) existed but the construction
side did not, and the Jackson wire (de)serializer silently dropped the `hll`
field — so a JVM/Android relay could not actually answer COUNT with HLL.

- HyperLogLog.addPubKey(): the NIP-45 construction (register index = pubkey
  byte at the filter offset; value = leading-zero-bits from offset+1, +1),
  KMP-safe. Plus HyperLogLog.builderFor(filter) and an HllBuilder that streams
  event pubkeys into registers and yields an approximate CountResult.
- Plumb CountResult through the count path: SessionBackend.countResult /
  ReqResponder.countResult (default = exact count(); override for approximate/
  hll); RelaySession sends the returned CountResult.
- Fix CountResultSerializer/Deserializer (jvmAndroid) to write/read the `hll`
  hex field, matching the kotlinx serializer the native targets already use.
- Tests for construction (index/value/merge-idempotence) and the wire path;
  RELAY.md Approximate COUNT section.

https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
2026-06-03 18:29:01 +00:00
Claude 319ffb729a feat(quartz): relay connection observability + stable connection ids
Gives relay operators metrics/logging hooks without patching the engine, and
removes the hashCode()-keyed connection registry the audit flagged.

- RelaySession gains a stable, process-unique `id` (monotonic counter).
- RelayConnectionListener (onConnect/onDisconnect, no-op default) is accepted
  by NostrServer and ReqResponderServer; both now key their connection
  registry by `id` instead of hashCode() (no more identity-collision hole).
- Both servers expose a live `activeConnections` gauge. Teardown accounting is
  idempotent (double close counted once) and onDisconnect fires for any
  connections still open at server close.
- Tests + RELAY.md Observability section.

https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
2026-06-03 18:21:05 +00:00
Claude bcb4b2b964 fix(quartz): roll back authentication when the post-auth hook rejects
Audit finding: FullAuthPolicy.accept(AuthCmd) added the pubkey to the
authenticated set before onAuthenticated ran, so a bridge that threw from
onAuthenticated (its whole point — reject when e.g. a JWT exchange fails)
produced an OK false while the connection stayed authenticated server-side.
Subsequent REQ/EVENT/COUNT were then allowed despite the failed login — an
auth bypass.

- Add IRelayPolicy.onAuthenticationFailed(pubKey) (default no-op), forwarded
  by PolicyStack and overridden by FullAuthPolicy to drop the pubkey.
- RelaySession.handleAuth calls it when onAuthenticated throws, restoring the
  invariant that a client treated as authenticated is exactly one that got
  OK true. The rollback is itself guarded so a misbehaving policy can't also
  swallow the failing OK.
- Tests: failed-hook now asserts the connection is NOT authenticated and that
  a follow-up REQ is rejected with auth-required.
- RELAY.md: note that throwing from onAuthenticated rolls auth back.

https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
2026-06-03 17:45:52 +00:00
Claude 68e49a97ba feat(quartz): Flow<Event> REQ-responder SPI + storage-free dispatch engine
Lets non-storage relays (search, redirector, computed/projected data) answer
REQs without implementing the heavy IEventStore or hand-writing the
readFrame -> parse -> policy -> EVENT/EOSE loop.

- ReqResponder: the public Flow<Event> SPI — respond(filters): Flow<Event>
  (+ a count() default). EOSE is sent when the flow completes.
- SessionBackend: the seam RelaySession now depends on (query/count/submit/
  negentropy-snapshot). submit + snapshot default to reject / empty so a
  responder only implements the read path. LiveEventStore implements it
  (storage path unchanged); ReqResponderBackend adapts a ReqResponder.
- ReqResponderServer: storage-free dispatch engine mirroring NostrServer's
  connect/serve/close, reusing RelaySession for the full wire protocol.
- RelaySession now frames backend failures as CLOSED error: <msg> (REQ) and
  count failures likewise, instead of dropping the coroutine — useful for
  responders doing network I/O.
- RELAY.md: Non-Storage Relays section + engine/source-map updates.

Storage path (NostrServer + IEventStore, live tail, negentropy) is unchanged;
existing server/auth/negentropy tests pass alongside the new responder tests.

https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
2026-06-03 17:31:53 +00:00
Claude 69aea29954 feat(quartz): server-side relay ergonomics — NIP-50 parser, suspend auth hook, wire helpers
Addresses the self-contained, low-risk items from the relay-ergonomics
request:

- NIP-50: add SearchQuery to parse Filter.search into free-text terms and
  the typed key:value extensions (domain/language/sentiment/nsfw/include),
  preserving unknown extensions and offering a canonical toSearchString().
- NIP-42: add a suspend IRelayPolicy.onAuthenticated(pubKey, event) hook
  (chained through PolicyStack) so external-auth bridges (e.g. JWT exchange)
  can live inside FullAuthPolicy instead of leaking into transport code.
  RelaySession invokes it after the AUTH passes; a throw becomes OK false.
- Ergonomics: Command.fromJson/toJson and Message.fromJson/toJson mirroring
  Event, plus MachineReadablePrefix + OkMessage/ClosedMessage factories for
  standardized OK/CLOSED reason prefixes.
- Docs: RELAY.md sections for the external-auth bridge, NIP-50 search, and
  the wire helpers.

https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
2026-06-03 17:15:13 +00:00
Claude 3a43c4f7bb fix: register CommunityRulesEvent in EventFactory
Kind 34551 (CommunityRulesEvent) was missing from EventFactory.create, so
signing a community-rules template produced a generic Event. Returning it
as CommunityRulesEvent in Account.sendCommunityRules threw a
ClassCastException when publishing community rules.

Register the kind in the factory and add a regression test.
2026-06-03 13:30:52 +00:00
Róbert NagyandGitHub 1f81a7fb25 Merge branch 'main' into fix/desktop-log-noise 2026-06-02 10:51:59 +03:00
nrobi144andClaude Opus 4.6 2ca8eb31dc fix: address root causes of 6 runtime log noise issues
1. LocalRelayStore: use batchInsert() with per-row savepoints instead of
   manual transaction — UNIQUE constraint violations skip that row instead
   of failing the whole batch

2. Robohash empty hex: guard blank input in CachedRobohash.get() with a
   fallback all-zeros hex key instead of passing empty string to assembler

3. GiftWrapEvent decrypt: downgrade from WARN to DEBUG — expected when
   gift wraps from local relay cache aren't addressed to current user
   (subscription filter is correct, but hydration doesn't filter by p-tag)

4. Relay URL %20: decode percent-encoded spaces before rejection check in
   RelayUrlNormalizer.fix() — wss://relay.example.com/%20 now normalizes
   to wss://relay.example.com/ instead of being rejected

5. NIP19 Parser: downgrade from ERROR/WARN to DEBUG — malformed bech32
   from relay content is expected in the wild, catch+log is correct

6. VLC macOS: add --avcodec-hw=none (disables VideoToolbox that causes
   CVPN chroma failures) and --reset-plugins-cache (rebuilds stale cache
   on startup instead of logging hundreds of stale-cache errors)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 10:47:58 +03:00
Claude 1c2775c6b5 fix(quartz): emit q tags again (inverted guard dropped all quotes)
TagArrayBuilder.addUniqueValueIfNew had an inverted guard:

    if (tag.has(1) || tag[0].isEmpty() || tag[1].isEmpty()) return this

Since has(index) == size > index, `tag.has(1)` is true for every
well-formed tag with a value, so the function returned early and never
added it. addUniqueValueIfNew / addAllUniqueValueIfNew are used only by
the quote() / quotes() builders, so every `q` tag (naddr, nevent, note,
nembed, npub, nprofile) has been silently dropped since this file was
introduced. Restore the missing `!` and add a regression test covering an
addressable (naddr) quote plus the guard's accept/skip/dedupe semantics.

https://claude.ai/code/session_01NMavNzJ7VRLhoD3hboCCC7
2026-06-01 19:15:03 +00:00
Claude 3f4066c4b8 refactor: buildHashtagLabel takes an EventHintBundle
Replace the labeledEventId/relay/author trio with a single
EventHintBundle<out Event>, building the e-tag via the standard
EventHintBundle.toETag() idiom. The Account caller now passes the
note's event hint directly.

https://claude.ai/code/session_019gc3FipVBcndF9fmqCCfVX
2026-05-30 21:23:56 +00:00
Claude d57f8c18c6 feat: first-class NIP-32 hashtag labels on posts and in the hashtag feed
Let users tag any post with a hashtag via a NIP-32 kind 1985 label event
(using the `#t` tag-association namespace), and surface follow-labeled
posts in the hashtag feed.

quartz:
- LabelEvent.buildHashtagLabel() + HASHTAG_NAMESPACE ("#t") and
  hashtagAssociations() to build/extract hashtag-association labels.

commons:
- Note now carries a `labels` reverse-reference map (hashtag -> labeler
  notes) with addLabel/removeLabel and a NoteFlowSet.labels flow,
  mirroring reactions/reports.

amethyst:
- LocalCache consumes LabelEvent, attaching hashtag labels to their
  target notes and re-notifying feed observers for already-cached
  targets.
- Account.createLabelHashtagEvent/labelHashtag/consumeLabelEvent and
  AccountViewModel.labelWithHashtag (tracked + direct broadcast).
- Overflow "⋯" menu gains an "Add hashtag" action backed by a new
  AddHashtagLabelDialog.
- HashtagFeedFilter also accepts posts a followed user labeled with the
  hashtag; a new label sub-assembler subscribes to kind 1985 by `#l`
  and fetches missing label targets.
- Hashtag feed shows an attribution banner ("#tag added by @user") above
  follow-labeled posts via a custom RefresheableFeedView onLoaded.

https://claude.ai/code/session_019gc3FipVBcndF9fmqCCfVX
2026-05-30 20:32:43 +00:00
davotoula 70f72d4604 fix(sonar-s1871): match ChannelMessage/Metadata explicitly, not via IsInPublicChatChannel
fix(sonar-s1871): merge PayInvoice/Nwc error arms via IErrorResponseLike
fix(sonar-s1871): drop redundant is CommentEvent branch in ThreadFeedView
fix(sonar-s1871): merge NPub/NProfile route arms via IPubKeyEntity
fix(sonar-s1871): merge Error/Notice debug-message arms via IRelayDebugMessageText
fix(sonar-s1871): merge LiveActivities/MeetingRoom arms via LiveStreamLike
fix(sonar-s1871): merge channel-message/metadata arms via IsInPublicChatChannel
fix(sonar-s1871): collapse channel-draft reply handling via BaseThreadedEvent
fix(sonar-s1871): drop redundant is CommentEvent arm in NoteCompose
fix(sonar-s1871): merge Failed/Error arms in NIP-05 badge
fix(sonar-s1871): merge Verifying/NotStarted arms in NIP-05 badge
fix(sonar-s1871): merge note-backed RenderOption arms via NoteBackedName
fix(sonar-s1871): drop redundant is PrivateDmEvent arm in RouteMaker
fix(sonar-s1871): merge GiftWrap/SealedRumor arms in RouteMaker via HasInnerEvent
fix(sonar-s1871): merge Connecting/Connected arms in CallSession state collector
fix(sonar-s1871): collapse playback-state when to if/else in CurrentPlayPositionCacher
fix(sonar-s1871): drop redundant is CommentEvent arm in sendPublicReply
fix(sonar-s1871): merge addressable-filter arms via AddressableTopFilter
fix(sonar-s1871): merge repost branches in LocalCache via BaseRepostEvent
fix(sonar-s1871): merge badge-set branches in LocalCache referenced-notes when
2026-05-30 19:43:03 +02:00
Claude b1961ea19c fix(zap): address reload-mint audit findings (double-submit, premature done, fee/poll robustness)
Correctness:
- ReloadMintViewModel.confirm() now guards against re-entry (only starts from
  Configuring/Failed and flips to Working synchronously), so a double-tap or a
  Failed-state Retry can't launch two pipelines that double-spend the source /
  double-mint the target.
- sendNutzapAndFinish awaits the real CashuWalletState.sendNutzap (throws on
  failure) and reports Done only on success — a send that fails after a
  successful reload now surfaces as Failed instead of popping the screen on a
  premature "done" and silently stranding the moved funds.
- The reload pipeline runs on the long-lived AccountViewModel scope; the VM now
  holds its Job and cancels it in onCleared(), so leaving the screen stops the
  (up to 3-minute) Lightning poll instead of hammering the mint unobserved.
- rebalance() poll budget widened to a steady ~60s so a merely-slow mint no
  longer strands funds that already left the source.
- Mint a small headroom buffer (RELOAD_FEE_BUFFER_SATS) above the bare shortfall
  so the follow-up nutzap's own swap fee doesn't leave the target a sat short;
  the source-feasibility gate accounts for it.

Regressions from the settings merge:
- mergeZapAmounts / the picker no longer .sorted() the amounts — a user's saved
  preset order is preserved instead of being silently reordered ascending.
- The on-chain send dialog falls back to DEFAULT_ONCHAIN_ZAP_SATS when the
  unified list has nothing above the on-chain minimum, restoring the guaranteed
  quick-pick preset.
- zapClick's one-tap (single-amount) path now checks rail capability and opens
  the picker when the recipient can't receive Lightning, instead of firing a
  doomed Lightning zap.

Cleanup:
- Centralized the mint-quote "settled" predicate as MintQuoteBolt11ResponseDto
  .isSettled(); rebalance, ReloadMintViewModel and CashuWalletViewModel now
  share it instead of three copies of paid==true || PAID || ISSUED.

https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
2026-05-29 20:23:00 +00:00
Claude 30a845a6c1 Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-amethyst-sdOWe
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
2026-05-29 13:32:49 +00:00
Vitor Pamplona c1dd59a068 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
# Conflicts:
#	commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt
2026-05-29 08:57:31 -04:00
Vitor Pamplona 350aadc578 Pushes event hints for citations in Public Messages 2026-05-29 08:36:41 -04:00
Vitor PamplonaandGitHub 0eb2188ebd Merge pull request #3093 from vitorpamplona/claude/adoring-turing-THM7p
Add podcast support (NIP-F4) with favorites, metadata, and episodes
2026-05-28 18:24:11 -04:00
Claude 12ed86627c feat(nutzap): fold nutzaps into reaction-row zap counter + icon highlight
Phase 0 (small fix): sendNutzap was async-launched with no success
callback, so after tapping the teal cashu chip in the zap picker
the popup vanished and the user saw no feedback for the 1-2 seconds
it took the swap + publish to complete. Add a "Cashu zap sent —
Sent N sat(s) via cashu" toast on success, matching the lightning
zap's progress feedback in spirit.

Phase 1 (foundation): NIP-61 nutzaps attach to their target note
the same way LN zaps and onchain zaps do, contributing to the
reaction-row total and the "you-already-zapped" icon highlight
without any UI-layer change.

Pieces:

- NutzapEvent.claimedSatsTotal() in quartz parses the sender-
  claimed sat sum from the proof tags once, leniently (a single
  malformed proof contributes 0 rather than throwing). The
  recipient wallet still verifies proofs against the mint at redeem
  time; this is the trusted-claim total for display.

- Note.nutzaps: Map<HexKey, NutzapEntry> on the canonical commons
  Note, parallel to onchainZaps. NutzapEntry carries the source
  kind:9321 note (sender = source.author) and the pre-parsed
  claimedSats. Volatile because writes happen on applicationIOScope
  and reads happen on the Compose main thread.

- updateZapTotal() now sums nutzap claimedSats into zapsAmount, so
  the existing ObserveZapAmountText composable in ReactionsRow
  picks up cashu without code change.

- hasZapped() and the suspend isZappedBy() extended to detect
  nutzaps from a given user. ReactionsRow's calculateIfNoteWasZap-
  pedByAccount path therefore highlights the bolt orange for cashu
  zaps the same way it does for lightning.

- LocalCache previously routed NutzapEvent through
  consumeRegularEvent, which would add it as a *reply* to the
  e-tagged note via computeReplyTo. computeReplyTo gains a
  NutzapEvent case returning the linked event ids, and a dedicated
  consume(NutzapEvent) function attaches via addNutzap instead of
  addReply.

The "list" merge across LN + cashu + onchain that the user floated
is deferred — three separate collections with different shapes
(zap pair, onchain entry, nutzap entry) are kept; only the
aggregates and queries are unified. That's enough for the
reaction-row UX and avoids touching every iteration site at the
call layer.

Coming next: notifications (NotificationFeedFilter + a cashu-icon
variant of ZapUserSetCard) and the dedicated cashu row in
ReactionDetailGallery modeled on OnchainZapGallery.
2026-05-28 22:20:25 +00:00
Claude ab0719fa68 fix(podcasts): wire podcast kinds into every consumer; close audit findings
Addresses 10 audit findings from the post-build review:

CRITICAL — non-functional without this:
- LocalCache.justConsume had no branches for kind 54 / 10054 / 10064 / 10154,
  so every podcast event fell through to "Event Not Supported" and was
  silently dropped. Added the four explicit branches (regular event for
  PodcastEpisode; replaceable for the other three).

HIGH — silent invisibility / broken tap-through:
- Home, profile (newthreads + mutual), hashtag, geohash, follow-pack, and
  notification feed filters didn't recognize PodcastEpisodeEvent /
  PodcastMetadataEvent. Episodes were invisible everywhere outside the
  dedicated tab; reactions/zaps on episodes were dropped from the
  notifications feed.
- ThreadFeedView's renderer dispatch had no podcast branch, so tapping a
  feed card opened a plain text-note view. Added explicit cases that call
  the new RenderPodcastEpisode / RenderPodcastMetadata composables.
- The hashtag / geohash / relay / search REQ kind lists didn't include
  podcast kinds, so discovery surfaces returned nothing for them.
- RelayInformationScreen kind→label map gained podcast entries +
  4 new string resources (Podcast Episode, Podcast Show, Authored
  Podcasts, Favorite Podcasts).
- HomeNewThreadFeedFilter.ADDRESSABLE_KINDS gained PodcastMetadataEvent so
  shows surface alongside music/wiki/long-form on the home feed.

HIGH — privacy leak in Quartz:
- FavoritePodcastsListEvent.add(isPrivate=true) was passing
  earlierVersion.tags through untouched, so toggling a previously-public
  favorite to private left the public p-tag intact. Made both branches
  symmetric: each removes the entry from the other half before adding to
  its own. Two regression tests cover the round-trip.

MEDIUM — data hygiene:
- AuthorTag.parse used to accept ANY non-empty slot-2 string as a role
  (rendering a stray relay-hint URL as "Role: wss://relay…"). Now
  validates against the spec-defined {host, cohost, editor} allowlist;
  unknown values resolve to role=null, preserving the pubkey association.

PERF:
- PodcastEpisode renderer was allocating a fresh 96-element WaveformData
  and rebuilding the cover Modifier chain per visible card. Hoisted both
  to top-level constants (FLAT_WAVEFORM, COVER_IMAGE_MODIFIER,
  PLAYER_BORDER_MODIFIER) so the whole feed shares one instance.

CODE QUALITY:
- Extracted PodcastCoverCard as a shared composable used by both renderers
  (was duplicated byte-identical across PodcastEpisode + PodcastMetadata).
- Extracted PodcastFeedLoaded so the Episodes screen and Shows screen
  share one feed body (was duplicated byte-identical).
- Dropped the misleading `group = listOf(singleAssembler)` wrapper in the
  two FilterAssembler files.
- Replaced `mapNotNull { … }.flatten()` with `flatMap { … }` in the
  Communities sub-assembly (the lambda never returns null).

DOCUMENTED:
- PODCAST_KINDS "Following" resolution still goes through kind:3 follows,
  but per NIP-F4 podcasts use their own keypairs tracked via kind:10054.
  Added an inline comment naming the deferred work — proper fix needs
  Account-level 10054 integration which is a separate scope.
2026-05-28 22:14:49 +00:00
Vitor PamplonaandGitHub 1fdde747de Merge pull request #3092 from vitorpamplona/claude/adoring-galileo-ws6FG
Add NIP-78 AppDataEvent (kind 78) and refactor AppSpecificDataEvent
2026-05-28 18:08:33 -04:00
Claude 40ed26ea85 refactor(quartz): NIP-78 events use the build-template pattern
Convert both kind 30078 and the new kind 78 from the legacy
`suspend create(... signer)` shape to the now-standard
`build(...) -> EventTemplate` shape used across recent quartz events
(NIP-34, NIP-66, etc):

- Use `eventTemplate<T>(KIND, content, createdAt) { ... }` and lean
  on the shared `alt()` and `dTag()` TagArrayBuilder extensions
  instead of hand-rolling the `d`/`alt` injection.
- Callers now do `signer.sign(AppSpecificDataEvent.build(...))`.

For kind 78 the `d` tag is optional (it's a grouping key, not an
addressing key), so we keep it nullable and assemble it via
`DTag.assemble` — the typed `dTag()` extension is constrained to
addressable events, which is correct.

Update the lone caller (`AppSpecificState.saveNewAppSpecificData`).
2026-05-28 21:16:03 +00:00
Claude 7efe6fdf2a feat(nip71): support audio-track imeta variants per nostr-protocol/nips#2255
Adds the audio-track imeta properties from NIP-71 PR #2255 so video
events can advertise external audio tracks (multi-language, alternate
bitrates) alongside video variants:

- New imeta properties: bitrate, duration (float seconds), waveform,
  and l <code> <standard> [ov] for language with an original-version flag
- Extends VideoMeta with bitrate, duration, waveform, language fields
  plus isAudio/isVideo helpers
- VideoEvent exposes audioTracks()/videoTracks() so players can prefer
  separate audio tracks over in-video audio while switching resolution
- Round-trip test against the PR's spec example
2026-05-28 21:15:08 +00:00
Claude 2e7583adfd feat(quartz): NIP-78 — add kind 78 normal app data event
NIP-78 was updated (nostr-protocol/nips#2292) to define a second
event kind alongside the existing addressable kind 30078:

- Kind  78: normal event, for apps that need to store and query
  multiple events of the same type. Recommended to use unique tags
  (including `d` tags) for grouping related events; the `d` tag here
  is a grouping key only, not an addressing key.

Add `AppDataEvent` (kind 78) extending `Event`, mirroring the
ergonomics of `AppSpecificDataEvent` (kind 30078): optional `d` tag
hoisted into `tags`, NIP-31 `alt` tag injected when absent, and a
`signer.sign(...)` factory. Register it in `EventFactory` so
incoming kind-78 events deserialize into the typed class.

The existing kind-30078 implementation remains compliant with the
updated spec.
2026-05-28 21:05:58 +00:00
Claude b47cdf5b96 feat(quartz): add NIP-F4 podcast event support
Implements the four event kinds defined by NIP-F4 so Quartz can parse and
build native Nostr podcasts: kind:10154 show metadata, kind:10064 author
counter-claim, kind:54 episode, and kind:10054 favorite-podcasts list. All
four are registered in EventFactory so the existing JSON deserialization
pipeline returns typed instances. Tag classes mirror the per-event package
layout used by the experimental music module.
2026-05-28 20:10:39 +00:00
Vitor PamplonaandClaude Opus 4.7 ebf8f195e4 refactor(cashu): delete wrong-theory dodge scaffolding
Removes ~700 lines of code added across ~10 prior commits trying to dodge
the ART JIT crash from the wrong angle. With the real root cause fixed
upstream (uLtInline inline-expansion), none of this is needed.

Deleted:
- BdhkeScratchpad.kt + 3 platform actuals (apple/jvmAndroid/linux). The
  thread-local Fe4/MutablePoint pool was added under the belief that
  per-call allocation density was triggering an ART escape-analysis
  bug. It wasn't. The original Bdhke functions allocated ~5-10 small
  objects per call — well under any TLAB pressure threshold.
- Bdhke.warmup() and the 2048-cycle blind+unblind loop it ran. The
  warmup was justified by "force the JIT compile to happen during init
  where a crash isn't user-facing" — except the warmup itself was what
  triggered the crash. ~4 seconds of wasted startup CPU.
- MintApiSerializerWarmup.kt (kotlinx.serialization decoder warmup).
  Same wrong theory, same wasted startup work.
- The `scope.launch(Dispatchers.Default) { Bdhke.warmup(); ... }` block
  in CashuWalletState.start() that called both warmups.

Reverted:
- Bdhke.kt to its pre-scratchpad shape. Drops `hashToCurveInto`,
  `parseAffinePointInto`, `computeNegRkInto`, `negateInto`,
  `toUncompressedOrNullScratch`, `compressedToUncompressedScratch`,
  `toCompressedScratch`, `@Volatile warmupDone`, `fun warmup()`, and
  the `JIT_WARMUP_ITERATIONS` constant. Restores the simple
  fresh-allocations-per-call form of `hashToCurve`, `blind`,
  `unblind`, `verifyDleq`, `addRTimesA`.

Stripped from CashuMintOperations.kt:
- Four `Log.i("CashuTrace") { ... }` diagnostic lines in the restore
  loop, added to chase the wrong hypothesis.
- The `import com.vitorpamplona.quartz.utils.Log` they were the only
  user of.
- Five "Bdhke uses a thread-local scratchpad internally" comments that
  referenced the now-deleted scratchpad.
- The "easier on the ART JIT" rationale in the per-counter dedup
  comment, replaced with the actual NUT-09 §2 echo-semantics
  explanation. The dedup itself is a real algorithmic win (~378 → ~6
  unblinds per batch), kept.

Cleaned in CashuPreferences.kt:
- "ART JIT crash on Android 15+" example in the durability rationale,
  replaced with generic "OOM, signer dialog dismiss, unexpected
  process death." The durability point stands regardless of crash
  source.

Net: -704 / +117 lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:15:18 -04:00
Vitor PamplonaandClaude Opus 4.7 6b9573906b revert(cashu): restore generated decoder for /v1/restore
The hand-rolled `parseRestoreResponse` was added under the (wrong) belief
that kotlinx.serialization's generated `RestoreResponseDto.serializer`
decode body was the ART JIT crash trigger. Real cause was `uLtInline`
inline-expansion downstream in `Bdhke.unblind` (see two commits back);
the decoder was never on the crash path.

The hand-roll is also actively worse than the generated path:
`runCatching { ... }.getOrNull() ?: empty` everywhere means a malformed
mint response that the generated decoder would have rejected with a
clean exception silently returns an empty `RestoreResponseDto`. The
restore driver reads that as "no matches in this batch → bump
empty-streak counter → terminate." Net effect: a misbehaving mint can
silently truncate your NUT-09 wallet restore. Fail-loud is the correct
posture for an untrusted endpoint.

Drops 6 kotlinx.serialization.json imports and the entire
`postWithManualResponseDecode` + `parseRestoreResponse` block (-110
lines). `restore` becomes a one-liner again, identical to every other
mint endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:14:49 -04:00
Vitor PamplonaandClaude Opus 4.7 7d5573532f revert(cashu): re-enable NUT-12 DLEQ on own-mint outputs
Restores the per-signature `Bdhke.verifyDleq` check in
`CashuMintOperations.unblindOne` that was removed earlier on this branch
under the (wrong) belief that `verifyDleq`'s allocation density was
triggering the ART JIT crash. The real bug was `uLtInline`
inline-expansion in U256/ScalarN/FieldP (see preceding commit); with
that fixed, `verifyDleq` runs at 2048 iterations/process in the
regression suite (`r_verifyDleq_2048`) with no JIT trouble.

The skip-DLEQ commit argued "a malicious mint can just refuse the
request" so the check buys "fail fast vs fail-at-next-spend, not actual
security." That undersells NUT-12: a key-substituting mint produces
*unspendable* proofs, and pre-emptive DLEQ catches that at mint-receive
time, before the user considers the operation done. Without the check,
the failure surfaces at the next swap — by which point a sender has
already considered the payment complete and any sent token is dead.

Third-party proof verification (incoming cashu tokens, nutzap redeems)
continues to go through `verifyDleqCarol` / `verifyTokenDleq` —
unchanged, still the harder untrust boundary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:14:33 -04:00
Vitor PamplonaandClaude Opus 4.7 1f3630c4ae fix(quartz): defeat ART JIT InstructionSimplifier crash by uninlining uLtInline
Root-cause fix for a deterministic SIGSEGV in the ART JIT compile thread
on Android 16, fault address 0x48, inside
`art::HBasicBlock::RemoveInstruction +32` →
`art::InstructionSimplifierVisitor::Run` → `OptimizingCompiler::JitCompile`.

`uLtInline` was `internal inline`, so its body
`(a xor MIN_VALUE) < (b xor MIN_VALUE)` was duplicated at every call site.
With 199 sites across U256, ScalarN, FieldP, FieldMul*, and 12 sequential
`if (uLtInline(...)) 1L else 0L` patterns inside `ScalarN.reduceWideTo`
alone, ART's `InstructionSimplifier::VisitAnd` / `VisitBooleanNot` tried
to fold them as a group and null-deref'd while removing HIR nodes.

Threshold for the crash on this Android 16 emulator is ~48 invocations
of any function whose call tree reaches `ECPoint.mul` (NUT-09 restore,
swap, ECDH, NIP-44 conversation-key derivation — basically every Cashu
op).

Drop `inline`. The function-call boundary at each site hides the
xor+lt+Select(0L,1L) chain behind an HInvokeStatic returning bool, so
the simplifier no longer sees the group-foldable shape.

Cost: ~80 ns per call (vs zero inlined). ~12 calls per `reduceWideTo`,
~4 `reduceWideTo`s per `splitScalarInto`, ~1 `splitScalarInto` per
`ECPoint.mul` — order of microseconds per unblind. NUT-09 restore of a
typical wallet adds ~1 ms vs the network round-trip. Negligible.

Also adds BdhkeJitCrashTest.kt, an instrumented regression suite that
exercises every Cashu-reachable cryptographic primitive at 2048 calls
each, with byte-for-byte cross-validation against fr.acinq.secp256k1
JNI where applicable (catches both crashes and silent miscompiles):

  - blind/unblind workload (a, b, e, f)
  - hashToCurve, blind, sign, secp256k1 pubkeyCreate isolations (g..j)
  - acinq pubKeyTweakMul control (l)
  - ECDH x-only vs acinq (n)
  - Schnorr sign byte-match vs acinq (o)
  - Schnorr verify (p)
  - privKeyTweakAdd byte-match vs acinq (q)
  - NUT-12 mint-side DLEQ accept+reject (r)
  - NUT-12 Carol-side DLEQ roundtrip (covers addRTimesA) (s)
  - NIP-44 v2 cipher encrypt-decrypt roundtrip (t)

All 16 tests pass on the Android 16 emulator that previously
deterministically reproduced the crash.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:14:01 -04:00
Claude b634a32ac6 fix(cashu): crank Bdhke warmup to 2048 iterations for ART tier-1
The last reproducer crashed AFTER the hand-rolled restore parser
moved past kotlinx.serialization: batches 1-3 unblinded fine, batch
4 SIGSEGV'd in the JIT thread between `deduped` and `batch unblinded`.
Now the JIT is choking on Bdhke.unblind itself at tier-1 (optimizing
compile threshold ~21 invocations on Android 15+).

Bump warmup from 32 → 2048 iterations of blind+unblind. At ~1 ms
per cycle on a mid-range Android 15 device that's ~4 s of background
warmup at app start (Dispatchers.Default coroutine, UI stays
responsive). Crossing tier-1 inside that window means the production
restore loop hits already-optimized code instead of triggering the
compiler mid-operation.

Also bump MintApiSerializerWarmup element count 32 → 128 for the
swap / mint / melt endpoints that still go through generated
deserializers (restore now uses the tree-API hand-roll).
2026-05-28 15:51:17 +00:00
Claude c8b7c4dddc fix(cashu): bypass kotlinx.serialization for /v1/restore response
The diagnostic logs confirmed the JIT crash hypothesis: three NUT-09
batches deserialize fine through the generated
RestoreResponseDto.serializer().deserialize(...), then the 4th batch
crosses ART's tier-1 (optimizing) compile threshold for the
generated decoder body, the optimizer crashes (SIGSEGV at offset
0x48 in Jit thread pool), and the process dies before the 4th
batch's "decoded" log can fire.

Trace shape on every reproducer:
  Bdhke.warmup begin / end             ← warmup OK
  MintApiSerializerWarmup begin / end  ← warmup OK
  restore POST batch 1, decoded, deduped, unblinded
  restore POST batch 2, decoded, deduped, unblinded
  restore POST batch 3, decoded, deduped, unblinded
  restore POST batch 4
  <SIGSEGV — never reaches "decoded">

Replace the generated decode path for /v1/restore only. The encode
side stays through the generated serializer (request payload is
small + cold). For the response: use Json.parseToJsonElement (the
JSON-tree API) and walk the tree by hand, extracting fields into the
existing DTOs. The tree API is one parser routine, completely
separate code from the per-class generated deserializers — much
smaller bytecode, no escape-analysis target shape, no JIT crash.

Defensive against missing fields (returns empty lists / null dleq)
so a misbehaving mint can't trip the hand-roll. Other endpoints
(swap, mint, melt, checkstate) keep their generated deserializers
since they don't go through the multi-batch tier-1 threshold.
2026-05-28 15:40:29 +00:00
Claude 9bef8d48c7 diag(cashu): targeted CashuTrace logs around restore HTTP boundary
JIT crash still recurs on Android 15+ even with ThreadLocal Bdhke
scratchpad + at-most-once warmups + serializer warmup. To isolate
whether the crash is:
  (a) the warmup never running,
  (b) kotlinx.serialization deserializing the restore response, or
  (c) downstream unblind work,
add four focused log points:

  - Bdhke.warmup begin / end
  - MintApiSerializerWarmup.warmup begin / end
  - restore: POST /v1/restore (req=N outputs)
  - restore: decoded sigs=N echoes=N      ← reached IFF deserialize OK
  - restore: deduped to N unique counter(s)
  - restore: batch unblinded

After the next crash, the last surviving log line says which phase
the JIT was compiling. If "decoded" never appears, (b) is confirmed
and we hand-roll the JSON parser for the restore endpoint.
2026-05-28 13:35:09 +00:00
davotoula 9d44c22401 fix(quartz): two more iOS compile errors in NIP-60 Cashu
Two issues surfaced once the @Volatile import fix (daa9bbff3) let the
iOS compiler proceed:

1. CashuDeterministic.bytesToLowercaseHex used `String(CharArray)`,
   which is `DeprecationLevel.ERROR` on Kotlin/Native. Swap for
   `CharArray.concatToString()` — identical semantics on all targets,
   and the only KMP-portable form.

2. MintExceptionTest lived in commonTest but referenced
   MintHttpException / MintProtocolException, which are defined in
   jvmAndroid/MintHttpClient.kt (HTTP-layer concerns, not portable).
   Move the test to jvmAndroidTest where its dependencies actually
   exist. No coverage change — these classes are JVM/Android-only.

Verified locally with `./gradlew :quartz:iosSimulatorArm64Test
:quartz:compileTestKotlinIosArm64`.
2026-05-28 13:06:47 +02:00
davotoulaandClaude Opus 4.7 daa9bbff3a fix(quartz): KMP-safe @Volatile import in Cashu warmup flags
`@Volatile` in commonMain resolves to `kotlin.jvm.Volatile` by default,
which doesn't exist on iOS targets. Two new NIP-60 Cashu files use
`@Volatile` without an explicit import, breaking
`:quartz:compileKotlinIosSimulatorArm64`:

  Bdhke.kt:577:6 Unresolved reference 'Volatile'.
  MintApiSerializerWarmup.kt:74:6 Unresolved reference 'Volatile'.

Add `import kotlin.concurrent.Volatile` to both files. Same pattern as
6f1292bfc (Note.kt) — semantics unchanged on JVM/Android, now also
resolves on iOS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:50:15 +02:00
Claude 63e9970340 refactor(cashu): Bdhke scratchpad via ThreadLocal — drop param API
Bdhke's allocation-free hot path used to expose `BdhkeScratchpad` as
an explicit parameter on every public function (`blind(secret, r, scratch)`,
`unblind(..., scratch)`, `verifyDleq(..., scratch)`, …). Every caller
in CashuMintOperations had to remember to allocate a scratchpad per
loop and thread it through.

Switch to a thread-local pool. New expect/actual:
  internal expect fun bdhkeScratchpad(): BdhkeScratchpad
    - jvmAndroid: ThreadLocal.withInitial { BdhkeScratchpad() }
    - apple / linux: fresh allocation per call (no Cashu in prod)

Each public Bdhke function pulls the scratchpad internally with one
`val scratch = bdhkeScratchpad()` at the top. Every thread that ever
touches Bdhke gets one scratchpad allocated lazily on first use and
reuses it across every subsequent call on that thread — same JIT-bug
mitigation, much cleaner API.

Removes:
- 0-arg and N+1-arg overloads on blind / unblind / verifyDleq /
  verifyDleqCarol / hashToCurveCompressed
- `scratch` parameter on private addRTimesA / unblindOne /
  unblindAll
- All `val scratch = BdhkeScratchpad()` boilerplate in
  CashuMintOperations.restore / meltToLightning / verifyTokenDleq /
  checkStates / secretOutputsFor

Also strips the diagnostic Log.i("CashuTrace") / Log.i("BdhkeTrace")
lines added during the JIT-bug investigation — the at-most-once
warmup + ThreadLocal pooling should resolve the crash, and the
traces were polluting logcat at info level.

Nested calls (verifyDleqCarol → blind + addRTimesA + verifyDleq)
all grab the same thread-local scratchpad; the holder field sets
are disjoint by design so nested use is safe.

BdhkeTest still 17/17 green.
2026-05-28 02:43:48 +00:00
Claude 6b3bcdfa53 fix(cashu): warmup must be at-most-once per process
The previous warmup change introduced a startup crash: each account's
CashuWalletState.start() spawned a coroutine on Dispatchers.Default
that ran Bdhke.warmup() (32 blind+unblind cycles) AND
MintApiSerializerWarmup.warmup() (decode 32-element synthetic
RestoreResponseDto). With two accounts, that's 128 BDHKE calls + 64
deserializations all in flight at once — far worse JIT pressure than
the original problem, since the warmup explicitly tries to make
methods hot.

The trace showed it clearly — multiple unblind/blind logs from
different threads interleaving mid-call ("unblind: parseAffinePointInto k"
appearing without a preceding "unblind: parseAffinePointInto cTick"
from the same logical call). ART's optimizer then crashed on the
flood.

Add a @Volatile flag to both warmups. First caller does the work;
every subsequent caller returns immediately. Plain volatile (not
atomic CAS or synchronized) because:
  - The race window is tiny (microseconds between read and write)
  - Two extra 32-cycle warmups in the worst case isn't a correctness
    or performance issue
  - Stays commonMain-portable without expect/actual or atomicfu
2026-05-28 02:13:16 +00:00