From 5d64d3829eb499e73423a36624b6cb89f8fca7a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 23:25:20 +0000 Subject: [PATCH] fix(rich-text): render cashu preview in the no-preview path too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CashuPreview parses tokens locally via CachedCashuParser — no network, no link unfurl, just CBOR decode of the cashuB string and a small card. The canPreview = false gate is there to suppress network previews (images, link unfurls, lightning invoice lookups); cashu tokens have no such cost. Suppressing it was the reason a cashuB pasted into a DM rendered as a wall of base64 even though the same token in the Redeem button worked fine. Flip the no-preview branch to render the same CashuPreview. Also add CashuTrace logging around the entire zap path (sendNutzap, swapToLocked, unblindAll, unblindOne, secretOutputsFor, Bdhke.unblind, signer.sign + publish for nutzap/keep/delete/history events). The JIT crash on Android 15+ keeps surfacing in different code paths even after the DLEQ-on-our-outputs skip; the trace lets us see the last frame before the SIGSEGV so we can target the right hot spot. --- .../model/nip60Cashu/CashuWalletOps.kt | 20 ++++++++++++++++- .../amethyst/ui/components/RichTextViewer.kt | 9 +++++++- .../nip60Cashu/mintApi/CashuMintOperations.kt | 22 ++++++++++++++++++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt index 07087d008e..4187a503ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt @@ -617,17 +617,23 @@ class CashuWalletOps( message: String, available: List, ): NutzapSent { + Log.i("CashuTrace") { "sendNutzap enter: amount=$amountSats mint=$mintUrl" } if (amountSats <= 0) throw IllegalArgumentException("Amount must be positive") + Log.i("CashuTrace") { "sendNutzap: seedWarmer begin" } seedWarmer() + Log.i("CashuTrace") { "sendNutzap: seedWarmer end" } val (selected, totalSelected) = selectProofsCovering(available, amountSats) if (totalSelected < amountSats) throw IllegalStateException("Insufficient balance for $mintUrl") + Log.i("CashuTrace") { "sendNutzap: selected=${selected.size} entries, total=$totalSelected sat" } + Log.i("CashuTrace") { "sendNutzap: swapToLocked begin (${selected.flatMap { it.content.proofs }.size} input proofs)" } val swap = ops(mintUrl).swapToLocked( proofs = selected.flatMap { it.content.proofs }, recipientP2pkPubkeyHex = recipientP2pkPubkeyHex, targetSplit = amountSats, ) + Log.i("CashuTrace") { "sendNutzap: swapToLocked end (send=${swap.send.size}, keep=${swap.keep.size})" } // Build the kind:9321 first so we have its id to reference from history. val proofJsons = swap.send.map { nutzapProofJson.encodeToString(NutzapProofJson.serializer(), it.toNutzapJson()) } @@ -640,15 +646,20 @@ class CashuWalletOps( zappedEvent = zappedEvent, recipientPubKey = recipientPubKey, ) + Log.i("CashuTrace") { "sendNutzap: signing nutzapEvent" } val nutzapEvent = signer.sign(nutzapTemplate) + Log.i("CashuTrace") { "sendNutzap: publishing nutzapEvent id=${nutzapEvent.id.take(8)}" } publish(nutzapEvent) + Log.i("CashuTrace") { "sendNutzap: nutzapEvent published" } // Roll over change locally if any. val keepEvent = if (swap.keep.isNotEmpty()) { val content = TokenContent(mint = mintUrl, proofs = swap.keep, del = selected.map { it.event.id }) + Log.i("CashuTrace") { "sendNutzap: building+signing keep kind:7375 (${swap.keep.size} proofs, NIP-44 encrypt)" } val template = CashuTokenEvent.build(content, signer) val signed = signer.sign(template) + Log.i("CashuTrace") { "sendNutzap: publishing keep event id=${signed.id.take(8)}" } publish(signed) signed } else { @@ -656,10 +667,14 @@ class CashuWalletOps( } // NIP-09 delete the source token events. + Log.i("CashuTrace") { "sendNutzap: signing delete event" } val deleteEvent = run { val template = DeletionEvent.build(selected.map { it.event }) - signer.sign(template).also { publish(it) } + signer.sign(template).also { + Log.i("CashuTrace") { "sendNutzap: publishing delete event id=${it.id.take(8)}" } + publish(it) + } } val historyTemplate = @@ -673,8 +688,11 @@ class CashuWalletOps( }, signer = signer, ) + Log.i("CashuTrace") { "sendNutzap: signing history (NIP-44 encrypt)" } val historyEvent = signer.sign(historyTemplate) + Log.i("CashuTrace") { "sendNutzap: publishing history id=${historyEvent.id.take(8)}" } publish(historyEvent) + Log.i("CashuTrace") { "sendNutzap: done — nutzap=${nutzapEvent.id.take(8)}" } return NutzapSent( nutzapEvent = nutzapEvent, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 45db5967bb..67dfa2d457 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -492,7 +492,14 @@ private fun RenderWordWithoutPreview( // Don't offer to withdraw is WithdrawSegment -> Text(word.segmentText) - is CashuSegment -> Text(word.segmentText) + // Cashu parsing is purely local + cached (CachedCashuParser), + // so the preview is safe to render even in the no-preview path + // — `canPreview = false` is meant to suppress network previews + // (images, link unfurls, lightning invoice lookups), not local + // decoding. Suppressing it here was the reason cashuB tokens + // pasted into DMs and other no-preview contexts only showed + // as a wall of base64. + is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) is EmailSegment -> ClickableEmail(word.segmentText) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt index 91874be7e6..7bb379092b 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip60Cashu.p2pk.P2PK import com.vitorpamplona.quartz.nip60Cashu.seed.CashuDeterministic import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof import com.vitorpamplona.quartz.nip60Cashu.token.TokenContent +import com.vitorpamplona.quartz.utils.Log /** * High-level mint operations: mint-from-LN, swap, melt-to-LN, send-as-token, @@ -222,12 +223,15 @@ class CashuMintOperations( recipientP2pkPubkeyHex: String, targetSplit: Long, ): SwapResult { + Log.i("CashuTrace") { "swapToLocked enter: inputs=${proofs.size}, target=$targetSplit" } if (proofs.isEmpty()) throw IllegalArgumentException("Nothing to swap") if (targetSplit <= 0) throw IllegalArgumentException("Target split must be > 0") val total = proofs.sumOf { it.amount } if (targetSplit > total) throw IllegalArgumentException("Target split exceeds available proofs") + Log.i("CashuTrace") { "swapToLocked: fetchKeyset begin" } val keyset = fetchKeyset() + Log.i("CashuTrace") { "swapToLocked: fetchKeyset end id=${keyset.id}" } // NUT-02: reserve per-input fees; change shrinks by the fee, send // amount stays whole (the recipient gets exactly targetSplit sats). val feeAtoms = computeInputFee(proofs.size, keyset.inputFeePpk) @@ -239,10 +243,13 @@ class CashuMintOperations( // proof, so NUT-13 recovery doesn't apply to those bytes), but // our change outputs go through the deterministic factory in one // batch — see [secretOutputsFor]. + Log.i("CashuTrace") { "swapToLocked: building ${splitAmounts(targetSplit).size} locked outputs (P2PK blinds)" } val sendOutputs = splitAmounts(targetSplit).map { lockedOutputFor(it, keyset, recipientP2pkPubkeyHex) } + Log.i("CashuTrace") { "swapToLocked: building ${if (keepAmount > 0L) splitAmounts(keepAmount).size else 0} keep outputs (NUT-13 deterministic blinds)" } val keepOutputs = if (keepAmount > 0L) secretOutputsFor(splitAmounts(keepAmount), keyset) else emptyList() val allOutputs = sendOutputs + keepOutputs + Log.i("CashuTrace") { "swapToLocked: POST /v1/swap with ${allOutputs.size} outputs" } val response = client.swap( @@ -251,6 +258,7 @@ class CashuMintOperations( outputs = allOutputs.map { it.toDto() }, ), ) + Log.i("CashuTrace") { "swapToLocked: swap response sigs=${response.signatures.size}" } if (response.signatures.size != allOutputs.size) { throw MintProtocolException( @@ -258,7 +266,9 @@ class CashuMintOperations( ) } + Log.i("CashuTrace") { "swapToLocked: unblindAll begin (${allOutputs.size})" } val unblinded = unblindAll(allOutputs, response.signatures, keyset) + Log.i("CashuTrace") { "swapToLocked: unblindAll end" } val sendProofs = unblinded.subList(0, sendOutputs.size) val keepProofs = unblinded.subList(sendOutputs.size, unblinded.size) return SwapResult(send = sendProofs, keep = keepProofs, keysetId = keyset.id) @@ -596,12 +606,14 @@ class CashuMintOperations( keyset: KeysetDto, ): List { if (amounts.isEmpty()) return emptyList() + Log.i("CashuTrace") { "secretOutputsFor: nextSecrets(${amounts.size})" } // NUT-00: secret is a UTF-8 hex string of 32 secret bytes. // [secretFactory] decides whether those bytes are pure-random or // NUT-13-derived from a wallet seed; either way the on-wire shape // is identical so the mint can't tell which scheme we're using. val derived = secretFactory.nextSecrets(keyset.id, amounts.size) return amounts.mapIndexed { i, amount -> + Log.i("CashuTrace") { " Bdhke.blind[$i/${amounts.size}] amount=$amount" } val pair = derived[i] val bTick = Bdhke.blind(pair.secretHex.encodeToByteArray(), pair.blindingFactor) BlindOutput(amount, keyset.id, pair.blindingFactor, pair.secretHex, bTick) @@ -634,7 +646,13 @@ class CashuMintOperations( "Got ${signatures.size} signatures for ${outputs.size} outputs", ) } - return outputs.mapIndexed { i, o -> unblindOne(o, signatures[i], keyset) } + val out = ArrayList(outputs.size) + for (i in outputs.indices) { + Log.i("CashuTrace") { "unblindOne[$i/${outputs.size}] begin amount=${outputs[i].amount}" } + out += unblindOne(outputs[i], signatures[i], keyset) + Log.i("CashuTrace") { "unblindOne[$i/${outputs.size}] end" } + } + return out } private fun unblindOne( @@ -668,12 +686,14 @@ class CashuMintOperations( // continue to be Carol-verified via [verifyTokenDleq] — that's // where the untrust boundary actually lives. + Log.i("CashuTrace") { " unblindOne: Bdhke.unblind begin" } val c = Bdhke.unblind( blindSignature = cTickBytes, r = output.r, mintPubKey = mintPubKey, ) + Log.i("CashuTrace") { " unblindOne: Bdhke.unblind end" } // Retain (e, s, r) on the resulting proof per NUT-12 §3 so // anything that later forwards this proof to another wallet — // a cashuB token, a kind:9321 nutzap — gives the recipient the