Commit Graph
2297 Commits
Author SHA1 Message Date
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
Claude 84dbf90076 fix(cashu): dedup restore unblind loop, pre-warm Bdhke + serializers
The trace from the last crash showed the restore unblind loop running
378 iterations per batch — minibits returns one signature per
(amount, bTick) we probed (63 denominations per counter × 6 unique
counters). Most iterations hit the recoveredCounters.add(...) continue
path. That's 60× more loop pressure than needed AND it's exactly the
kind of wide hot loop the ART JIT keeps trying to compile.

Changes:

1. Dedup BEFORE the unblind loop. Walk response.signatures once,
   keep one (counter -> first signature) entry per unique counter
   into a LinkedHashMap, then iterate that. Restore's per-batch
   inner loop drops from 378 to ~6 iterations and stops being a
   JIT compile target.

2. Pre-warm hot paths during CashuWalletState.start() on a background
   coroutine, so the synchronous JIT compile pause happens at app
   init (low pressure, no user waiting) instead of mid-restore where
   we observed a 13ms gap on the first Bdhke.blind of a new batch
   followed by a crash:

   - Bdhke.warmup() runs 32 blind+unblind cycles with synthetic
     keypair so the secp256k1 / hashToCurve / addPoints / toCompressed
     hot path tier-1 compiles once.

   - MintApiSerializerWarmup.warmup() decodes a synthetic 32-element
     RestoreResponseDto (with nested DleqProofDtos) plus a SwapResponseDto.
     kotlinx.serialization's generated decoder for BlindSignatureDto
     becomes JIT-compiled during init — that decoder is otherwise
     the heaviest allocation density we hit (~1.5k data-class
     instances + ~5k Strings per real restore response, ART optimizer
     bait on Android 15+).

If the JIT crash recurs, the next move is hand-rolling the JSON
parser for RestoreResponseDto / SwapResponseDto to bypass
kotlinx.serialization on the hot path entirely.
2026-05-28 02:00:59 +00:00
Claude 56e5aea79f fix(cashu): scratchpad toCompressed + deep Bdhke tracing
The previous round of scratchpadding still left two Fe4 allocations
per call inside toCompressed — the final point→bytes step at the
tail of every blind / unblind / addRTimesA. Across an NUT-09 restore
sweep that's ~250+ Fe4 allocations the ART JIT optimizer still gets
to chew on. After ART inlines toCompressed back into the outer
crypto bodies, those allocations end up in the same hot method
bodies the scratchpad was supposed to clear out.

Adds a [toCompressedScratch] variant that reuses two new holders on
[BdhkeScratchpad], and routes every hot-path caller through it
(unblind, blind, addRTimesA, hashToCurveCompressed). The plain
[toCompressed] stays for the cold paths (sign, signFull, others
only used by tests).

Also adds verbose BdhkeTrace / CashuTrace markers in:
  - Bdhke.blind: secKeyVerify → hashToCurveInto → mulG → addPoints → toCompressedScratch
  - Bdhke.unblind: parseAffinePoint cTick → parseAffinePoint k → computeNegRk → addPoints → toCompressedScratch
  - restore inner loop: per-counter (CashuDeterministic → Bdhke.blind → build dtos), HTTP, per-signature unblind begin/end

So when the next JIT crash happens (if it does), the last log line
points at the exact sub-step ART was compiling. Logs are at INFO
level; cheap enough to leave on while diagnosing, easy to strip
afterward.
2026-05-28 01:23:48 +00:00
Claude 509cbcb297 fix(cashu): scratchpad verifyDleq / Carol / addRTimesA / hashToCurveCompressed
Final pass on the Bdhke JIT-crash mitigation. Three more Bdhke
functions on the production hot path still allocated per-call holders
and could trigger the Android 15+ ART JIT escape-analysis crash:

  verifyDleq        ~17 short-lived Fe4 / MutablePoint per call
  addRTimesA         ~6 short-lived holders per call
  hashToCurveCompressed  ~3 per iter (plus ~3 inside hashToCurve)

verifyDleqCarol orchestrates blind + addRTimesA + verifyDleq, so the
Carol path (used by every inbound nutzap / cashu-token redeem) was
allocating ~40 short-lived holders per proof. Same shape that crashes
the JIT in swap output handling.

Adds scratchpadded overloads:
  verifyDleq(..., scratch)
  verifyDleqCarol(..., scratch)
  hashToCurveCompressed(x, scratch)
  addRTimesA's scratchpad variant is the only signature (it's private)

Plus three helper -Into variants for the negate / toUncompressedOrNull /
compressedToUncompressed steps verifyDleq inlines.

Wired:
  verifyTokenDleq → one scratch per call, reused across every per-proof
    Carol verification
  checkStates → one scratch per call, reused across the per-proof
    hashToCurveCompressed pre-image computation

Tests: 17/17 BdhkeTest still pass.

After this commit, every Bdhke function on the production hot path
runs allocation-free. Functions only used by tests / mint emulation
(sign, signFull, verify) are intentionally not refactored — they're
never invoked at runtime in the app. The remaining 2-allocation
helpers (toCompressed, toCompressedOrNull) are well below the JIT
crash threshold.
2026-05-28 01:02:30 +00:00
Claude b20bb4e1d0 fix(cashu): scratchpad Bdhke.blind + hashToCurve — covers restore path
Latest crash log showed no CashuTrace markers between the mint HTTP
response and the SIGSEGV — the tracing was only around swapToLocked
and unblindAll. The crash is in the NUT-09 restore loop, which calls
Bdhke.blind hundreds of times per batch (one per counter slot) and
each blind call still allocated ~5 short-lived Fe4 / MutablePoint
holders inside hashToCurve + the blind step itself.

