mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
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:
+19
-1
@@ -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)
|
||||
|
||||
|
||||
+21
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user