diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/CashuTokenB64Parser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/CashuTokenB64Parser.kt index 39620a82f2..99be94fc04 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/CashuTokenB64Parser.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/CashuTokenB64Parser.kt @@ -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? = 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? = 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(bytes) + val parsed = CashuV4Cbor.decodeFromByteArray(bytes) parsed.t?.map { group -> val keysetId = group.i.toHexKey() val proofs = diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/V4Encoder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/V4Encoder.kt index 94a25469e2..bb3ef864bd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/V4Encoder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/V4Encoder.kt @@ -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" } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/V4Models.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/V4Models.kt index e8f5733733..c4627fe6f5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/V4Models.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/V4Models.kt @@ -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( diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/CashuTokenB64ParserTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/CashuTokenB64ParserTest.kt index 83e3b53629..a40482bb2a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/CashuTokenB64ParserTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/CashuTokenB64ParserTest.kt @@ -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 }, + ) + } }