Same JIT-bug shape as unblind had. Same fix: pre-allocate the holders
inside BdhkeScratchpad, pass it to blind / hashToCurveInto so the
methods write through caller-owned holders instead of allocating
fresh ones. Adds 6 fields to the scratchpad (kept distinct from the
unblind ones so future nesting can't silently overwrite live state).

Wired:
- CashuMintOperations.restore: one batchScratch per HTTP batch,
  reused across all per-counter Bdhke.blind calls in that batch.
- secretOutputsFor: one scratch per call, reused across the
  mapIndexed loop.
- Pre-existing scratchpads in unblindAll, melt-change loop, NUT-09
  restore unblind loop unchanged.

Added a `restore: batch counter=N size=M denoms=K` trace line so
the next failure (if any) points at restore unambiguously.

Tests: 17/17 in BdhkeTest still pass (spec vector + round-trips).
2026-05-28 00:41:57 +00:00
Claude 35c260f572 fix(cashu): allocation-free Bdhke.unblind via BdhkeScratchpad
The earlier "split into smaller methods" mitigation didn't hold —
ART's JIT inliner re-merged them at compile time and the same
SIGSEGV at 0x48 in "Jit thread pool" still hit on Android 15+. The
crash was visible in NUT-09 restore (hundreds of unblinds in a
loop), in zap-send, and in send-token.

Root cause is ART 15+'s escape-analysis scalarization pass choking
on the allocation density inside Bdhke.unblind — about 10 short-
lived Fe4 / MutablePoint holders per call. Splitting doesn't help
because once the JIT inlines, the holders are back in one body.

This commit eliminates the allocations entirely. Bdhke.unblind
gains an overload that takes a [BdhkeScratchpad] holding pre-
allocated holders; the internal helpers (parseAffinePointInto,
computeNegRkInto) write through them instead of constructing new
ones. The JIT sees objects coming from outside the method, marks
them as escaping, and the buggy scalarization pass never runs.

unblindAll, the melt-change loop, and the NUT-09 restore loop each
allocate ONE scratchpad and reuse it across every iteration —
1 allocation per loop instead of ~10 per output. Big perf win for
restore on top of dodging the crash.

The scratchpad-less unblind(blindSignature, r, mintPubKey) overload
still exists for tests and one-off callers; it just delegates to
the scratchpad variant with a fresh allocation.

If this still crashes, the trace logging now points at the
swap-level frames (swapToLocked / fetchKeyset / POST swap / swap
response / unblindAll begin / end). If it survives, the JIT had
nothing left in the hot path to choke on and the underlying ART
bug is no longer reachable from our code.
2026-05-28 00:24:27 +00:00
Claude 8d997a9973 fix(cashu): split Bdhke.unblind into smaller helpers — dodge ART JIT bug
CashuTrace showed the Android 15+ JIT compiler crash (SIGSEGV 0x48
in "Jit thread pool") firing while ART was compiling Bdhke.unblind:

  unblindOne[0/2] begin amount=2
    unblindOne: Bdhke.unblind begin
  >>> Fatal signal 11 (SIGSEGV) <<<  in Jit thread pool
    unblindOne: Bdhke.unblind end       ← app thread keeps running
  unblindOne[0/2] end
  unblindOne[1/2] begin amount=8
    unblindOne: Bdhke.unblind begin
    unblindOne: Bdhke.unblind end
  unblindOne[1/2] end
  […300ms of other work, then process death tombstone…]

The unblind itself runs fine to completion (twice). The crash is the
JIT compiler thread choking on Bdhke.unblind's bytecode — specifically
the escape-analysis pass on the 10 short-lived Fe4 / MutablePoint
allocations packed into one ~30-line method body. ART decides the
process is unstable a few hundred ms later and tears it down.

Split the body into three: an orchestrator + parseAffinePoint() +
computeNegRk(). Same semantics, three smaller bytecode bodies for
the JIT to compile, each well under the threshold that triggers the
bad optimizer path.

If this still crashes, the trace will tell us which of the three
methods the JIT was compiling and we move to the next mitigation
(reusable per-thread buffer pools to drop allocations further).
2026-05-27 23:45:22 +00:00
Claude 5d64d3829e fix(rich-text): render cashu preview in the no-preview path too
CashuPreview parses tokens locally via CachedCashuParser — no network,
no link unfurl, just CBOR decode of the cashuB string and a small
card. The canPreview = false gate is there to suppress network
previews (images, link unfurls, lightning invoice lookups); cashu
tokens have no such cost.

Suppressing it was the reason a cashuB pasted into a DM rendered as
a wall of base64 even though the same token in the Redeem button
worked fine. Flip the no-preview branch to render the same CashuPreview.

Also add CashuTrace logging around the entire zap path (sendNutzap,
swapToLocked, unblindAll, unblindOne, secretOutputsFor, Bdhke.unblind,
signer.sign + publish for nutzap/keep/delete/history events). The
JIT crash on Android 15+ keeps surfacing in different code paths
even after the DLEQ-on-our-outputs skip; the trace lets us see the
last frame before the SIGSEGV so we can target the right hot spot.
2026-05-27 23:25:20 +00:00
Claude 23ea046d06 fix(cashu): skip NUT-12 DLEQ on our own mint outputs — ART JIT crash
Android 15 ART crashes (SIGSEGV at 0x48 in "Jit thread pool") when
the post-swap unblind loop runs Bdhke.verifyDleq once per signed
denomination in tight sequence. The pure-Kotlin elliptic curve
math allocates many short-lived MutablePoint / Fe4 holder objects;
ART 15+'s JIT compiler hits a bug compiling that pattern under load
and takes the whole process down right after a swap (or mint, or
swap-to-locked) returns. User reported it on auto-redeem first; now
hits send-token too, exactly 3 ms after the swap response — the
unblind loop barely started before the JIT crashed.

unblindOne / unblindAll only ever processes outputs WE asked the
mint to sign. A malicious mint can just refuse the request or
silently substitute a key — both are caught at next-spend when the
mint rejects our proofs, so the pre-emptive DLEQ check buys "fail
fast" vs. "fail at next op", not actual security. CDK and nutshell
wallets skip this check for the same reason.

Drop the DLEQ block from unblindOne. Third-party proof verification
(incoming cashu tokens, nutzap redeems) goes through the separate
verifyTokenDleq / verifyDleqCarol path and is unchanged — that's
where the untrust boundary actually is.
2026-05-27 22:44:54 +00:00
Claude 2a97511bd3 fix(cashu): NUT-09 restore — match echoed outputs by bTick only
Mint failure "Signature amount 8 != output amount 4" hit on the
resume-pending-invoice flow because some mints (observed on
minibits) match their internal restore table by the blind point
alone — when the wallet probes (B_=X, amount=4) and (B_=X, amount=8),
those mints return ONE entry with echo.amount=4 (the first matching
output we sent) and sig.amount=8 (the actually-signed denomination).
The strict amount-equality check in unblindOne then trips.

The cryptographic source of truth is the signature, not the echo:
the mint can only sign one denomination per blind point because the
keys are per-denomination. Switch the restore loop to look up
counter materials by bTick alone, then reconstruct the BlindOutput
with the signature's amount before unblinding.

Side benefits: the per-counter map shrinks from N_denoms entries
to 1 entry, and a HashSet<Long> dedups counters in case a tolerant
mint echoes the same blind point under multiple amounts.
2026-05-27 21:56:44 +00:00
Claude a1cbaea644 fix(cashu): cap NUT-09 restore request size to mint validation limit
The "Issuing proofs" dialog hung at 80% for 30 seconds and the mint
returned "List should have at most 1000 items after validation, not
6300" on the resume-pending-invoice flow. Two compounding causes:

1. CashuMintOperations.restore packs `batchSize * denominations.size`
   blinded outputs into each /v1/restore body. The default
   batchSize=100 against a typical keyset with the full power-of-2
   denomination set (~63 amounts) hits 6300 outputs per request —
   nutshell / CDK / minibits all enforce a 1000-item Pydantic cap
   and reject the body.

   Auto-clamp the effective batch size so total outputs stay under
   MAX_RESTORE_REQUEST_ITEMS (500, leaving headroom for the
   response doubling — the mint echoes outputs alongside signatures).
   Honours the caller's batchSize when it already fits.

2. recoverPreviouslyIssuedProofs (the "outputs already signed"
   fallback) was scanning every keyset denomination at every counter
   in the rewind window. But the prior /v1/mint/bolt11 reserved only
   `splitAmountIntoDenominations(amountSats).size` counters and the
   mint signed exactly one output per slot — so the relevant denoms
   are a handful, not 63.

   Pass `amounts = splitAmountIntoDenominations(amountSats)` plus a
   single-batch sweep (`batchSize = rewind`, `emptyBatchesToStop = 1`)
   so the recovery makes one HTTP call against ~3-6 denominations
   instead of 32 batches of 63.
2026-05-27 21:37:43 +00:00
Claude d28458553a Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-amethyst-sdOWe 2026-05-27 20:02:35 +00:00
Claude bf02a235e0 Merge remote-tracking branch 'origin/main' into claude/confident-allen-AOGU6
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
2026-05-27 19:55:07 +00:00
m cfb3f1b9fa feat(namecoin): TOFU pin for Namecoin Core RPC TLS path
When the user picks the Namecoin Core RPC backend and points at a
self-hosted node behind a self-signed cert (StartOS / Start9, umbrel,
LAN reverse proxy, …) the previous flow only worked if the cert's CA
was already in the device trust store. There was no in-app way to
inspect or pin the certificate, so users had to install the StartOS
root CA at the OS level — or settle for an unencrypted onion path.

This change brings the Namecoin Core RPC path up to parity with the
existing ElectrumX path:

  - NamecoinCoreRpcClient.probe() now opens a short-lived, no-auth TLS
    socket alongside the JSON-RPC call to capture the server's leaf
    certificate (PEM + SHA-256 fingerprint). Capture is best-effort
    and only runs for https:// URLs. Credentials are never sent over
    the inspection socket.

  - RpcProbeResult exposes serverCertPem, certFingerprint, and
    tlsHandshakeFailed so the Settings UI can react. New fields are
    nullable / default false so existing callers compile unchanged.

  - NamecoinCoreRpcClient maintains its own dynamic-cert keystore and
    a lazy pinned SSLSocketFactory (same shape as ElectrumXClient's,
    minus the hardcoded list — Core RPC has no public defaults). When
    cfg.usePinnedTrustStore is true and the URL is https, callRpc()
    routes through the pinned factory with a permissive hostname
    verifier (LAN/onion certs commonly carry IP-only SANs).

  - NamecoinSettingsSection's Namecoin Core RPC card now shows a
    'Trust Server Certificate?' AlertDialog after Test RPC when the
    probe captured a cert and the user hasn't pinned yet, reusing the
    existing namecoin_pin_cert_* strings. Accept persists the PEM
    AND flips usePinnedTrustStore=true on the config. The result
    card also displays the captured fingerprint and a '(pinned)'
    marker so the user can see the current trust state at a glance.

  - The pinned PEM list is stored in the existing
    KEY_PINNED_CERTS DataStore entry, so a single TOFU confirmation
    covers both backends. AppModules' namecoinCoreRpcClient init now
    bootstraps the pinned list on app start, matching ElectrumX.

  - Tests cover the new probe fields' defaults and the addPinnedCert
    / setDynamicCerts surface.

Local verification: builds clean (assembleFdroidDebug), :quartz:jvmTest
NamecoinCoreRpcClientTest all green, :amethyst:testFdroidDebugUnitTest
namecoin suites all green.
2026-05-28 03:32:28 +10:00
Claude e876a162a4 fix(music): review feedback round
- isPublic() now always returns !isPrivate() so a playlist tagged with
  both public=true and private=true is consistently reported as private,
  matching the 'isPrivate wins' contract documented on isPrivate().
- AddToMusicPlaylistViewModel + NewMusicPlaylistFab: hop to
  Dispatchers.Main.immediate for every Compose State write. The wrapping
  coroutines (rescan loop, launchSigner) run on Dispatchers.IO; Snapshot
  tolerates off-main writes but the codebase convention is main-only.
- MusicTracksSubAssembler + MusicPlaylistsSubAssembler: the single REQ
  asks both kinds 36787+34139, so the since cursor must be the min of
  both feeds' lastNoteCreatedAt to avoid over-fetching the lagging kind.
  Both assemblers now also listen to the other feed's cursor flow.
- Extract formatTrackDuration into MusicFormatting.kt; MusicTrack and
  MusicPlaylist share it.
- syntheticWaveformFor: replace inline FQN
  com.vitorpamplona.amethyst.service.playback.composable.WaveformData
  with an import.
- NewMusicPlaylistFab: drop the second .trim() — the dialog's confirm
  button already trims before invoking onCreate.
2026-05-27 17:27:44 +00:00
Claude 052592b91b fix(cashu): NUT-12 hash input — uncompressed bytes, hex-encoded, UTF-8
Real minibits / nutshell / CDK-backed mints were rejecting every mint
with "DLEQ verification failed for amount X — mint signature does not
match its published keyset key". The hash input format was wrong on
two axes:

1. Used 33-byte COMPRESSED points; spec uses 65-byte UNCOMPRESSED
   (`04 || X || Y`). CDK's `hash_e` in crates/cashu/src/dhke.rs is
   the authoritative reference — it calls `.to_uncompressed_bytes()`
   then `hex::encode`.

2. Hashed RAW BYTES; spec hashes the UTF-8 of the hex-encoded form.
   So the SHA-256 input is 520 ASCII chars (4 points × 130 hex chars),
   not 132 raw bytes.

The earlier round-trip test for `signFull → verifyDleq` hid this
because BOTH halves agreed on the wrong format. The fix re-roots
against the verbatim NUT-12 spec vector from
cashubtc/nuts/tests/12-tests.md, which catches any future drift
without needing a live mint.

Carol path (NUT-12 §3) also fixed in the same commit. The previous
`verifyDleqCarol` passed the UNBLINDED `C` to `verifyDleq` as if it
were `C'` — DLEQ math is over the blinded form, so it always
returned false. Per spec, Carol reconstructs both:

  B' = hashToCurve(secret) + r·G       (what Alice sent)
  C' = C + r·A                         (what the mint returned)

`Bdhke.addRTimesA(C, r, A)` is the inverse of [unblind]'s
`C' - r·A` step. Renamed the `blindSignature` parameter to
`unblindedC` to make the contract clear at call sites.

What still works after the fix:
- Round-trip self-consistency (signFull ↔ verifyDleq) — same algo on
  both sides, still verifies.
- All existing tampered-input rejection cases.
- The new dleqProofTestVector pins against the spec's published
  proof values; reproducible offline.
- The Carol verification call site in CashuMintOperations.verifyTokenDleq
  was updated to the new parameter name.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:43:38 +00:00
Claude 5e25968059 feat(cashu): NUT-12 Carol verification + mint-info caching + keyset migration
Three follow-ups from the CDK comparison audit:

(#1) NUT-12 §3 Carol verification on incoming proofs.

Previously the wallet only verified DLEQ on proofs the mint signed
directly (Alice-side: mint → our wallet). Proofs arriving from other
wallets — incoming nutzaps, imported cashuB tokens — were trusted at
face value and the validity check was deferred until spend time. A
malicious sender could hand us junk proofs and we'd only find out
when the swap failed, by which point the "payment" was already
considered done from the sender's side.

What's here:

- `CashuProof.dleq: DleqProofDto?` — proofs now retain the
  `(e, s, r)` tuple after Alice-side unblinding. `r` is the wallet's
  blinding factor (only the minter knows it), included in the
  outbound proof so the next recipient can reconstruct `B'` and
  verify against the mint's keyset key offline.
- `Bdhke.verifyDleqCarol(secret, r, e, s, C, A)` — Carol-side check.
  Reconstructs `B' = hashToCurve(secret) + r·G` from the proof's
  secret and the carried blinding factor, then delegates to the
  existing `verifyDleq`. No round-trip to the mint required.
- `CashuMintOperations.verifyTokenDleq(proofs)` — batch helper that
  fetches the mint's full keyset list, looks up each proof's amount
  key, and verifies the DLEQ when the proof carries one. Best-effort:
  proofs without dleq (legacy sender, mint stripped) skip cleanly so
  we still accept their owner's intent; only an actual mismatch
  fails.
- Wired into `CashuWalletOps.redeemNutzap` — every inbound nutzap's
  proofs are now Carol-verified BEFORE we hand them to the mint for
  swap. Mismatch throws `MintProtocolException`, refusing to redeem
  rather than racing the bad proofs into our wallet event.
- `NutzapProofJson` extended with an optional `dleq` field so kind:9321
  nutzaps round-trip the tuple between wallets that support it. Older
  senders that omit it remain compatible (no Carol check, fall back to
  spend-time validation).

(#2) `/v1/info` caching with 30-minute TTL.

Every Verify-mint tap, every future NUT-17 feature-detection lookup,
and every UI surface that shows mint metadata used to fire a fresh
GET. Mint info changes on the order of weeks; 30 minutes is a
compromise between staleness and not blocking a fresh user-visible
upgrade for an hour. Force-refresh path (force=true) for callers that
need certainty after a user-initiated retry. Volatile pair so the
read on the hot path doesn't need a lock — a torn read just causes
one extra HTTP call, never a wrong answer.

(#4) Proactive keyset migration on wallet load.

When the mint rotates its active keyset, proofs minted under the old
one remain spendable — until the mint actually retires that keyset's
private key, after which our held proofs become un-spendable. Without
proactive migration, the first warning is a melt that mysteriously
fails. We now sweep on every wallet start: group held tokens by mint,
fetch the current active id, identify entries with any stale-keyset
proof, and consolidate them via a single swap → fresh kind:7375 →
NIP-09 the source events. Best-effort per mint: a network failure on
one mint skips that mint and the sweep continues for the others.

Three new top-level surfaces:
- `CashuMintOperations.verifyTokenDleq(proofs)` — Carol verification
- `CashuWalletOps.fetchActiveKeysetId(mintUrl)`
- `CashuWalletOps.migrateToActiveKeyset(mintUrl, entries, activeId)`
- `CashuWalletState.migrateStaleKeysets()` — driver, called in start()

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:45 +00:00
Claude 805e010c57 fix(cashu): audit polish — DLEQ scalar check, batch counters, seed mutex, log throwables
Four small fixes from the post-NUT audit; none user-visible on the
happy path, but #1 is a real correctness issue that would have
rejected vanishingly-rare-but-valid mint signatures.

1. Bdhke.verifyDleq: stop rejecting valid `e >= N`.

   NUT-12 treats the challenge `e` as bytes, not as a scalar mod n —
   there's no requirement that `e < n`. The previous `ScalarN.isValid(e)`
   check would have thrown MintProtocolException with probability ~2^-128
   per proof (when sha256 happens to output >= curve order). The
   downstream point multiplication `ECPoint.mul(out, A, eScalar)` handles
   `e >= n` correctly via GLV reduction internally, so the rejection
   was the only thing standing between us and a valid proof. Now we
   only reject `e == 0` (which would degenerate verification — `sG -
   0·A == sG`, independent of the mint's keyset key — so any junk
   signature would pass). Tests still green; vector-style proofs hit
   `e < n` 99.99...% of the time, so this isn't observable in the
   suite.

2. CashuWalletViewModel Log.w: pass the throwable.

   Four call sites (recommendMint / deleteRecommendation /
   restoreFromMint / cancelMintQuote) interpolated the exception
   message into the lambda but dropped the stack trace. Switched to
   the (tag, message, throwable) overload so support reports keep
   the trace. Same antipattern that find-non-lambda-logs flags.

3. SecretFactory: batch counter reservation.

   The factory was called once per amount denomination inside
   secretOutputFor. A 100-sat mint that splits into 7 powers of 2
   meant 7 @Synchronized critical sections on
   AccountSettings.reserveCashuCounters + 7 saveable.update calls.
   Disk was already debounced (1s), but the lock-acquisition pattern
   was wasteful and serialised any concurrent mints longer than needed.

   New contract: `nextSecrets(keysetId, count): List<DerivedSecret>`.
   The deterministic impl reserves the whole counter range in ONE
   atomic reservation; the random impl just allocates N random pairs.
   CashuMintOperations gained a `secretOutputsFor(amounts, keyset)`
   helper that does one batch call; all four call sites (swap, swap
   keep-change, swapToLocked keep-change, meltProofs change-outputs)
   migrated. `nextSecret(keysetId)` stays as a default-method
   single-output convenience.

4. CashuWalletState.ensureSeed: serialise via Mutex.

   The previous @Volatile-only double-check could race two coroutines
   into both calling `walletPrivkeyHex()` (signer round-trip). For local
   signers that's a μs of wasted HMAC; for NIP-46 bunker signers it's
   a network decrypt the user pays for twice. Wrapping with
   `Mutex.withLock` after the unlocked fast-path gives proper
   single-flight semantics without slowing the warm-cache path.

5. NUT-09 restore loop: micro-perf cleanup (rides along).

   Pre-sized HashMap + ArrayList to `batchSize × denominations.size`
   (was resizing ~10x per 1000-output batch). Hoisted
   `bTick.toHexKey()` once per counter — the old code called it twice
   per output (once for the map key, once for output.toDto()). Inner
   loop now builds the BlindedMessageDto directly with the hoisted
   hex instead of going through toDto's accessor.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:44 +00:00
Claude 29de889a1a feat(cashu): NUT-09 restore — recover proofs from seed end-to-end
This commit closes the loop on the NUT-13/09 combo started in earlier
commits. With NUT-13 wired, every secret + blinding factor is now
seed-derivable; this commit adds the driver loop that asks the mint
"which of these did you sign?", filters out spent ones, and republishes
the survivors as kind:7375 events. The wallet now genuinely survives
losing all its kind:7375 token events — losing your nostr-relay-stored
proofs is no longer permanent funds loss.

What's here:

- CashuMintOperations.restore(seed, keysetId, startCounter, batchSize,
  emptyBatchesToStop, amounts?): scans counters in batches, calls
  /v1/restore for each batch, unblinds returned signatures, repeats
  until N consecutive empty batches signal "no more proofs". Gap-limit
  heuristic matches BIP-32 wallet recovery — bounds the scan to
  "actually-used + buffer" rather than a fixed upper bound.

- CashuMintOperations.checkStates(proofs): NUT-07 batched proof-state
  query, returning a Map<secret, ProofState>. Required because
  /v1/restore returns even SPENT proofs (the mint signed them at the
  time); without filtering we'd treat already-spent secrets as
  recoverable balance.

- CashuMintOperations.activeKeyset() — public surface for the restore
  driver. Was previously private.

- New ProofState enum (UNSPENT / SPENT / PENDING / UNKNOWN) with
  fromWire() fallback for forwards compatibility with mints that
  introduce new state strings.

- CashuWalletOps.restoreFromMint(mintUrl, seed, startCounter): drives
  the scan, filters by /v1/checkstate, publishes a single kind:7375
  for surviving proofs + a kind:7376 IN history row. Returns
  RestoreOutcome with the totals + the highest-counter-seen so the
  caller can bump persisted counter state.

- CashuWalletState.restoreFromMint(mintUrl): materialises the seed
  via ensureSeed(), delegates to ops, then advances
  cashuKeysetCounters past the highest recovered slot so subsequent
  mints don't reuse a counter we've just confirmed in use. Returns
  null when no seed is available yet (caller retries after wallet
  load).

- CashuWalletViewModel.restoreFromAllMints() iterates every mint in
  the wallet's mint list, aggregating recovered totals. Best-effort
  per mint — one mint failing doesn't abort the others. State
  exposed via restoreState: StateFlow<RestoreFlowState>.

- CashuWalletSettingsScreen "Recover from seed" row, sandwiched
  between "Edit wallet" and "My mint recommendations". Subtitle
  shows the running status, the result totals, or the error. Tap
  to kick the scan; idempotent.

Subtle bit: NUT-09 returns proofs regardless of state. Without the
/v1/checkstate filter we'd republish spent proofs and the wallet
would think its balance had recovered, only for the next swap/melt
to fail. The two endpoints together produce a clean "recoverable
balance" answer.

Restore is idempotent — re-running after a successful pass finds
the same proofs (the mint still signed them) but checkStates
classifies them SPENT because the previous round of redemption
consumed them. The result is "Recovered 0 sats", not a duplicate.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:44 +00:00
Claude f6b3c40b3f feat(cashu): wire NUT-20 signed mint quote into start/complete flow
Mints that support NUT-20 require the wallet to prove ownership of the
quote when redeeming it for proofs. Without this, anyone who observes
the quote id can race to /v1/mint/bolt11 and steal the freshly-minted
proofs. The earlier commit landed the protocol primitive
(MintQuoteSignature); this one threads it through the live flow.

What's here:

- CashuMintQuoteEvent gets a JSON content shape — `{"quote_id":
  "...", "p2pk_priv": "..."}` instead of the bare quote-id string.
  Backwards compatible: build() falls back to the plain-string shape
  when no signing key is supplied, and decrypt() tries JSON first then
  treats parse failure as a legacy plain string. Existing wallets keep
  reading their old kind:7374 events; new ones carry the NUT-20 key.

- New CashuMintQuoteEvent.decrypt() returns both the quote id and the
  optional signing privkey in one go. quoteId() / signingPrivkey()
  are thin accessors over decrypt() so call sites that don't care
  about the key stay unchanged.

- CashuMintOperations.requestMintQuote(amountSats, signingPubkey?)
  forwards the pubkey to the mint. Always-on is safe: mints that
  don't support NUT-20 ignore the extra field per spec.

- CashuMintOperations.mintProofs(quote, amountSats, signingPrivkey?)
  signs `sha256(quote || B_0 || B_1 || …)` with the privkey and
  attaches the signature when present. Null signingPrivkey skips
  NUT-20 entirely (legacy events that don't carry a key).

- CashuWalletOps.startMintFromLightning generates a fresh per-quote
  keypair, sends the pubkey to the mint, and stashes the privkey
  inside the encrypted kind:7374. Ephemeral keypair per quote keeps
  the privacy property — no observable correlation between quotes.

- CashuWalletOps.completeMintFromLightning reads back both fields
  from the kind:7374 in one decrypt() call and forwards them to
  mintProofs. Resume-from-relaunch works because the key lives with
  the quote event (which is restored on app start), not in memory.

Compatibility:
- New quote events carry the NUT-20 key; old ones don't and skip the
  signature. Either way the on-wire shape of /v1/mint/bolt11 is
  identical to NUT-04 when signature is null.
- Pre-NUT-20 mints ignore the unknown pubkey field — JSON tolerance
  per the spec.
- Persistence: the privkey is co-located with the quote in the same
  NIP-44-encrypted blob, so backup/restore semantics are unchanged.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:44 +00:00
Claude d7e447428c feat(cashu): wire NUT-13 deterministic secrets into the live mint flow
The earlier commit landed the math primitives + spec-vector tests. This
commit threads them into the actual blind-message construction so every
mint / swap / melt the wallet performs now uses NUT-13-derived secrets
instead of pure randomness. Side effect: kind:7375 loss is no longer
permanent funds loss — the wallet can re-derive past secrets from the
seed and recover via NUT-09 /v1/restore (driver loop still to come).

What's here:

- `SecretFactory` strategy in quartz mintApi/. Two impls:
    RandomSecretFactory — pure random; the new default for tests and
      legacy callers that don't carry a seed.
    DeterministicSecretFactory — NUT-13 derivation via a (seedProvider,
      reserveCounter) pair. Seed is lazy (the wallet decrypts kind:17375
      asynchronously); if the cache is empty, it falls back to random,
      preserving pre-NUT-13 behaviour during the warm-up window.
- `CashuMintOperations` constructor now takes a SecretFactory
  (default: random). `secretOutputFor` delegates to it. The on-wire
  shape is identical either way — the mint can't tell which scheme
  we're using.
- `CashuWalletOps` constructor takes a SecretFactory + a suspend
  seedWarmer callback. Every blinding op (mintProofs / meltToLightning
  / sendNutzap / redeemNutzap) calls seedWarmer() before constructing
  outputs so the seed cache is warm by the time the synchronous
  SecretFactory queries it.
- `CashuDeterministic.deriveWalletSeed(p2pkPrivkey)` — derives the
  64-byte NUT-13 master seed from the wallet's existing P2PK key via
  `HMAC-SHA512("Cashu-Wallet-Seed-v1", priv)`. No new field on
  kind:17375 needed; existing wallets become recoverable on first use.
  Distinct key-derivation domain so leaking a Cashu secret doesn't
  expose the P2PK key.
- `CashuWalletState.cachedSeed` (@Volatile) + `ensureSeed()` (suspend)
  — derives once on first call (paying the signer round-trip for the
  P2PK key), caches for the wallet's lifetime. Pure function of the
  P2PK key, so the cache never invalidates.
- `AccountSettings.cashuKeysetCounters` (`MutableMap<keysetId, Long>`)
  + `reserveCashuCounters(keysetId, count)` — atomic, persistent
  read-modify-write that hands out a strictly-monotonic counter range.
  Two contracts the spec demands: never reuse a (seed, keysetId,
  counter) tuple, and persist the increment BEFORE the secret is used.
  Both satisfied — the saveAccountSettings call happens inside the
  @Synchronized block before reserveCashuCounters returns.
- `peekCashuCounter(keysetId)` — read-only inspector for the upcoming
  NUT-09 restore driver loop (needs to know how high to scan).

Approach (a) — derive seed from the existing P2PK key — was chosen
over (b) — add a new BIP-39 mnemonic field to kind:17375 — because:
  - Zero migration: every existing wallet becomes recoverable on next
    op, no save-and-republish required.
  - The P2PK key is already the recovery-critical secret in kind:17375.
    Anyone with the kind:17375 can derive the seed and reconstruct
    everything; same threat model as today.
  - Cross-wallet recovery via mnemonic is a separate UX feature we can
    layer later by also storing/importing a mnemonic when the user
    wants that contract.

Backwards compatibility: SecretFactory defaults to RandomSecretFactory
on CashuMintOperations and CashuWalletOps, so existing direct
constructions (mint-operations tests, ad-hoc helper code) keep their
previous behaviour. Production goes through CashuWalletState, which
injects the deterministic factory.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:44 +00:00
Claude d971ff4da7 feat(cashu): NUT-17 WebSocket subscription protocol (JSON-RPC layer)
NUT-17 lets a wallet subscribe to mint events instead of polling. The
big win is the receive flow: instead of `LaunchedEffect { while (true)
{ delay(3000); viewModel.checkAndCompleteMint() } }` hammering the
mint every three seconds, a single subscribe at quote-open time and a
push-notification at PAID time. Smaller wins for melt status and
proof-state tracking.

This commit lands the protocol-message layer + tests; the actual
WebSocket client wrapper and integration into the receive flow follow
in a separate commit. Splitting these because the framing is normative
(any mismatch is a wire-format bug we'd rather catch in unit tests
than against a real mint).

What's here:

- WsRequest / WsResponse / WsNotification — JSON-RPC 2.0 framing as
  data classes. Wallet sends WsRequest; mint replies WsResponse for
  acknowledgement and pushes WsNotification for state updates.

- WsRequestParams unifies the two shapes (subscribe needs kind +
  filters + subId; unsubscribe needs only subId). kotlinx default
  null-omission keeps unsubscribe payloads clean.

- WsNotificationParams.payload is left as `JsonElement` rather than
  pre-deserialised to a discriminated union: the caller already knows
  the kind (it owns the subId it created), so it decodes directly to
  the typed DTO without a wasted intermediate parse.

- NutSeventeenKinds constants match the on-wire strings verbatim
  (`bolt11_mint_quote`, `bolt11_melt_quote`, `bolt12_mint_quote`,
  `bolt12_melt_quote`, `proof_state`). Renaming any of these would
  break interop with every mint — a test pins the values.

- ProofStateNotificationDto for the proof-state push payload (NUT-07
  shape: Y, state, optional witness).

Subtle bit: kotlinx.serialization omits fields whose value equals
the default. We need `jsonrpc: "2.0"` to ALWAYS appear on the wire —
mints reject anything else — so the field is annotated
`@EncodeDefault` (ExperimentalSerializationApi). Without this, the
first test of the round-trip showed `jsonrpc` missing in the encoded
payload, which a strict mint would reject before parsing further.

Tests: 10 cases covering subscribe / unsubscribe round-trip, the
unsubscribe shape omitting kind+filters cleanly, mint-quote and
proof-state notification decode, response with result vs error,
the wire-string constants, forwards-compat with unknown payload
fields, and a JsonObject-builder spoof for downstream tests that
want to simulate mint notifications without a real WS connection.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:44 +00:00
Claude 4747c0f039 feat(cashu): NUT-20 signed mint quote — quote-theft prevention
Some mints (and increasingly the upcoming ones) require the wallet to
prove ownership of the mint quote when redeeming it for proofs. Without
this, anyone who observes the quote id can race the wallet to /v1/mint/
bolt11 and steal the freshly minted proofs — the quote id is the only
credential. NUT-20 binds the quote to a wallet pubkey at quote creation
and requires a matching BIP-340 Schnorr signature at mint time.

What's here:

- MintQuoteBolt11RequestDto.pubkey — optional 33-byte compressed
  secp256k1 hex. When set, the mint records it and refuses to issue
  proofs without a matching signature.
- MintBolt11RequestDto.signature — optional 64-byte BIP-340 Schnorr
  hex over `sha256(quote_id || B_0 || B_1 || …)` where each B_ is the
  UTF-8 encoded hex string of an output's blinded message.
- MintQuoteSignature object — three-arg sign() taking quote id +
  output hex list + privkey, plus a DTO convenience overload. Hashes
  the concatenation with SHA-256 first per spec (BIP-340 is over a
  32-byte digest, not arbitrary payload bytes).

Older mints ignore both fields and operate per NUT-04, so leaving
them null is the no-op default for wallets that don't care.

Tests: 8 cases covering the payload-construction contract (quote ||
outputs in order, no separator), signature length (always 64 bytes),
round-trip verification against the derived x-only pubkey, sensitivity
to either input changing, wrong-length key rejection, and the DTO
convenience overload agreeing with the plain string form.

Not yet wired into CashuMintOperations / CashuWalletOps — like NUT-13
and NUT-09 from the previous commit, this lands the protocol primitive
so the integration step (per-quote keypair generation + persistence,
attaching pubkey to startMintFromLightning and signature to
completeMintFromLightning) is mechanical.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:44 +00:00
Claude c2f229b1ee feat(cashu): NUT-13 deterministic secrets + NUT-09 restore endpoint
Foundation for "lose your kind:7375 token events → still recover the
funds from a seed" — currently a permanent funds loss because secrets
are purely random and the mint won't return proofs the wallet can't
prove ownership of. NUT-13 fixes that by deriving every (secret, r)
pair from a seed + per-keyset counter, and NUT-09 gives the wallet a
way to ask the mint "which of these blinded messages have you signed?"
so a fresh wallet can reconstruct historic proofs.

What's here:

- `CashuDeterministic` (quartz commonMain) — full NUT-13 derivation
  for both v00 and v01 keyset id formats. The two cases need different
  algorithms:
    v00 (8-byte id): BIP-32 hardened derivation at
      `m/129372'/0'/{keyset_id_int}'/{counter}'/{0|1}`
      keyset_id_int = int.from_bytes(keyset_id) % (2^31 - 1)
    v01 (33-byte id): HMAC-SHA256 directly off the seed —
      base = "Cashu_KDF_HMAC_SHA256" || keyset_id || counter_be64
      secret = HMAC(seed, base || 0x00)
      r      = HMAC(seed, base || 0x01)
  The v01 swap is necessary because a 33-byte id can't faithfully
  encode into a 31-bit BIP-32 child index without lossy collapse.
  First byte of the id selects the branch (`00` → v00, `01` → v01).

- `RestoreRequestDto` / `RestoreResponseDto` + `/v1/restore` on the
  mint HTTP client (NUT-09). Wallet sends a batch of blinded
  messages; mint echoes back the subset it has previously signed +
  the issued signatures. Caller is responsible for the scan loop
  (try counters [0..N], stop after M consecutive empty batches).

Tests against the verbatim NUT-13 spec vectors (mnemonic = "half
depart obvious quality work element tank gorilla view sugar picture
humble", both v00 and v01 keysets, counters 0–4):
- v00: 5 counters × {secret, blinding} = 10 vector checks
- v01: 2 counters × {secret, blinding} = 4 vector checks (samples)
- 4 sanity properties: leaf 0 ≠ leaf 1, counter advances change
  output, different keysets produce different secrets, secretAsAscii
  is exactly 64 lowercase hex chars
- 3 keysetIdToInt edge cases: zero, small, fits-in-31-bits

Initial implementation had v00 working but v01 producing wrong output
— spent some time confirming the spec actually swaps algorithm based
on version byte (cashu-ts and nutshell both do; spec text doesn't
make this obvious). The cashubtc/nutshell `_derive_secret_hmac_sha256`
is the authoritative reference for the v01 path.

Not yet wired into CashuMintOperations — that's the next commit
(per-keyset counter persistence, deterministic blinding on mint/swap,
the actual restore() driver loop, UI for "recover from seed"). This
commit lands the math primitives + endpoint plumbing so the
follow-up is integration only, no further protocol decisions.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:44 +00:00
Claude 70e04523ea feat(cashu): NUT-12 DLEQ verification on every blind signature
Without DLEQ, a malicious or buggy mint can hand us a C' that doesn't
correspond to its published keyset key — we accept it, store the proof
as kind:7375, and only discover the forgery when we try to spend the
proof. By that point any sender already considers the payment done.

NUT-12 fixes this by having the mint return (e, s) alongside each C',
proving in zero knowledge that it used the same private key k for both
C' = k·B' and its published A = k·G. The wallet now verifies this on
every signature it accepts.

What's new:

- Bdhke.verifyDleq(e, s, B', C', A) — Alice-side check:
    R1 = sG - eA
    R2 = sB' - eC'
    e' = SHA256(R1 || R2 || A || C')   (compressed, 33-byte each)
    return e' == e
  Uses the existing ECPoint primitives; point negation via the same
  affine-Y flip pattern already used in unblind(). Defensive against
  malformed inputs — wrong sizes, off-curve points, zero/oversized
  scalars all return false (no exception escapes to callers).

- Bdhke.signFull(B', k, r') — mint-side counterpart, test-only.
  Lets round-trip tests exercise both halves without standing up a
  real mint. Optional r' argument keeps the suite deterministic.

- DleqProofDto on BlindSignatureDto.dleq — optional, parsed from the
  same JSON the mint already returns. Carries e, s, and an optional
  blinding-factor r (only ever non-null in proofs that travel between
  WALLETS — Carol verification, NUT-12 §3, not used here yet).

- CashuMintOperations.unblindOne now verifies the DLEQ proof when
  present. Mismatch → MintProtocolException, aborting the swap/mint
  before any proof event is published. Older mints that don't emit
  the dleq field still pass through (backwards compatible).

Tests: 9 cases on the verify/sign round-trip — happy path, wrong
mint pubkey, tampered C / e / s, wrong-length inputs, zero scalars,
and a determinism check that different DLEQ nonces produce different
proofs that nonetheless both verify.

Two bugs fixed during implementation worth flagging:
1. Secp256k1.pubkeyCreate returns 65-byte UNCOMPRESSED format. The
   first cut of signFull was passing that straight into the hash
   input, overrunning into the C' slot. Now compresses to 33 bytes
   first.
2. Initial verifyDleq rejected `e` scalars whose 32-byte value
   exceeded n. sha256 output can technically be >= n with
   probability ~2^-128 — vanishingly rare in practice, but the
   check was also rejecting valid proofs spuriously through a
   different path. ScalarN.isValid is only applied to `s` now;
   `e` only needs to be non-zero.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:44 +00:00
Claude aa307bfb63 feat(cashu): NUT-02 input-fee math on swap / swap-to-locked / melt
Newer mints charge a per-input fee on swap and melt. Without reserving
it from the output total, the mint rejects every swap/melt with
"amount mismatch" the moment it has any fee configured. We were
reading input_fee_ppk into the KeysetSummaryDto but never threading
it through the actual ops math — fee-charging mints simply didn't
work for us.

Per NUT-02 the fee is `ceil(numInputs * input_fee_ppk / 1000)`. The
ceiling is load-bearing: floor undercharges by one sat in the common
case (numInputs * ppk not exactly divisible by 1000), which is also
exactly what mints reject. New `computeInputFee` helper does the
ceiling-division in pure Long math — `(n * ppk + 999) / 1000` — with
defensive zeroing for null / zero / negative ppk.

Applied in three paths:

- swap(): output total = inputs - fee. The change bucket shrinks by
  fee; the send bucket (when split) stays whole.
- swapToLocked() (nutzap send): change shrinks by fee, recipient
  still gets exactly targetSplit sats locked.
- meltProofs(): required inputs grow by fee (separate from
  quote.feeReserve, which bounds LN routing fees, not the mint's
  processing fee). Change-output upper bound shrinks accordingly.

Also exposes input_fee_ppk on the full KeysetDto (was only on the
summary) so the fee-aware paths can read it from the same /v1/keys
call we already make.

Tests: 10 cases on the ceiling-division helper covering null/zero
ppk, exact-divide boundaries (999 / 1000 / 1001 inputs at 1 ppk),
typical and large fees, and defensive negative-ppk handling.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:43 +00:00
Claude 311c9d13d9 feat(cashu): account-load filter + LocalCache dispatch for NIP-60/61 kinds
Two fixes that together make sure NIP-60 / NIP-61 events actually reach
the cache and the always-on account loader picks them up.

1) LocalCache dispatch
   Before: justConsumeInnerInner's `when (event)` block had no branches
   for any of CashuWalletEvent / CashuTokenEvent /
   CashuSpendingHistoryEvent / CashuMintQuoteEvent / NutzapEvent /
   NutzapInfoEvent. Events of those kinds were dropped on the floor by
   the cache — only our private re-broadcast through
   account.sendLiterallyEverywhere() (which calls
   cache.justConsumeMyOwnEvent directly, bypassing the dispatcher) made
   the wallet visible. Events arriving cleanly from relays were
   silently lost.

   Now: each kind dispatches through the same consumeBaseReplaceable
   (kinds 17375, 10019) / consumeRegularEvent (7374, 7375, 7376, 9321)
   paths used by every other Nostr kind in the app. Token events,
   history, mint quotes, and inbound nutzaps now land in LocalCache
   from any source (relay, restore-from-prefs, manual paste, …).

   To make CashuWalletEvent dispatchable through consumeBaseReplaceable
   (which requires AddressableEvent), promote it from `Event` to
   `BaseReplaceableEvent`. The static helper `createAddress(pubKey)`
   stays for callers that don't have an instance; FIXED_D_TAG kept for
   backwards source compatibility.

2) Account-load filter
   AccountInfoAndListsFromKeyKinds2 (the always-on per-account
   subscription that loads kind:0 / NIP-65 / mute list / etc. on
   signin) now also pulls kind:17375 and kind:10019. This means even
   users who never open the wallet screen have their wallet event and
   nutzap-info indexed against their home-relay set — so the wallet is
   ready to render the moment they do open it, and inbound nutzaps can
   target a known kind:10019 without a separate fetch.

   Note: this doesn't replace CashuWalletFilterAssembler — that one
   runs against outbox relays and also fetches the non-replaceable
   kinds (7374, 7375, 7376, 9321). Both are needed; the relay client
   dedupes overlapping filters on the wire.

playDebug + fdroidDebug compile clean; 24/24 NIP-60 jvm tests still
pass (BdhkeTest × 7, AmountSplit × 7, P2PK × 6, MintException × 4).

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:41 +00:00
Claude bfd00ccc34 feat(cashu): send NIP-61 nutzaps from the zap picker
Adds an end-to-end "Nutzap" path to the existing zap chooser popup.

Protocol layer (quartz)
  * CashuMintOperations.swapToLocked: mints P2PK-locked outputs for a
    recipient pubkey alongside the unlocked change. Uses the new
    lockedOutputFor() helper which encodes NUT-11 P2PK secret strings
    before blinding.
  * NutzapInfoEvent.createAddress() mirrors CashuWalletEvent's helper so
    LocalCache.getOrCreateAddressableNote can look up a recipient's
    kind:10019 by pubkey alone.

Wallet ops (amethyst)
  * CashuWalletOps.sendNutzap: spends [available] proofs at [mintUrl] to
    produce locked outputs worth [amountSats], publishes a kind:9321 with
    those proofs + the zappedEvent + recipient p-tag, rolls leftover
    change into a new kind:7375 (with `del` referencing the sources),
    NIP-09-deletes the source token events, and logs kind:7376 (direction
    OUT, destroyed/created references).
  * CashuWalletState.peekNutzapTarget(recipient): pure read against the
    cached kind:10019 + our mint set. Returns a NutzapTarget (mint URL +
    recipient P2PK pubkey) if (a) we have a Cashu wallet, (b) recipient
    published kind:10019 with a P2PK pubkey, and (c) we share at least
    one mint with them. Returns null otherwise so the UI can hide the
    nutzap chip.
  * CashuWalletState.sendNutzap: orchestrates target lookup + ops call.

UI integration
  * ReactionsRow.ZapAmountChoicePopup gains a `nutzapEnabled: Boolean`
    parameter. When true, the popup renders a NutzapAmountChip per zap
    amount (tertiary-color, wallet icon) inline with the existing LN +
    on-chain chips. Tap fires AccountViewModel.sendNutzap which
    forwards into CashuWalletState.sendNutzap. Errors surface via the
    same toast path as LN-zap errors.
  * ReusableZapButton computes nutzapEnabled from the recipient's
    cached kind:10019; chip is hidden when no nutzap target resolves.

Sender currently has to have the recipient's kind:10019 already in
LocalCache for the chip to appear (typical when viewing a note whose
author the user has interacted with). Background prefetch of kind:10019
for unfamiliar authors is a follow-up.

24/24 NIP-60 jvm tests still passing. Both playDebug and fdroidDebug
compile clean.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:40 +00:00
Claude bbd43e34e9 fix(cashu): close publish-bridge race + lock in exception types via tests
Replaces the `var publishDelegate` set-after-construction pattern with an
explicit `CashuWalletState.start(publish: suspend (Event) -> Unit)`.
Account now calls `cashuWalletState.start { event -> sendLiterallyEverywhere(event) }`
from its own init { } block, AFTER all field initializers complete.

Why this matters: the previous code launched the backfill + cache-live
collectors from inside the state's own init { } block. Those collectors
could (and would, for returning users) fire an auto-redeem during
Account's field-initializer phase — at which point `publishDelegate` was
still the no-op default AND `followPlusAllMineWithIndex` (which
sendLiterallyEverywhere depends on) wasn't initialized yet. The publish
would silently swallow or NPE. Gating all of start()'s work behind a
@Volatile started flag eliminates the window.

Also: `MintExceptionTest` (+4 tests) pins down the runtime-exception
contract of `MintHttpException` and the new `MintProtocolException` —
the latter is what callers branch on when distinguishing "mint refused"
from "HTTP failed". Kept simple so any future refactor that breaks the
hierarchy fails loudly here instead of silently in describeMintError.

24/24 NIP-60 jvm tests passing.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:40 +00:00
Claude 5cd756cea2 refactor(cashu): lift wallet state to Account, react to live cache updates
Addresses the critical findings from the post-implementation audit:

A1. State holder lives on Account, not the ViewModel
  New CashuWalletState owns the wallet event, decrypted token contents,
  history, mint-quote, and inbound-nutzap indexes. It's constructed on
  Account and runs for the lifetime of the login session — so nutzaps
  arriving while the user is on Home/DMs/etc. get auto-redeemed without
  requiring the wallet screen to be open. ViewModel becomes a thin
  presenter that forwards flows + holds per-flow UI state (mint quote
  in progress, melt confirmation pending).

A2. Reactive observation via LocalCache.live.newEventBundles
  The state object backfills once from cache.notes at construction time,
  then receives incremental updates from the live new/deleted event
  bundles for any NIP-60/61 event authored by us (or addressed to us
  via #p for nutzaps). NIP-44 decryption results for kind:7375 events
  are cached by event-id, so the per-refresh re-decrypt is gone (D2).

A3. Mutex-guarded auto-redeem (no more duplicate /v1/swap races)
  redeemPendingNutzapsSerialized uses tryLock so a sweep already in
  flight short-circuits any new triggers; subsequent cache updates
  catch up via the next bundle.

A4. Mint-quote recovery on launch
  pendingQuotes flow surfaces unfulfilled kind:7374 events whose
  expiration hasn't passed and whose id isn't yet referenced with a
  "destroyed" marker in any kind:7376. ViewModel.resumeMintQuote()
  re-polls the mint for the original quote and rebuilds the flow.

B1. NutzapInfoEvent now carries the wallet's outbox relays so senders
  publish nutzaps where our assembler is actually listening.

B2. Subscription tracks the outboxRelaysFlow — when the relay list
  changes, the assembler subscription is rebuilt with the new set.

B5. New MintProtocolException distinguishes "HTTP fine, protocol said
  no" (e.g. melt state != PAID) from "HTTP error". Both surface
  through describeMintError() (now top-level — C4).

B7. redeemNutzap now pre-checks the P2PK secret's pubkey matches our
  wallet pubkey before signing — saves a wasted mint round-trip when
  the lock targets someone else.

B8. Melt is a two-phase flow: startMelt() returns a Quoted state with
  amount + fee_reserve so the UI confirms before paying; confirmMelt()
  actually spends. No more silent fee acceptance.

C1. MintHttpClient + CashuMintOperations cached per mint URL via a
  ConcurrentHashMap.

C3. AddCashuWalletScreen has a "Verify" button that pings /v1/info
  before adding, with inline success / failure feedback.

C7. Inline JsonObject FQN in P2PK.kt replaced with proper import.

C8. Dead .also { _ -> secretJson } removed from redeemNutzap.

D1. runCatching {}.getOrNull() callsites in the state holder now log
  via Log.w("CashuWallet") so silent failures surface in logcat.

D5. CashuWalletQueryState made @Immutable + data class for Compose
  stability hygiene.

Touched files: Account.kt (state field + constructor params),
AccountCacheState.kt + AppModules.kt (wire the assembler factory +
okHttpClientForMoney through), CashuWalletOps.kt (decouples from
Account, takes signer + publish callback), CashuWalletState.kt (new),
CashuWalletViewModel.kt (presenter rewrite), CashuWalletScreen.kt
(two-phase melt UI), AddCashuWalletScreen.kt (Verify button),
strings.xml (new keys).

All 20 NIP-60 jvm tests still pass; playDebug + fdroidDebug compile
clean.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:40 +00:00
Claude 16401e536a feat(cashu): full NIP-60 wallet + NIP-61 nutzap receive
Builds out the Cashu wallet beyond the scaffold: a complete mint
protocol layer, the four user-facing wallet operations (mint, melt,
send-as-token, redeem), and auto-redemption of inbound NIP-61
nutzaps. Wires the relay subscription so the wallet state syncs
across devices.

quartz/ — mint protocol layer (commonMain + jvmAndroid)
  * nip60Cashu/mintApi/MintApiDtos.kt — Kotlinx Serialization DTOs
    for NUT-00..06 (info, keys, mint/quote/bolt11, mint/bolt11,
    swap, melt/quote/bolt11, melt/bolt11, checkstate). ProofDto
    carries the optional NUT-11 witness.
  * nip60Cashu/mintApi/MintHttpClient.kt — OkHttp + kotlinx-json
    client bound to a single mint URL; surfaces MintHttpException
    with the mint's detail string preserved for the UI.
  * nip60Cashu/mintApi/CashuMintOperations.kt — combines BDHKE +
    HTTP + amount splitting. Exposes requestMintQuote / mintProofs
    / swap / requestMeltQuote / meltProofs / redeemNutzap. Power-
    of-2 amount split per NUT-00.
  * nip60Cashu/mintApi/AmountSplit.kt — extracted into commonMain
    for testability.
  * nip60Cashu/p2pk/P2PK.kt — NUT-11 locked-secret format and
    BIP-340 Schnorr witness signing.
  * CashuProof gains an optional witness field.

amethyst/ — wallet ops + UI
  * model/nip60Cashu/CashuWalletOps.kt — Nostr publishing layer
    over CashuMintOperations:
      - publishWalletEvents (kind 17375 + kind 10019 together)
      - startMintFromLightning / checkMintQuote /
        completeMintFromLightning (kind 7374 lifecycle + 7375 +
        7376 + NIP-09 deletion of the quote)
      - meltToLightning (pre-swap if needed, melt, change rollover,
        delete sources, history)
      - sendAsToken (swap to exact split, V4Encoder for cashuB,
        rollover, history)
      - redeemToken (inbound cashuA/B via swap)
      - redeemNutzap (NIP-61 P2PK unlock + swap, history with
        unencrypted "redeemed" marker per spec)
  * service/cashu/v4/V4Encoder.kt — inverse of the existing
    V4Parser; encodes proofs to cashuB strings for send.
  * ui/screen/loggedIn/wallet/CashuWalletScreen.kt — adds four
    action buttons (Receive / Send LN / Send Token / Redeem) with
    AlertDialog-based flows that poll the mint quote, paste/copy
    from clipboard, and surface mint errors.
  * ui/screen/loggedIn/wallet/CashuWalletViewModel.kt — new mint
    / melt / send-token / redeem state machines, subscribes via
    CashuWalletFilterAssembler on init (auto-syncs the wallet
    across devices), observes the wallet note's flow for reactive
    refresh, and auto-redeems any inbound kind 9321 nutzap that
    isn't already marked redeemed in our kind 7376 history.

relay subscription
  * commons/.../CashuWalletFilterAssembler.kt refactored into the
    standard ComposeSubscriptionManager + SingleSubEoseManager
    pair (matches the NWC pattern). Now driven by subscribe(query)
    / unsubscribe(query) calls from the ViewModel.
  * RelaySubscriptionsCoordinator.cashuWallet exposes a singleton
    assembler reachable as Amethyst.instance.sources.cashuWallet.

Tests (jvmTest)
  * BdhkeTest — 7/7
  * AmountSplitTest — 7/7 (NUT-00 vectors + sum invariants)
  * P2PKTest — 6/6 (secret round-trip, witness verifies under
    BIP-340, compressed + x-only acceptance)

Total: 20 new NIP-60 jvm tests, all passing. Both playDebug and
fdroidDebug compile clean.

Deferred (clearly bounded follow-ups):
  * Sending nutzaps (kind 9321) from the zap picker UI — requires
    integrating with the existing LN zap chooser surface. The
    underlying P2PK locking primitives are in place.
  * Recovering an interrupted kind 7374 mint quote on next launch
    — current flow keeps polling while the dialog stays open.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:40 +00:00
Claude a64cc274cc feat(amethyst): scaffold NIP-60 Cashu wallet UI + state
Adds the user-visible scaffolding for a Cashu wallet alongside the
existing NWC wallets. View-only for now — minting, send/receive, and
NIP-61 nutzaps land in a follow-up commit on this branch.

UI
  * AddWalletScreen is now a wallet-type chooser. The existing NWC
    flow moves verbatim to AddNwcWalletScreen; AddCashuWalletScreen
    is new: takes one or more mint URLs, auto-generates a separate
    P2PK key for nutzap receiving (or accepts a pasted hex key), and
    publishes a kind:17375 wallet event via the account's signer
    using CashuWalletEvent.build(mints, privkey).
  * CashuWalletScreen renders the wallet's mint list, total balance
    in sats (summed across all unspent kind:7375 token events the
    signer can decrypt, with rollover applied via the `del` field),
    and a chronological history view sourced from kind:7376.
  * WalletScreen surfaces the Cashu wallet as a card under "Your
    Wallets" when one exists, so the Wallets entry point shows both
    wallet kinds side by side.

Relay subscription
  * CashuWalletFilterAssembler (commons) subscribes one filter per
    relay covering kinds 17375/7375/7376/7374/10019 by author and
    one targeting inbound kind:9321 via #p. Not yet wired into
    Account.kt — the view path works because we feed our own writes
    through cache.justConsumeMyOwnEvent. Cross-device sync requires
    the assembler subscription wiring, which comes next.

Plumbing
  * Routes.WalletAddNwc / WalletAddCashu / CashuWallet added and
    registered in AppNavigation.
  * CashuWalletEvent.createAddress(pubKey) mirrors MetadataEvent for
    looking up the replaceable wallet event from LocalCache.

Compiles clean on playDebug + fdroidDebug; BDHKE jvm tests still
pass (7/7).

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:39 +00:00
Claude 023e2df542 feat(quartz): add BDHKE primitives for NIP-60 Cashu wallets
Implements blind Diffie-Hellman key exchange per NUT-00 — the
cryptographic core that lets a Cashu mint sign blinded messages
without seeing the underlying secret. Used by the upcoming NIP-60
wallet flows (mint, swap, melt) to issue and verify ecash proofs.

- hash_to_curve (NUT-00 try-and-increment, with Cashu domain separator)
- blind:    B_ = Y + r·G
- unblind:  C  = C_ - r·K
- sign/verify: mint-side helpers used by tests and DLEQ-less
  client-side proof validation.

All operations sit on top of the existing pure-Kotlin secp256k1
implementation in quartz/utils/secp256k1/, so they run on every KMP
target without JNI. Includes the official NUT-00 hash_to_curve test
vectors and a BDHKE round-trip with both the trivial (a=1, r=1) and
a random key.

7/7 jvm tests pass.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
2026-05-27 15:17:39 +00:00
Claude 3f3ccd88c8 Merge remote-tracking branch 'origin/main' into claude/confident-allen-AOGU6 2026-05-27 15:12:43 +00:00
Claude e876e2b09b feat(zap-splits): hide single-author zap split row in NoteCompose
When a note's only zap split recipient is the post author, the split is
redundant — the author already receives the zap. Skip rendering the row
in those cases by gating on a new `hasZapSplitSetupBesidesAuthor` helper.
2026-05-27 14:54:48 +00:00
Claude 62943d8f0a fix(music): preserve tags on edit/toggle, lock concurrent toggles, search, mute
Audit pass turned up several real bugs in the music feature. Rolled the
fixes into one commit since they all touch the publish/edit path.

Data-loss bugs (must-fix)

  - NewMusicTrackViewModel.publish() in edit mode rebuilt the event via
    MusicTrackEvent.build() with only the composer-visible fields, silently
    dropping every other tag the original event carried — video URL,
    released, track_number, format, bitrate, sample_rate, language,
    explicit, extra `t` genre tags, zap splits, anything custom. Adds a
    MusicTrackEvent.edit(earlierVersion, ...) companion that clones the
    existing TagArray and only mutates composer-managed fields, mirroring
    PinListEvent/BookmarkListEvent's `add`/`remove`/`resign` pattern.
  - AddToMusicPlaylistViewModel.toggle() and .createWithTrack() had the
    same problem for playlists. Adds MusicPlaylistEvent.addTrack /
    removeTrack companions that preserve the rest of the tag array. The
    VM now uses these instead of round-tripping through build().

Correctness bugs (must-fix)

  - MusicPlaylistEvent.build() emitted only one of `public`/`private`
    while isPublic() defaulted to `true`-when-absent. A private playlist
    therefore round-tripped as isPublic() && isPrivate(). isPublic() now
    falls back to !isPrivate(), and build() still emits the explicit pair
    so unrelated clients reading either flag agree on visibility.
  - AddToMusicPlaylistViewModel's `isWorking` flag wasn't a concurrency
    primitive — fast taps on different rows could race and the loser's
    broadcast would replace the winner's. Adds a Mutex around toggle /
    createWithTrack so they serialize.
  - The initial rescan() ran on the composition thread (init() is called
    from the sheet's body). Both initial and live re-scans now run on
    Dispatchers.IO inside the same Job, and the live collector filters
    bundles by kind so we don't re-walk LocalCache for every Text Note.
  - NewMusicTrackViewModel.init() set `isEditing = true` based purely on
    `editDTag != null`, so a stale dTag pointing at no cached event left
    the user on a Delete button that no-op'd. `isEditing` now derives
    from loadedEvent and falls back to create-mode when the lookup misses.
  - MusicTrackHeader synthesized mimeType "video/${format ?: "mp4"}" when
    a `video` URL was present — but `format` is the AUDIO format per
    spec, so a track with both `video=...mp4` and `format=mp3` emitted
    "video/mp3". Pass null when videoUrl wins and let ExoPlayer sniff.
  - MusicTracksFeedFilter only consulted params.match(), which checks
    the follow list but not mute/spammer/word lists. Muted authors leaked
    through. Mirror LongsFeedFilter and AND `account.isAcceptable(note)`.

Search & idiomatic fixes (should-fix)

  - MusicTrackEvent + MusicPlaylistEvent now implement SearchableEvent so
    the local SQLite FTS indexes title/artist/album/description rather
    than only the JSON content. Searching "Pink Floyd" by artist now
    matches the local cache instead of waiting for relay results.
  - MusicTracksFeedFilter.feedKey() now class-prefixes with "music-" so it
    can't collide with other feeds that key off the same Home follow list.
  - MusicPlaylistEvent.build() renamed `description`/`shortDescription` to
    `content`/`description` so the parameter names match the spec.

Nits

  - Drop trackCount() — callers prefer `trackAddresses().size`.
  - Drop six unused strings (composer placeholders for not-yet-wired
    Blossom audio/cover upload UI).
  - Wrap preview runBlocking { justConsume(...) } in remember{} so it
    runs once per preview key instead of every recompose.
  - Use a hex-shaped id for preview events instead of "track_xxx_yyy".
2026-05-27 11:10:09 +00:00
m daa7c7913e feat(namecoin): mention umbrel alongside StartOS in docs and UI hints
Both umbrelOS (via getumbrel/umbrel-apps#4962) and StartOS / Start9
(via Start9-Community/namecoin-core-startos) ship a self-hosted
Namecoin Core that this backend can target. Generalize the
help/strings so umbrel users discover the feature too.

No logic changes.
2026-05-27 14:50:37 +10:00