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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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.
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.
Address bugs and gaps surfaced by an audit of the prior 14 commits.
JVM tests passed because of typealias / platform-type lenience that
won't hold on Native; these are real iOS compile / behavior issues.
BUG fixes (iOS compile failures):
- commons/.../Note.kt:899 — Iterable.sumOf { -> BigDecimal } is a
JVM-stdlib-only overload. Common stdlib ships sumOf only for
Int/Long/Double/Float/UInt/ULong. Replaced with fold(BigDecimal(0)).
- commons/.../Note.kt:889 — BigDecimal(it.event?.content): the quartz
expect-class constructor takes String non-null; JVM accepted nullable
via platform-type lenience and threw NPE caught downstream. Switched
to ?.let { content -> BigDecimal(content) }.
- commons/.../Note.kt:838 — `catch (e: java.lang.Exception)` -> `Exception`.
- commons/.../feeds/custom/FeedDefinitionBuilder.kt + FeedBuilderState.kt:
inline FQN `java.util.UUID.randomUUID().toString()` -> kotlin.uuid.Uuid.
random().toString() (Kotlin 2.0+, @OptIn ExperimentalUuidApi).
inline `System.currentTimeMillis() / 1000` -> TimeUtils.now() (already
used elsewhere in the codebase).
- commons/.../viewmodels/NestViewModelTest.kt: moved from commonTest to
jvmTest. The test imports NestViewModel + nestsclient, both of which
the prior PR moved to jvmAndroid. commonTest depends on commonMain
only, so the test would fail to compile for iosSimulatorArm64Test.
SUBTLE fixes:
- commons/.../UserRelaysCache.kt: the flow field used double-checked
locking on a non-volatile var. JMM hazard on Native (ARM weak memory
model) — outer fast-path could observe a partially-published
WeakReference. Added @kotlin.concurrent.Volatile.
- commons/.../util/UrlValidation.ios.kt: NSURL.URLWithString("http:")
returns non-null with scheme="http" and no host; JVM's URI.toURL()
rejects with MalformedURLException. Reject scheme-only network URLs
(http/https/ws/wss/ftp without a host) to match JVM behavior.
- commons/.../util/KmpLock.kt commonMain doc: corrected "NSLock" ->
"NSRecursiveLock" to match the actual iOS implementation.
verifyKmpPurity gate extended (commons + quartz):
- Adds patterns: System.currentTimeMillis, Thread.sleep, java.util.UUID,
kotlin.jvm.Synchronized, kotlin.jvm.Volatile.
- Each pattern paired with a hint pointing at the canonical KMP
replacement; the error message surfaces both.
- Skips lines that start with //, *, or /* to avoid false positives on
KDoc / migration notes.
Three changes that bring commons/commonMain to zero java.* imports
(down from 18 at the start of Phase 2).
- EventListMatchingFilter, NoteListMatchingFilter: moved to jvmAndroid.
Both use ConcurrentSkipListSet + SortedSet for ordered concurrent
iteration, and their only consumer is LocalCache in the Android app.
iOS-time we can revisit if a KMP ordered concurrent set is needed.
- Note.kt's BigDecimal: switch import from java.math.BigDecimal to
quartz's existing expect/actual com.vitorpamplona.quartz.utils.BigDecimal.
BigDecimal.ZERO -> BigDecimal(0); BigDecimal.valueOf(longVal) ->
BigDecimal(longVal) (the expect class already has the Long
constructor). NoteOnchainZapTest gets the same treatment.
- Adds two top-level extensions in quartz commonMain (separate
BigDecimalOps.kt file to avoid the duplicate-JVM-classname collision
with the existing BigDecimal.kt actuals):
operator fun BigDecimal.plus(other: BigDecimal)
operator fun BigDecimal.minus(other: BigDecimal)
Lets += / + / - continue to work on commonMain BigDecimal values.
Commons/commonMain is now structurally iOS-ready as far as the
java.* import audit can tell. Remaining iOS work: actually flip on
the iOS targets, see what UI / dep transitives break, and address.
Phase 1 of the iOS support plan (amethyst/plans/2026-05-24-ios-support.md).
Two independent guards so JVM-only imports can't silently appear in
quartz's iOS-bound source sets:
- :quartz:verifyKmpPurity (Linux, ~1s): scans commonMain + apple/native
source sets for com.fasterxml.jackson / okhttp3 references and fails
the build with a clear pointer to the offending file:line. Wired into
the existing lint job so it runs on every PR.
- test-quartz-ios (macos-latest): runs :quartz:iosSimulatorArm64Test on
the simulator (NIP-04, NIP-17, NIP-19, NIP-49 vectors + AES-GCM and
chatroom-key tests already in quartz/src/iosTest) and additionally
compileTestKotlinIosArm64 to catch device-variant compile drift.
Wire NIP-82 Software Applications (kind 32267), Releases (kind 30063)
and Assets (kind 3063) into the Quartz event model and surface them
through a dedicated rendering path in Amethyst.
Quartz: extend the existing experimental NIP-82 builders with topic
(`t`) and NIP-34 app-link (`a`) helpers, and add a small detector
(`isNip82SoftwareRelease`/`asSoftwareRelease`) so kind 30063 events
can be disambiguated from NIP-51 ReleaseArtifactSetEvent at the
renderer layer. Pin behavior with unit tests covering build paths,
disambiguation, and the real-world Amethyst NIP-82 description event.
Amethyst: add modern card visualizations for each kind — application
header with icon/screenshots/platforms/topics/links, release header
with channel pill and bundled-asset count, and asset row with MIME,
size and platforms — and dispatch to them from both `NoteCompose`
and `NoteMaster` (`ThreadFeedView`).
A new "Apps" feed (left nav drawer) mirrors the Picture Feeds shape:
`SoftwareAppsFeedFilter` reads kind 32267 from `LocalCache`, a
`PerUserEoseManager`-backed subscription pulls applications and
releases from outbox relays, and a dedicated screen renders them in
a `LazyColumn` of `RenderSoftwareApplication` cards.
Audit findings from an independent code review:
- HIGH: When the user zaps their own post (a common flow), every split
that included the post author put the sender on the recipient list,
and OnchainZapBuilder.buildSplit refused the whole tx with "cannot
zap yourself". Fix: new OnchainZapSplitter.prepare() filters the
sender's pubkey out of the splits before they reach the builder.
- HIGH: NIP-57 lets the same pubkey appear in zap-split tags more than
once (additive weights). buildSplit rejected duplicate recipients.
Same prepare() helper merges duplicates by summing weights, in
first-seen order.
- HIGH: The dialog's live preview only showed amounts for recipients
whose share was BELOW dust (because DustRecipientException only
carries belowDust). Fix: parent composable computes shares with a
zero dust threshold for the preview, gating the Send button on a
separate belowDustShares check so the user can see all amounts and
can't tap Send into a guaranteed BUILDING-stage failure.
- MEDIUM: OnchainZapSendResult.Failure didn't carry the ids of
receipts that successfully published before a partial-publish
failure. Added publishedReceiptEventIds: List<HexKey>.
- LOW: useSplits state was keyed by zappedEvent reference; re-emitted
bundles would silently reset the toggle. Now keyed on the event id.
Tests added:
- splitter: prepare() drops sender, merges duplicates, filters
non-positive weights; floating-point weights (0.1 + 0.2) sum exactly
- builder: buildSplit produces N recipient outputs + 1 change at index
N, conserves sats, rejects duplicates and below-dust shares
- sender: sendSplit publishes one receipt per recipient sharing the
txid with correct per-recipient amount; partial-publish failure
carries the broadcast txid and the ids of receipts that did publish
Extends NIP-BC onchain zaps to honor a note's NIP-57 zap-split tags: one
Bitcoin transaction pays every pubkey-based recipient atomically, and
one kind:8333 receipt is published per recipient (each receipt carries
the recipient's pubkey + sat share and shares the same i:<txid>).
quartz / OnchainZapBuilder
- new buildSplit(recipients = listOf(pubkey to sats), ...) produces a
PSBT with one output per recipient + optional change output
- existing build(...) now delegates to buildSplit; coin selection and
change-vs-dust logic are unchanged for the single-recipient path
commons / new OnchainZapSplitter
- distribute(totalSats, splits, dustThreshold) does the weighted
integer-math allocation, dropping the rounding remainder onto the
largest-weight recipient first so the per-recipient sats sum exactly
to totalSats
- throws DustRecipientException if any share lands below dust; the
caller surfaces that as a build-stage failure before the tx is built
- unit tests cover equal weights, fractional weights, remainder
distribution, dust rejection, and input-order preservation
commons / OnchainZapSender.sendSplit
- mirrors send() but takes the precomputed shares, builds via
buildSplit, and publishes N receipts using the same txid; if one
receipt publish fails the broadcast txid + already-published receipt
ids are surfaced in the Failure result
amethyst / Account.sendOnchainZapWithSplits
- thin wrapper that hands off to OnchainZapSender.sendSplit using the
signer's pubkey
amethyst / OnchainZapSendDialog
- detects pubkey-based zap splits on the zappedEvent and, when present,
defaults to split mode: a SplitsRecipientSection renders one row per
recipient with weight % and live per-recipient sats preview
- lnAddress-only splits are filtered out (no pubkey -> no Taproot
address); a short note tells the user how many recipients were
skipped
- the send button label switches to "Send X sats, N ways"; an opt-out
button lets the user fall back to single-recipient mode
- on send: shares are recomputed via OnchainZapSplitter; below-dust
configurations surface as a BUILDING-stage failure before signing
nostr-protocol/nips#2332 adds optional ["block", …] and ["proof", …] tags
on kind:8333 that ship the SPV proof inline. The data-model side is already
shipped in Quartz (BlockTag, ProofTag, OnchainZapEvent.block()/.proof() and
the TagArray helpers), but no production code path produces or consumes
either tag yet.
amethyst/plans/2026-05-14-onchain-zaps.md — onchain zaps plan:
- Update the "Chain backend" decision bullet to flag the spec change and
the two production gaps (send-side emit, receive-side consume).
- Add a dedicated "Inline SPV proofs" section covering: spec status and
the merkle-proof encoding ambiguity flagged back to the PR; per-layer
status matrix (shipped vs gap, with file locations); send-side
two-publish design (Design B — keep instant receipt, add post-confirm
republish with block+proof; dedupe by (txid, target)); receive-side
fast-path with fall-through on proof failure (never hard-reject);
phased delivery G.1–G.7 with effort estimates (~3–4 d after S1 ships
and spec encoding lands).
- Add the two new pending items to the existing "What's still pending"
list to keep that section authoritative.
quartz/plans/2026-05-08-local-headers-explorer.md — headers-explorer plan:
- Rewrite §19 (follow-up onchain-zap verification) to reflect the spec
change. Original section assumed we'd need BIP-37 merkleblock or
full-block fetch over P2P; with the proof inline none of that
infrastructure is required. Estimate collapses from 10–15 d to ~3–4 d.
- Point §19 at the full implementation plan in the onchain-zaps file,
keeping S1 focused on OTS while the follow-up details live with the
rest of the NIP-BC work.
Every consensus-relevant layer of the planned headers explorer (BlockHeader80
parser, DifficultyTarget compact↔target, CalculateNextWorkRequired retarget,
MedianTimePast, header validator end-to-end, P2P wire codecs, reorg/chain
selection, OTS proofs) is pinned to upstream test vectors committed under
quartz/src/commonTest/resources/bitcoin/, matching the existing
nip44.vectors.json / bip39.vectors.json / mls/*.json pattern.
The single highest-value test is a nightly differential check asserting
LocalHeadersBitcoinExplorer.blockHash(h) == OkHttpBitcoinExplorer.blockHash(h)
for every height in [checkpoint, tip] — any consensus drift surfaces as a
disagreeing height.
Maps cleanly onto the existing Phase 1/3/4/5/7/9 work, adding ~5–7
engineer-days total to the plan budget. No new top-level phase needed.
Lock the design choices from the 2026-05-19 review against the intervening
2026-05-14 onchain-zaps work:
- Move the module from quartz/.../nip03Timestamp/bitcoin/ to a sibling
quartz/.../bitcoin/ package so the headers explorer, header validator,
peer pool and store can be reused by the future onchain-zap merkle-proof
work without an inverted import path.
- Use androidx.sqlite + BundledSQLiteDriver for HeaderStore, matching the
existing SQLiteEventStore. Schema lives in commonMain via IModule. Drops
the hand-rolled flat-file + sidecar height index.
- Drop the bundled headers blob. Ship a single hardcoded PinnedCheckpoint
constant; first-run sync starts from the checkpoint and pulls forward
over P2P. APK growth: 0 bytes.
- Pre-checkpoint OTS heights fall through to OkHttpBitcoinExplorer via
BitcoinExplorerEndpoint (shared with the onchain-zap EsploraBackend).
Strict-mode users get an explicit error instead of a network call.
- Mark trustless NIP-BC onchain-zap verification as out of scope and
capture it as a follow-up plan (BIP-37 merkleblock or full-block fetch
on top of this stack).
Resolves open questions Q1, Q2 and Q4 from the original plan; Q3 (Quartz
public API vs internal) left open for Phase 0.
Receipts were only being checked for a valid event signature — anyone could
sign a kind:9735 and have it counted toward another user's zap totals. NIP-57
Appendix F mandates three additional checks: receipt.pubkey == LNURL
provider's nostrPubkey (MUST), bolt11 invoice amount == zap request "amount"
tag (MUST), and lnurl tag == recipient's lnurl (SHOULD).
- Adds LnZapReceiptValidator + LnurlForm in quartz commonMain (pure logic).
- Adds LnurlEndpointCache (jvmAndroid) and the LnurlEndpointResolver
interface for async lookup. The cache is primed by outbound zaps (existing
LightningAddressResolver fetches now extract nostrPubkey) and on demand for
inbound receipts when no entry is present.
- Adds OkHttpLnurlEndpointResolver in commons, wired into LocalCache via
AppModules using the existing money-tier OkHttp builder (so Tor settings
apply).
- LocalCache.consume(LnZapEvent) now: drops receipts that fail MUST checks
synchronously when the cache is warm, defers credit until async resolution
finishes on cache miss, and falls back to legacy signature-only behavior
when no resolver is wired (tests).
- LnZapRequestEvent.create() now accepts amountMillisats + lnurl; both are
threaded through Account.createZapRequestFor and emitted as tags so future
receipts can be validated against them.
21 new tests cover validator reasons, lnurl form canonicalization across
lud16/URL/bech32, and cache eviction.
Without busy_timeout SQLite returns SQLITE_BUSY immediately when
BEGIN IMMEDIATE can't acquire a lock — e.g. during a WAL
auto-checkpoint or a reader briefly upgrading its snapshot — instead
of retrying. ParallelInsertTest's reader+writer test hit this ~10%
of runs. 5s matches Room's default and adds no overhead in the
uncontended case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#2946 fixed the ClassCastException with a bespoke AtomicReference<Map>
+ CAS copy-on-write helper. Quartz already has a concurrent-map
abstraction for exactly this purpose — LargeCache — with platform-tuned
actuals (ConcurrentSkipListMap on jvmAndroid, CacheMap on Apple, custom
on Linux). Swap to it.
Removes the bespoke putAuthStatus/removeAuthStatus helpers, the
ExperimentalAtomicApi opt-in, and the AtomicReference imports.
The RelayAuthenticatorConcurrencyTest from #2946 still passes against
the new implementation.
OkHttp dispatches WebSocket callbacks on one thread per relay socket,
so RelayAuthenticator's plain LinkedHashMap was mutated concurrently
from many threads during connection storms. When a bucket crossed
HashMap's TREEIFY_THRESHOLD the racing treeify corrupted internal
state and threw ClassCastException: LinkedHashMap$Entry cannot be
cast to HashMap$TreeNode from onDisconnected.
Primal-style clients emit "dim 317.0x498.0" in NIP-92 imeta tags. DimensionTag.parse
called Int.parseInt on each component, threw NumberFormatException, and returned
null. With dim==null and no cached aspect ratio, GifVideoView / UrlImageView built
the container without an aspectRatio modifier, so the inline image collapsed to
zero height and the post body looked empty until Coil delivered the bitmap.
Parse each component as Double then truncate to Int. Adds DimensionTagTest covering
integer, float, truncation, 0x0 and malformed inputs.
https://claude.ai/code/session_01W1crao6Hwip8k5ByoLxVrc
Adds a third public Namecoin ElectrumX server to the default and
Tor-preferred lists in DEFAULT_ELECTRUMX_SERVERS / TOR_ELECTRUMX_SERVERS:
electrum.nmc.ethicnology.com:50002 (IPv4 142.44.246.181, OVH Canada)
Operated by @ethicnology, who ships the namecoind + ElectrumX + mempool
podman stack at github.com/ethicnology/namecoin-compose. Probed live:
- server.version -> ElectrumX 1.19.0, protocol 1.4
- server.features -> Namecoin mainnet genesis 000000000062b72c...c770
- scripthash.get_history for d/testls -> full history (heights up to
822885), and blockchain.transaction.get decodes the OP_NAME_UPDATE
output correctly. Same code path used by ElectrumXClient against all
other public servers, no client changes required.
TLS uses a publicly-trusted Let's Encrypt cert, so usePinnedTrustStore
is left at the default (false). This makes it the first entry in the
list whose TLS does NOT depend on PINNED_ELECTRUMX_CERTS, and adds
useful diversity:
- electrumx.testls.space (self-signed, pinned, often ECONNRESETs)
- nmc2.bitcoins.sk / 46.229.238.187 (self-signed, pinned)
- relay.testls.bit / 23.158.233.10 (self-signed, pinned)
- electrum.nmc.ethicnology.com (LE cert, system trust store)
If every self-signed peer is unreachable (e.g. corporate networks that
strip unknown CAs but allow LE chains), resolution can still succeed.
No bare-IP companion entry is added for 142.44.246.181: unlike the
46.229.238.187 / 23.158.233.10 pinned peers (which use DER-SHA256
pinning that ignores hostname verification), an IP-literal endpoint
against the LE cert would fail standard hostname verification under
the system trust manager (SAN covers only the hostname). The IP is
captured in this commit message and the source comment for reference.
Verification on this branch:
- :quartz:spotlessCheck OK
- :quartz:jvmTest OK (BitRelayResolverTest etc. unchanged)
Tapping the Bitcoin card on the wallet screen now navigates to a new
OnchainTransactionsScreen that lists transactions touching the account's
Taproot address, mirroring the NWC transactions view.
- OnchainBackend gains getTxsForAddress(address, afterTxid) returning
BitcoinAddressTx rows (netValueSats, confirmations, blockHeight,
blockTime, counterparty addresses). EsploraBackend implements it via
GET /address/{addr}/txs and /address/{addr}/txs/chain/{last_seen}
for pagination; CachingOnchainBackend passes through.
- OnchainTransactionsViewModel loads the address from the account
signer + LocalCache.onchainBackend, paginates, and for each chain
row scans LocalCache for an OnchainZapEvent with a matching txid so
the UI can render the Nostr counterparty (sender pubkey for
incoming, p-tagged recipient for outgoing).
- ALL / ZAPS / NON-ZAPS filter chips reuse the existing
TransactionFilter enum. Mempool rows are flagged "Pending" in
bitcoin-orange.