From f6b3eb0552440c08faefea2301bc37d61b281aad Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 20:41:44 +0000 Subject: [PATCH] fix(richtext): don't markdown-detect cashu tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../amethyst/service/CachedRichTextParser.kt | 18 ++++- .../CachedRichTextParserMarkdownTest.kt | 65 +++++++++++++++++++ .../amethyst/CashuV4ParserTest.kt | 50 ++++++++++++++ 3 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/CachedRichTextParserMarkdownTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/CashuV4ParserTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt index 6ca953b4e4..80a4dfcfb5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt @@ -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 { diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/CachedRichTextParserMarkdownTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/CachedRichTextParserMarkdownTest.kt new file mode 100644 index 0000000000..8987d7342f --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/CachedRichTextParserMarkdownTest.kt @@ -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__")) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/CashuV4ParserTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/CashuV4ParserTest.kt new file mode 100644 index 0000000000..513e6b2025 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/CashuV4ParserTest.kt @@ -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") + } + } +}