fix(rich-text): render cashu preview in the no-preview path too

CashuPreview parses tokens locally via CachedCashuParser — no network,
no link unfurl, just CBOR decode of the cashuB string and a small
card. The canPreview = false gate is there to suppress network
previews (images, link unfurls, lightning invoice lookups); cashu
tokens have no such cost.

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

Also add CashuTrace logging around the entire zap path (sendNutzap,
swapToLocked, unblindAll, unblindOne, secretOutputsFor, Bdhke.unblind,
signer.sign + publish for nutzap/keep/delete/history events). The
JIT crash on Android 15+ keeps surfacing in different code paths
even after the DLEQ-on-our-outputs skip; the trace lets us see the
last frame before the SIGSEGV so we can target the right hot spot.
This commit is contained in:
Claude
2026-05-27 23:25:20 +00:00
parent ec44b001fc
commit 5d64d3829e
3 changed files with 48 additions and 3 deletions
@@ -617,17 +617,23 @@ class CashuWalletOps(
message: String,
available: List<TokenEntry>,
): 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,
@@ -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)
@@ -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<BlindOutput> {
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<CashuProof>(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