fix(richtext): don't markdown-detect cashu tokens

A cashuB token pasted into a chat rendered as raw base64 instead
of the redeem card, because the message was routed through the
markdown renderer rather than the rich-text renderer.

Root cause: computeIsMarkdown treats any content containing `__`
as markdown (bold). cashuB is base64url-encoded CBOR, and the
base64url alphabet uses `_`; a token whose CBOR ends with the
indefinite-length break marker (0xff) easily produces a trailing
run like `______`. The user's actual token did. The markdown
renderer has no CashuSegment support, so it just dumped the raw
text. The previous truncation patch was unrelated — the cutoff
guard already returned content.length for this 398-char token, so
truncation never fired.

Fix: when content contains `cashuA` or `cashuB` (case-insensitive),
short-circuit isMarkdown to false. The trade-off is that a chat
message mixing markdown formatting AND a cashu token will lose
the markdown rendering, but the cashu card showing up matters
more — and pure-markdown messages are unaffected.

Tests cover the exact user-reported token, the same token
embedded in a longer message, a synthetic cashuA, and a control
case proving plain `__bold__` is still classified as markdown.
This commit is contained in:
Claude
2026-05-28 20:41:44 +00:00
parent 3dbf247108
commit f6b3eb0552
3 changed files with 131 additions and 2 deletions
@@ -81,14 +81,28 @@ object CachedRichTextParser {
return result
}
private fun computeIsMarkdown(content: String): Boolean =
content.startsWith("> ") ||
private fun computeIsMarkdown(content: String): Boolean {
// Cashu v2/cashuB tokens are base64url payloads that routinely
// contain '__' (the base64url alphabet uses '_'; a token whose
// CBOR ends with a fixed-bytes break easily produces a tail
// like `______`). Without this exemption, the markdown detector
// tags any chat message carrying a cashuB token as markdown,
// the content is routed through RenderContentAsMarkdown, which
// has no knowledge of CashuSegment, and the user sees the raw
// base64 instead of the redeem card. cashuA (v3 JSON+base64url)
// shares the same alphabet and the same risk. Force the
// rich-text path whenever a cashu token is present so the
// inline CashuPreview renders.
if (content.contains("cashuA", true) || content.contains("cashuB", true)) return false
return content.startsWith("> ") ||
content.startsWith("# ") ||
content.contains("##") ||
content.contains("__") ||
content.contains("**") ||
content.contains("```") ||
content.contains("](")
}
}
object CachedUrlParser {
@@ -0,0 +1,65 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class CachedRichTextParserMarkdownTest {
private val cashuBToken =
"cashuBv2FteCJodHRwczovL21pbnQubWluaWJpdHMuY2FzaC9CaXRjb2luYXVjc2F0YWRkVEVTVGF0n79haUgAEHk32wzI" +
"ZWFwn79hYQJhc3hAZWM1YWI3Yjc1NjViYjBjZTZhNzg2NzBkMDA0OGExMjVlZGQzMjJhYmVjMTEzYWMwZTBmZGVkZmE3NTQ4Mzg3OWFj" +
"WCED0-ops8Ta6NjKChNJPe_jgIbXLlyxg2KSy2WaSTADo5D_v2FhCGFzeEBkNDZlODU5MDExNjU0NmNjZjAwNTE3ZTQ1NmU0MTY0N2Fm" +
"ZWUxOWNlMzY2N2IzYTcxODZkMzEwZDY1MjM3OTM4YWNYIQMTDGTY943O4ojhKopoYdemsUE2rSLfzwNBODL8WgOX0v______"
@Test
fun cashuBTokenWithTrailingUnderscoresIsNotMarkdown() {
// Bug repro: the user's token tail is `______`, which the
// markdown detector tagged as bold via the `contains("__")`
// check. RenderContentAsMarkdown has no CashuSegment support,
// so the chat bubble rendered the raw base64 instead of the
// redeem card. The exemption forces the rich-text path.
assertFalse(CachedRichTextParser.isMarkdown(cashuBToken))
}
@Test
fun cashuBTokenInLongerMessageIsNotMarkdown() {
val text = "Here, send to a friend: $cashuBToken"
assertFalse(CachedRichTextParser.isMarkdown(text))
}
@Test
fun cashuATokenIsNotMarkdown() {
// cashuA payloads share the base64url alphabet, so the same
// false-positive risk applies. Pre-empt it.
val fakeCashuA = "cashuAeyJ0b2tlbiI6W3sicHJvb2ZzIjpbXX1dfQ___"
assertFalse(CachedRichTextParser.isMarkdown(fakeCashuA))
}
@Test
fun plainBoldStillRecognizedAsMarkdown() {
// Make sure the exemption is targeted — content without a cashu
// prefix still triggers the markdown branch.
assertTrue(CachedRichTextParser.isMarkdown("hello __world__"))
}
}
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst
import com.vitorpamplona.amethyst.service.cashu.v4.V4Parser
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
class CashuV4ParserTest {
@Test
fun decodesMinibitsTestToken() {
val token =
"cashuBv2FteCJodHRwczovL21pbnQubWluaWJpdHMuY2FzaC9CaXRjb2luYXVjc2F0YWRkVEVTVGF0n79haUgAEHk32wzI" +
"ZWFwn79hYQJhc3hAZWM1YWI3Yjc1NjViYjBjZTZhNzg2NzBkMDA0OGExMjVlZGQzMjJhYmVjMTEzYWMwZTBmZGVkZmE3NTQ4Mzg3OWFj" +
"WCED0-ops8Ta6NjKChNJPe_jgIbXLlyxg2KSy2WaSTADo5D_v2FhCGFzeEBkNDZlODU5MDExNjU0NmNjZjAwNTE3ZTQ1NmU0MTY0N2Fm" +
"ZWUxOWNlMzY2N2IzYTcxODZkMzEwZDY1MjM3OTM4YWNYIQMTDGTY943O4ojhKopoYdemsUE2rSLfzwNBODL8WgOX0v______"
val outcome = V4Parser.parseCashuB(token)
when (outcome) {
is GenericLoadable.Loaded -> {
val list = outcome.loaded
assertTrue("Expected at least one CashuToken", list.isNotEmpty())
val first = list.first()
println("mint=${first.mint} totalAmount=${first.totalAmount} proofs=${first.proofs.size}")
}
is GenericLoadable.Error -> fail("V4Parser failed: ${outcome.errorMessage}")
else -> fail("V4Parser returned $outcome")
}
}
}