mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
4db226d5f2fa5c50d5c04d975b1e4e72caa0ecbb
14209
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4db226d5f2 |
fix(cashu): heal stale proofs + harden auto-redeem against double-spend
Two changes that together address "Mint error (HTTP 400): proofs already spent" on self-send / self-zap: 1. Replace the auto keyset-migration on wallet load with a non-destructive NUT-07 sweep. The migration performed a swap-then-publish that wasn't atomic — a failure mid-sequence (cancelled signer prompt, transient network) left the mint with the source proofs marked spent while our local kind:7375 still held them. The next user-initiated send would then pick those ghost proofs and the mint would reject the swap. The new sweep does a read-only /v1/checkstate per mint and NIP-09 deletes any kind:7375 whose proofs are all reported SPENT. Mixed-state entries (rare; partial sub-swap landed) are logged and skipped — they self-resolve when the user spends the entry. migrateStaleKeysets() stays around for explicit on-demand wiring. 2. Track redeemed nutzap ids in an in-memory set inside the redeem mutex. Without this, a second triggerAutoRedeem firing in the ~1s window between publishing kind:7376 and the bundled newEventBundles emission could re-pick the same nutzap and the mint would 400 with "proofs already spent" on the P2PK swap. |
||
|
|
1190abb30d |
refactor(user): eager-init pinned addressable notes
The three pinned per-user replaceable notes (NIP-65 / DM relays / nutzap info) were lazy fields. Lazy delegation here adds a synchronized read on every access for no gain — User is constructed via LocalCache.getOrCreate, and the pinned notes are read on essentially every interaction with the user. Resolving them at construction also lets us drop the stored UserContext reference. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
6caaae460b |
ui(cashu): swap generic Wallet glyph for the bundled CustomHashTagIcons.Cashu logo
Four spots that represent Cashu-specific things were using MaterialSymbols.AccountBalanceWallet — a generic bank/wallet icon that read no different from the on-chain or Lightning iconography on the zap popup. Replace with the multi-tone Cashu logo that already ships in commons/hashtags/Cashu.kt and is the canonical brand mark used by CashuRedeem.kt and the hashtag chips. Spots swapped: - ReactionsRow.NutzapAmountChip — the purple chip in the zap popup (most important — gives Cashu a distinct visual identity next to the orange Lightning bolt and on-chain ₿ chips) - CashuWalletSettingsScreen.RecommendationSuggestionList — the autocomplete dropdown beneath "Recommend a mint" - AddCashuWalletScreen.MintSuggestionList — autocomplete under the mint URL input on the create/edit wallet form - CashuWalletScreen.MintRow — the leading icon on each wallet-mint row in the wallet header Used `tint = Color.Unspecified` to preserve the icon's native tan + amber palette (the icon ships with three solid colors on its paths); flattening to a single colorScheme tint would drop the brand cue. Brought in via `import androidx.compose.material3.Icon as Material3Icon` because the file-level `Icon` is the project's custom MaterialSymbol- only overload (in commons.icons.symbols) — couldn't reuse it for an ImageVector. https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP |
||
|
|
a105eb91d7 |
ui(zap): reorder amount-choice chips — Cashu, Lightning, on-chain
Previously the popup rendered Lightning chips first, then on-chain, then Cashu. With recipient-capability gating in place, lead with the rail that's both fastest and free-of-fees when available — Cashu (nutzap) — falling back to Lightning, then on-chain. Mirrors the priority order RailCapabilityResolver uses for the underlying capability fallback, so the visual top-down sweep matches "best rail for this recipient that the sender has configured". Mechanical reorder inside ZapAmountChoicePopupContent's FlowRow — no behaviour change beyond layout. Gear settings button stays at the end. https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP |
||
|
|
b0bb2885ea |
ui(cashu): @Preview composables for the new wallet bits
Adds Android-Studio-visible previews for the recently-authored composables on the Cashu wallet stack, so designers / reviewers can eyeball them without building and launching the app. All previews use the existing ThemeComparisonColumn helper to render dark + light side by side, matching the convention used by SensitivityWarning and the other existing preview files. CashuWalletSettingsScreen: - SettingsRow (with + without subtitle) - EmptyRecommendationsHint - AddRecommendationRow (empty + typing state) - RecommendationSuggestionList - RecommendationRow (plain + with review text) AddCashuWalletScreen: - MintSuggestionList (multi + single) For RecommendationRow a tiny `fakeRecommendation` helper builds a synthetic kind:38000 with stable tag layout (d / k / u) so the preview exercises the same `mintUrls()` + `dTag()` paths the real renderer uses without touching the signer. Skipped: top-level screen composables (CashuWalletSettingsScreen, CashuWalletScreen, AddCashuWalletScreen) — those take AccountViewModel / CashuWalletViewModel / INav, which can't be cheaply faked. Anyone who needs to see them previewed should iterate on the smaller sub- composables included here. https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP |
||
|
|
8fa636bbe8 |
refactor(model): lazy-pinned addressable notes on User via UserContext
Pins each per-user replaceable note to the User's lifetime so weak-ref
eviction from LocalCache.addressables can't lose them — same fix the
NIP-65 / DM relay list notes already had, generalised so adding new
pinned kinds is a one-liner.
Background: LocalCache.addressables is a LargeSoftCache<Address,
AddressableNote> backed by WeakReference. Without a strong reference
somewhere, an addressable note shell (and any event loaded into it) can
be cleared on any GC cycle even though it was successfully delivered.
The User constructor already held nip65RelayListNote / dmRelayListNote
fields exactly to defeat this for kinds 10002 and 10050. kind:10019
(NutzapInfoEvent) had no such pin, so the zap picker's "does this user
accept nutzaps?" check would silently return null for an evicted note —
the chip never showed even when the recipient had actually published.
This refactor:
1. Adds `UserContext` — a one-method `fun interface` exposing
`addressableNote(addr): Note`. User holds it for life; LocalCache
implements it via a single instance bound to ::getOrCreateAddressableNoteInternal.
2. Converts the three per-user pinned notes (nip65 / dm / nutzapInfo)
to `by lazy` fields backed by the context. Each is resolved the
first time it's read and then held by the User's strong reference
until the User itself is collected. `by lazy`'s default SYNCHRONIZED
mode handles concurrent reads from the zap picker + wallet state.
3. Adds typed accessors on User: nutzapInfo(), acceptsNutzaps(),
nutzapMints(), nutzapP2pkPubkey() — mirrors the existing
authorRelayList() / dmInboxRelayList() shape.
4. CashuWalletState.peekNutzapTarget now reads via
`cache.getOrCreateUser(recipientPubKey).nutzapInfo()` instead of
touching the cache's addressable map directly.
Tradeoffs vs the eager-constructor approach:
- No upfront allocation for kinds the screen never reads.
- Adding a new pinned kind (mute list, blocked relays, bookmark list)
is one `by lazy { context.addressableNote(...) }` line in User —
no constructor-signature churn across call sites.
- User now depends on a narrow `UserContext` interface; test fakes are
a one-liner: `User(hex) { addr -> Note(addr.toValue()) }`.
Migration:
- Single User constructor call site (LocalCache.getOrCreateUser) updated.
- Two existing test fakes (NoteOnchainZapTest, SearchResultSorterTest)
switched to the SAM-lambda form.
- No external behaviour change — the public `nip65RelayListNote` /
`dmRelayListNote` fields keep the same names and types, so the few
consumers (RelayFeedViewModel, ChatNewMessageViewModel) need no edits.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
|
||
|
|
0cb07761ce |
feat(cashu): add-recommendation input + suggest only on typed text
Two changes in the Cashu Wallet Settings flow: 1. My Mint Recommendations now has its own input row. A new OutlinedTextField under the section header lets the user paste a mint URL and tap "+" to publish a kind:38000 recommendation without having to leave Settings, find the mint elsewhere, and thumbs-up it. As the user types, the same cache-backed directory autocomplete that AddCashuWallet uses surfaces matching mints from the kind:10019 / kind:38000 / kind:38172 the cache already holds — tap a suggestion to one-shot recommend (publish + clear the field), useful for chaining several adds. Suggestions are filtered to drop URLs the user has already recommended (de-duped by the lowercased / trailing-slash-stripped mint URL across the user's own kind:38000s) so the same row never appears in both the autocomplete and the list directly below. 2. Autocomplete reacts only to typed text, not to an empty field. The first iteration showed the whole directory the moment the field gained focus — i.e. on the Edit Cashu Wallet mint-URL popup the user saw mint URLs without typing anything, which felt like the form was pre-populating itself. Both call sites (AddCashuWalletScreen + CashuWalletSettingsScreen) now early-return an empty suggestion list when the trimmed input is blank, so the dropdown is purely a reaction to what the user types. https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP |
||
|
|
94121c71c9 |
feat(cashu): mint URL directory + autocomplete
Adds a cache-backed Cashu mint directory sibling to LocalCache.relayHints that aggregates mint URLs from every relevant event the cache sees, and wires it into the AddCashuWallet mint-URL text field as inline autocomplete so users don't have to remember mint URLs. What feeds the directory: - NutzapInfoEvent (kind:10019) — every nostr user with a Cashu wallet publishes their accepted mints there. A typical inbox of cached profiles seeds a useful starter directory automatically. - MintRecommendationEvent (kind:38000) — explicit public vouches. - CashuMintEvent (kind:38172) — formal mint announcements from the NIP-87 directory subscription. How it's populated: - LocalCache.updateMintIndex(event) is called from justConsumeAndUpdateIndexes alongside updateHintIndexes, so every new event with a mint URL adds to the index. wasNew gating prevents re-emissions from inflating popularity counters. - LocalCache.ensureMintDirectoryBackfilled() does a one-shot scan of the existing notes + addressables maps. The autocomplete UI kicks this in a LaunchedEffect on screen open so suggestions are useful before the next relay round-trip. Where it surfaces today: - AddCashuWalletScreen — under the mint-URL OutlinedTextField, a MintSuggestionList card shows up to 6 cache-derived suggestions ranked by popularity desc + URL asc. Tapping a row fills the field (does not auto-add — users typically want to Verify first). Filters out URLs the user already added and exact matches of what they typed. The MintPicker dropdown inside the Receive / Send dialogs is unchanged — those only need to choose between mints the user already has in their wallet, so no directory autocomplete applies there. Tests: 8 unit tests cover normalisation (case-insensitive, trailing-slash stripping, http(s) gating), popularity ranking, substring filtering, limit enforcement, and malformed-URL handling. URL normalisation: trimmed, lower-cased, trailing `/` stripped, scheme must be http(s). Same URL with different casing or trailing slash collapses to one entry so popularity counts correctly. Implementation notes: - MintDirectoryIndex lives in commons/jvmAndroid (uses ConcurrentHashMap; iOS doesn't ship Cashu wallet yet). - Thread-safe; safe to read from any dispatcher. - No persistence — purely in-memory, accumulates over the session. - Entries are never removed: stale entries don't hurt (user always verifies before adding), and tracking which event added which URL would add bookkeeping without UX benefit. https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP |
||
|
|
fa9c7503ed |
fix(cashu): pending-invoice card lingering + thumbs-up mint recommendation never landing
Two bugs sharing the same root cause — LocalCache silently dropping or
losing events that downstream wallet state depended on.
1. Discard Invoice didn't remove the pending banner.
`CashuWalletState` listens on `cache.live.deletedEventBundles` to
prune `quoteEvents` when a NIP-09 delete of a kind:7374 is processed.
That stream is only emitted from `LocalCache.deleteNote`, which is
only reached when `consume(DeletionEvent)` finds the target Note
still resident in `notes` — a `LargeSoftCache<HexKey, Note>` backed
by `WeakReference`s (commons/.../LargeSoftCache.kt). Weak references
can be cleared on any GC cycle, so on a moderately busy device the
quote Note is often gone between publish and the deletion round-trip;
the cache's deleteNote path then no-ops, `_deletedEventBundles`
never fires, and our `quoteEvents` map keeps the deleted entry until
process death — manifesting as a pending-invoice banner that the
user can't dismiss.
Fix: process our own kind:5 deletions inline in the
`newEventBundles` collector (which DOES see every kind:5 we publish,
independent of soft-cache state) by extracting `deleteEventIds()`
and calling the existing `removeEvents()`. The wallet flows now stay
in sync regardless of weak-ref collection.
2. Thumbs-up on a mint never appeared in My Mint Recommendations.
`LocalCache.justConsumeAndUpdateIndexes` dispatches by event type and
falls into a `else -> Log.w("Event Not Supported")` branch for
anything missing a `when` arm — silently dropping the event. None of
the three NIP-87 events (`CashuMintEvent`, `FedimintEvent`,
`MintRecommendationEvent`) had a dispatch entry, so when the wallet
published a kind:38000 the cache rejected it, `newEventBundles`
never emitted, and `CashuWalletState.applyEvents` never indexed it.
Same broken path for mint announcements arriving from
`CashuMintDirectoryFilterAssembler`'s relay subscription.
Fix: add three dispatch entries routing all NIP-87 events through
`consumeRegularEvent`. They're parameterized-replaceable per spec
but none extend `AddressableEvent` in Quartz today, so
`consumeBaseReplaceable`'s `check(event is AddressableEvent)` would
throw — `consumeRegularEvent` works because the downstream consumers
(`CashuMintDirectoryState`, `CashuWalletState.applyEvents`) already
dedupe by `(pubKey, dTag)` and keep the newest.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
|
||
|
|
12f8b025fb |
feat(zap): gate ZapAmountChoicePopup chips on recipient capability
Until now the popup showed Lightning chips whenever the sender had LN
amounts configured, regardless of whether the recipient had any way to
receive sats over LN. Nutzap chips were already recipient-gated; this
extends the same pattern to Lightning and (defensively) on-chain so a
single tap can't silently land in a no-op.
Adds a small `RailCapabilityResolver` that, given a note, returns three
flags evaluated against the author + every `zap` split tag on the event:
- hasCashu — any pubkey recipient has a kind:10019 with P2PK and
shares a mint with our wallet (delegates to the existing
CashuWalletState.peekNutzapTarget).
- hasLightning — any pubkey recipient has lud16/lud06 in kind:0, OR the
note has at least one direct ZapSplitSetupLnAddress.
- hasOnchain — at least one pubkey recipient exists (NIP-BC derives the
Taproot address from the pubkey, so any nostr pubkey is
payable; an event with only lnAddress-only splits has
nothing to tweak).
A flag is `true` when at least one recipient on the note can be paid
through that rail — matching the existing best-effort behaviour of the
real send paths (ZapPaymentHandler skips pubkeys with no lnAddress;
OnchainZapSendDialog separately warns about skipped lnAddress splits).
The popup gates the existing `zapAmountChoices` / `onchainZapAmountChoices`
lists on the corresponding flags by passing an empty list when the rail
is unsupported — same pattern already used for nutzap chips, so
ZapAmountChoicePopupContent and the chip composables stay unchanged.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
|
||
|
|
9d0224ba5b |
feat(cashu): Wallet Settings screen with retractable mint recommendations
The top-bar pencil on the Cashu Wallet screen becomes a gear that opens a new Settings hub instead of jumping straight to the edit form. The hub hosts: - "Edit wallet details" → routes to the existing AddCashuWallet form in edit mode (mints + nutzap key). - "My mint recommendations" → live list of NIP-87 kind:38000 events this account has published, each with a NIP-09 retract button. Retraction fires DeletionEvent with both `e` and (when a d-tag is present) `a` tags so compliant relays drop all versions of the parameterized- replaceable recommendation. The wallet's existing CashuWalletFilterAssembler now pulls MintRecommendationEvent.KIND alongside the other NIP-60 / NIP-61 kinds, so the list populates without an extra subscription. CashuWalletState indexes own recommendations into a new `ownRecommendations` StateFlow (keyed by d-tag, falling back to event id for malformed events) and keeps it in sync via the existing live cache + delete observers. Future settings (auto-recommend toggle, nutzap relay overrides, export/backup) belong here — consolidating wallet-shaped knobs in one place avoids re-cluttering the main wallet screen. https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP |
||
|
|
e4b0d80e5c |
ui(cashu): wallet polish — discard invoice, tile alignment, history rows
Three small UI fixes on the Cashu wallet screen: - Add a Discard button to the Receive dialog. When the user requested an invoice they no longer want (typo'd amount, wrong mint, changed their mind), tap Discard to NIP-09-delete the kind:7374 mint quote so the pending-quote banner stops re-surfacing it. - Stop the action-row labels from clipping. The previous OutlinedButton-per-tile layout, with 4 tiles in a row plus default 24dp horizontal padding and a 20dp icon, clipped "Send Token" on standard 360dp phones. Replaced with a custom Surface+Column tile that gives us control over padding and lets the label wrap to 2 lines. - Render history rows like the LN + on-chain transaction lists: counterparty avatar on the left, name + timestamp in the middle, signed amount on the right. For inbound nutzap redemptions we resolve the sender's pubkey from the redeemed-marker `e` tag (the kind:9321 is in LocalCache thanks to the cashu filter assembler). For everything else there's no Nostr counterparty, so we fall back to a directional arrow icon matching the LN wallet's empty-counterparty pattern. https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP |
||
|
|
9cfca5272e |
fix(wallet): replace the chooser in the back stack when user picks a type
Bug: after saving an NWC connection (or any conclusion of the form
screen), nav.popBack() landed on the wallet-type chooser instead of
the Wallet screen. The chooser would then sit there as a useless dead
end requiring another back press to escape.
Same bug existed on the Cashu add path from the chooser, though the
edit-from-CashuWalletScreen path was unaffected because it bypassed
the chooser entirely.
Fix: at the chooser, replace the chooser entry with the form via
popUpTo(target, Route.WalletAdd::class) instead of pushing the form
on top via nav.nav(target). Now:
* From + on Wallet → Choose → NWC → Save → back to Wallet (1 pop)
* From + on Wallet → Choose → Cashu → Save → back to Wallet (1 pop)
* From CashuWalletScreen → Edit → Save → back to CashuWallet (unchanged)
* Back button from inside the form now also goes directly to
Wallet, skipping the chooser that's no longer in the stack —
a minor improvement since the chooser had nothing useful to
return to after a type was picked.
|
||
|
|
817227806b |
fix(cashu): replace auto-popup with a tappable pending-quote banner
Bug: every entry to the wallet screen re-opened the Receive dialog if any pending kind:7374 mint quote existed. So a user who got an invoice and then navigated away — for any reason, even without dismissing — would be greeted by the same invoice dialog the next time they opened the wallet. Worse: even after they'd paid and the mint had issued proofs, the brief window before the kind:7374 NIP-09 delete propagated would cause the dialog to pop again. Fix: drop the LaunchedEffect(pendingQuotes) auto-resume; replace with a non-modal banner card just under the BalanceCard that shows "N pending invoices · Tap to resume". The user opts in by tapping it. The underlying CashuWalletState.pendingQuotes flow + the viewModel.resumeMintQuote() VM method are unchanged — only the trigger surface moves from "auto" to "user-initiated". playDebug + fdroidDebug compile clean; 24/24 jvm tests still pass. |
||
|
|
8ac2a6cac3 |
ui(wallet): drop the "Your Wallets" header from the wallet list
It was redundant context — the screen title already says "Wallet" and the cards underneath are clearly the user's. Removing the section header reclaims vertical space on the main list. Kept the Spacer above the first card so the cards don't bump against the OnchainSection divider. Leaving the wallet_your_wallets string resource in place so Crowdin translations stay valid; removing it would force a fan-out across every locale file for no functional gain. |
||
|
|
b9e85d15e0 |
feat(cashu): pre-cache kind:10019 alongside kind:0 for every viewed user
UserMetadataForKeyKinds — the per-user kind list pulled by UserWatcherSubAssembler every time the app renders a user (profile pictures, names, status, identities, NIP-65, DM-relays, etc.) — now also includes NIP-61 NutzapInfoEvent (kind:10019). Net effect: when you scroll into a note authored by user X, the same subscription that fetches X's kind:0 also pulls X's kind:10019 if present. The Nutzap chip in the zap picker (which peeks LocalCache via CashuWalletState.peekNutzapTarget) can then resolve without an extra round-trip — so the chip appears immediately for any user we've at least seen the profile of. Deliberately NOT co-loading kind:17375 here. That's the user's private wallet, NIP-44-encrypted to them; we couldn't decrypt it and have no reason to fetch other people's wallet events. Only the public kind:10019 announcement is useful cross-user. The owning user's own 17375 is fetched by AccountInfoAndListsFromKeyKinds2 (the always-on account-load filter). Cost: one extra kind in an already-batched per-relay author filter. No additional round-trips, no extra subscriptions. playDebug + fdroidDebug compile clean; 24/24 NIP-60 jvm tests still pass. https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP |
||
|
|
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 |
||
|
|
3a35eaac73 |
feat(cashu): back up kind:17375 + kind:10019 to account preferences
Mirrors the existing local-backup pattern used for kind:0, kind:3,
NIP-65 relay list, mute list, etc.: every time the user's Cashu wallet
event or nutzap info event lands in LocalCache, the latest copy is
serialized into encrypted account prefs. On next launch the saved copy
is pushed into LocalCache before any relay round-trip, so the wallet
screen renders the user's existing wallet immediately — even if relays
are slow or unreachable. The AccountSessionManager also re-broadcasts
both events on signin, exactly like it does for kind:0 / kind:3.
Why this matters:
* Wallet event (kind:17375) holds the user's mint list AND the P2PK
private key used to receive nutzaps. If we couldn't fetch it from
relays on cold start and the user tapped "Create wallet", we'd
overwrite the remote one — destroying the P2PK key and orphaning
any inbound nutzaps. The discovering-state UI commit added a
timeout safety net; this commit makes the wallet actually load
instantly from local backup, removing the race entirely for
returning users.
* Nutzap info (kind:10019) tells other users which mints we accept
and which P2PK pubkey to lock proofs to. Losing it would mean
senders' new nutzaps wouldn't reach us.
Plumbing:
* AccountSettings gains backupCashuWallet + backupNutzapInfo +
updateCashuWallet/updateNutzapInfo setters (dedup by event id,
saveAccountSettings() on change — same shape as updateNIP65RelayList).
* LocalPreferences: LATEST_CASHU_WALLET + LATEST_NUTZAP_INFO PrefKeys
constants, putOrRemove in the writer, async parseEventOrNull in
the reader, constructor wiring on AccountSettings rebuild.
* AccountSessionManager: rebroadcast both events on signin alongside
the existing kind:0/3/NIP-65/etc. broadcast.
* CashuWalletState: takes AccountSettings, on start() pushes both
backups into LocalCache via justConsumeMyOwnEvent, and applyEvents
persists any new wallet/nutzap-info into settings via update*().
* isRelevantEvent + applyEvents + removeEvents now also handle
NutzapInfoEvent (we subscribe to kind:10019 but previously didn't
index it as a first-class state surface — now exposed as
cashuWalletState.nutzapInfoEvent).
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
|
||
|
|
bb86df246a |
fix(cashu): show "discovering" state instead of empty-create CTA on first launch
NIP-60 wallets are portable — kind:17375 + 7375 + 7376 + 10019 are stored on relays, so a wallet created in another client (cashu.me, Boardwalk, etc.) should appear in Amethyst when the user signs in with the same Nostr key. The plumbing already handles this: our CashuWalletFilterAssembler subscribes to kinds=[17375, 7375, 7376, 7374, 10019] authored by us, and CashuWalletState.applyEvents() indexes incoming events regardless of which client published them. The UX bug: the wallet screen had two states (wallet event present / absent). On first launch, before relays delivered the existing wallet event, we rendered the "No Cashu wallet — Create" state. Tapping Create there published a fresh kind:17375 which (being replaceable) clobbered the remote wallet — destroying the P2PK key and orphaning any inbound nutzaps locked to it. Fix: * CashuWalletState gets a `discovering: StateFlow<Boolean>` set to true at start() until either a wallet event arrives (cleared from applyEvents) or DISCOVERY_TIMEOUT_MS (8 s) elapses, whichever first. * CashuWalletScreen renders a "Looking for your wallet…" pane with a spinner + explainer while discovering is true. Empty-create CTA only fires after timeout for genuinely wallet-less users. playDebug + fdroidDebug compile clean; 24/24 jvm tests still pass. https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP |
||
|
|
811aa26d57 |
fix(cashu): init the wallet VM synchronously in composition body
Stack trace from runtime:
java.lang.NullPointerException
at CashuWalletViewModel.getState(CashuWalletViewModel.kt:139)
at CashuWalletViewModel.getWalletEvent(CashuWalletViewModel.kt:142)
at WalletScreen.kt:94
Cause: `CashuWalletViewModel.state` dereferences `account!!`, which is
populated by init(). I had init() inside `LaunchedEffect(Unit)` in the
three call sites — that effect only fires *after* the first composition
returns, so the very first read of `viewModel.walletEvent` (line 94 of
WalletScreen) hit a null account and threw.
The existing `WalletViewModel` (NWC) handles this by calling init()
directly in the composable body — init() is idempotent (just assigns
two fields), so recomposing is fine. Match that pattern in
WalletScreen, CashuWalletScreen, and AddCashuWalletScreen.
Both flavors compile clean; 24/24 NIP-60 jvm tests still pass.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
|
||
|
|
ba46f31359 |
feat(cashu): NIP-87 mint discovery + recommendations
Adds end-to-end NIP-87 support so users can pick mints from the
network instead of having to know URLs upfront, and can publicly
endorse mints they use.
Discovery (commons + amethyst)
* commons/.../CashuMintDirectoryFilterAssembler — subscribes to
kind:38172 cashu mint announcements and kind:38000 cashu-scoped
recommendations (#k=["38172"]) on a configurable relay set.
Fedimint announcements (38173) are intentionally excluded — this
feeds the Cashu mint picker only.
* RelaySubscriptionsCoordinator.cashuMintDirectory — singleton
assembler reachable as Amethyst.instance.sources.cashuMintDirectory.
Indexing state (amethyst/model)
* CashuMintDirectoryState — account-scoped index of announcements +
recommendations. Reactive: backfills from LocalCache.notes on
first observer and listens to LocalCache.live.newEventBundles for
incremental updates. The relay subscription only runs while at
least one picker is on screen (ref-counted open()/close()).
* Ranking: follows-recommendations DESC, then total recommendations
DESC, then URL ASC. Dedup'd by (recommender, mint URL) so a
single recommender can't inflate counts by re-posting.
* CashuMintDirectoryEntry — display model with URL, latest
announcement, total and follows-recommendation counts.
Publishing recommendations (CashuWalletOps)
* recommendMint(mintUrl, dTag?, review) — publishes kind:38000 with
both the `a`-tag (pointing at the mint's announcement by
kind:pubkey:dTag) and a `u`-tag with the raw URL so older clients
indexing by URL still pick it up.
UI integration
* MintPickerSheet — ModalBottomSheet with search field + scrollable
list. Each row shows the mint name (parsed from the announcement
content) or URL, with badge chips for "from people you follow"
and total recommendation counts. The "Add" button writes the URL
back to the caller's mints list; already-added URLs show "Added"
instead.
* AddCashuWalletScreen gets a "Browse" button next to the Mints
section header that opens the picker. Selected mints are still
Verify-able via the existing ping; users can still paste manually
if they want.
* CashuWalletScreen's mint list gets a thumb-up icon button per
mint that fires viewModel.recommendMint(url) — best-effort,
silent failure (logged via Log.w("CashuWallet")).
Wiring
* cashuMintDirectoryFilterAssembler factory plumbs through Account →
AccountCacheState → AppModules. The mock test AccountViewModel
constructions in AccountViewModel.kt are updated to pass a fresh
assembler.
playDebug + fdroidDebug compile clean. 24/24 jvm tests still passing.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
|
||
|
|
c6757a2fae |
fix(cashu): make AddCashuWallet screen also work as Edit, preserve P2PK key
Bug: tapping the Edit pencil on CashuWalletScreen routed to
AddCashuWalletScreen, which always started with an empty mints list and
auto-generated a fresh P2PK key on save. Net effect: editing a wallet
silently wiped the mint list and invalidated any inbound nutzaps locked
to the previous key.
Changes:
* AddCashuWalletScreen now detects edit mode (walletEvent != null) and
pre-fills the mints list from CashuWalletState.mints on entry.
Subsequent state updates (e.g. mints arriving from relays mid-edit)
merge in via LaunchedEffect(existingMints).
* P2PK key handling is now an explicit 3-way radio (KeepCurrent /
AutoGenerate / Manual) with KeepCurrent as the edit-mode default.
AutoGenerate in edit mode shows a destructive-action warning. Create
mode hides KeepCurrent and defaults to AutoGenerate.
* CashuWalletState.exportP2pkPrivkeyHex() — suspending accessor used by
the VM when KeepCurrent is selected. Necessary because remote / NIP-46
signers need a round-trip to decrypt the wallet's NIP-44 content.
* CashuWalletViewModel.saveWallet(mints, keyMode, manualPrivkey?) —
replaces the old (autoGenPrivkey, manualPrivkey) shape with the
explicit P2pkKeyMode enum so the screen and VM agree on intent
instead of inferring it from a boolean.
* Title shows "Edit Cashu wallet" + button reads "Save changes" when
editing an existing wallet.
* Vertical scroll added so radio + manual key field don't push the
Save button off-screen on small devices.
Both playDebug + fdroidDebug compile clean; 24/24 jvm tests still pass.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
|
||
|
|
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
|
||
|
|
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
|
||
|
|
bae6e8dcb5 |
fix(cashu): correct skip-on-decrypt-fail + auto-resume orphan mint quotes
Two small follow-ups to the audit refactor:
* recomputeUnspent: replace the broken `getOrPut { ... return@forEach }`
pattern (which short-circuited the outer loop on a single decryption
failure, skipping remaining tokens) with an explicit containsKey
guard. Decryption failures are now individually skipped without
affecting other tokens in the same pass.
* CashuWalletScreen: when the wallet opens and pendingQuotes (live
flow from CashuWalletState) is non-empty, automatically resume the
most recent kind:7374 by re-polling the mint and reopening the
receive dialog. Without this, a user who backgrounded the app
mid-mint would see no indication their pending invoice exists.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
bbc7f9740e |
Merge pull request #3065 from vitorpamplona/claude/gifted-ritchie-5XjZf
Exclude author from zap split display logic |
||
|
|
7d6bbac200 |
Merge pull request #3064 from vitorpamplona/claude/sweet-maxwell-UMahG
Add playback error overlay with browser fallback for video codec failures |
||
|
|
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. |
||
|
|
89607376cc |
feat(playback): surface unsupported codec errors with browser fallback
ExoPlayer entered the ERROR state silently when a codec was missing or the container/format wasn't supported, leaving a blank video area with no recourse. Track the player error in MediaControllerState, render an overlay with the error code, and offer an "Open in browser" button so the user can fall back to the system browser for codecs the device can't decode. |
||
|
|
d381cf9109 |
Merge pull request #3063 from davotoula/feat/avif-support
Comprehensive AVIF support (#837) |
||
|
|
ef25f8c0e6 |
test(amethyst): instrumented coverage for AVIF upload + decode
Adds 4 instrumented test files + 3 tiny pre-committed AVIF fixtures to catch regressions in the upload pipeline. |
||
|
|
f8b24c645a | fix(chat): hide DM quality slider for AVIF and correct error framing | ||
|
|
7d580452e4 | fix(uploads): surface specific AVIF metadata error instead of 'Upload cancelled' | ||
|
|
50a81c35cf |
Code review:
style(nests): import TimeUtils in CreateNestViewModel instead of inline FQN
HIGH-1: import java.io.RandomAccessFile in MetadataStripper instead of
inline fully-qualified name
HIGH-2: catch AvifMetadataNotVerifiableException in the 6 ViewModels
that call MetadataStripper.strip directly (profile picture, emoji pack
list+display, bookmark group, nest, channel)
MEDIUM-1: tighten AvifAnimatedDecoderFactory.createAnimatedImageDecoder
annotation from @RequiresApi(P) to @RequiresApi(S); the outer guard is
already SDK_INT < S.
MEDIUM-2: replace the curried lambda DI seam in MetadataStripper with
a named fun interface (AvifExifReader).
MEDIUM-3: rename isGifUrl -> isAnimatedMediaUrl (MyAsyncImage) and
BaseMediaContent.isGif() -> isAnimatedMedia() (ZoomableContentView)
since both predicates now cover AVIF as well as GIF.
- AvifAnimatedDecoderFactory.isAvif now iterates a single brand list
with .any { rangeEquals(8, it) } instead of three || branches.
- MetadataStripper.inspectAvifMetadata dropped the outer defensive
try/catch; the inner catch already converts parse failures to
AvifMetadataNotVerifiableException and the rest of the function
cannot realistically throw.
- PreviewMetadataCalculator extracts the shared ImageDecoder allocator
+ exception path from decodeAvifBytes and decodeAvifFromUri into a
single private decodeAvif(source) helper.
- RobohashFallbackAsyncImage merges its identical Loading and Error
when branches into one via Kotlin's multi-value branch syntax.
- MediaCompressorTest drops a no-op MockKAnnotations.init(this) call
and the now-unused import; no @MockK fields exist.
|
||
|
|
03c42f585e |
AVIF display + thumbnail-cache fixes from manual testing
fix(ui): default avatar contentScale to Crop, not Fit fix(images): skip thumbnail cache for animated AVIF profile pictures fix(ui): animate profile pictures regardless of URL extension |