fix(cashu): consistent prefix casing + reuse one CBOR codec

Audit follow-ups on the cashu token codec move:

- CashuTokenB64Parser.parse() dispatches on the prefix case-insensitively
  (matching commons RichTextParser's case-insensitive cashuA/cashuB
  detection), but parseCashuA/parseCashuB stripped it with a case-sensitive
  removePrefix — so a mixed-case prefix passed dispatch and then fed its
  own prefix bytes into the Base64 decoder, failing to parse. Strip the
  fixed 6-char prefix with drop() so dispatch and stripping agree.
- hoist a single shared CashuV4Cbor instance instead of allocating a new
  Cbor on every encode (V4Encoder) and every cashuB parse.

Adds an acceptsMixedCasePrefix regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UMNKix4qEfiAPP9s2a4gB
This commit is contained in:
Claude
2026-06-19 15:47:09 +00:00
parent c5b1d0051a
commit 3515afa34c
4 changed files with 29 additions and 10 deletions
@@ -23,7 +23,6 @@ package com.vitorpamplona.quartz.nip60Cashu.token
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.json.Json
import kotlin.coroutines.cancellation.CancellationException
@@ -43,6 +42,9 @@ import kotlin.io.encoding.ExperimentalEncodingApi
* (one [CashuToken] per mint/keyset group).
*/
object CashuTokenB64Parser {
/** Both "cashuA" and "cashuB" prefixes are 6 chars. */
private const val PREFIX_LENGTH = 6
private val json =
Json {
ignoreUnknownKeys = true
@@ -59,7 +61,9 @@ object CashuTokenB64Parser {
@OptIn(ExperimentalEncodingApi::class)
fun parseCashuA(token: String): List<CashuToken>? =
try {
val payload = token.removePrefix("cashuA")
// drop() rather than removePrefix() so the case-insensitive
// dispatch above stays consistent with prefix stripping.
val payload = token.drop(PREFIX_LENGTH)
val decoded =
Base64.Default
.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL)
@@ -88,12 +92,12 @@ object CashuTokenB64Parser {
@OptIn(ExperimentalEncodingApi::class, ExperimentalSerializationApi::class)
fun parseCashuB(token: String): List<CashuToken>? =
try {
val payload = token.removePrefix("cashuB")
val payload = token.drop(PREFIX_LENGTH)
val bytes =
Base64.UrlSafe
.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL)
.decode(payload)
val parsed = Cbor { ignoreUnknownKeys = true }.decodeFromByteArray<V4Token>(bytes)
val parsed = CashuV4Cbor.decodeFromByteArray<V4Token>(bytes)
parsed.t?.map { group ->
val keysetId = group.i.toHexKey()
val proofs =
@@ -22,7 +22,6 @@ package com.vitorpamplona.quartz.nip60Cashu.token
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.encodeToByteArray
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -72,11 +71,7 @@ object V4Encoder {
t = groups.toTypedArray(),
)
val cbor =
Cbor {
ignoreUnknownKeys = true
}
val bytes = cbor.encodeToByteArray(token)
val bytes = CashuV4Cbor.encodeToByteArray(token)
val base64 = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(bytes)
return "cashuB$base64"
}
@@ -23,6 +23,15 @@ package com.vitorpamplona.quartz.nip60Cashu.token
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.ByteString
import kotlinx.serialization.cbor.Cbor
/**
* Shared CBOR codec for the NUT-00 v4 (`cashuB`) wire format. Stateless and
* thread-safe, so a single instance is reused by [V4Encoder] and
* [CashuTokenB64Parser] instead of allocating one per call.
*/
@OptIn(ExperimentalSerializationApi::class)
internal val CashuV4Cbor: Cbor = Cbor { ignoreUnknownKeys = true }
@Serializable
class V4Token(
@@ -125,4 +125,15 @@ class CashuTokenB64ParserTest {
assertTrue(CashuTokenB64Parser.parse(cashuTokenA)!!.isNotEmpty())
assertTrue(CashuTokenB64Parser.parse(cashuTokenB1)!!.isNotEmpty())
}
@Test
fun acceptsMixedCasePrefix() {
// parse() dispatches case-insensitively, so prefix stripping must too —
// an upper-cased prefix should still decode the same payload.
val upper = "CASHUB" + cashuTokenB1.drop(6)
assertEquals(
CashuTokenB64Parser.parse(cashuTokenB1)!!.flatMap { it.proofs }.map { it.secret },
CashuTokenB64Parser.parse(upper)!!.flatMap { it.proofs }.map { it.secret },
)
}
}