diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt index c29880f46d..8b1b677264 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt @@ -844,26 +844,27 @@ class CashuWalletViewModel : ViewModel() { return } - val parsedToken = - CashuTokenB64Parser.parse(trimmed)?.firstOrNull() + // A single token string can carry proofs from more than one mint; + // redeem every group rather than just the first. + val parsedTokens = + CashuTokenB64Parser.parse(trimmed)?.takeIf { it.isNotEmpty() } ?: run { _redeemState.value = CashuRedeemFlowState.Error("Could not parse token") return } - val mintUrl = parsedToken.mint - val proofs = parsedToken.proofs - if (mintUrl !in mints.value) { + val unknownMint = parsedTokens.firstOrNull { it.mint !in mints.value }?.mint + if (unknownMint != null) { _redeemState.value = - CashuRedeemFlowState.Error("Token mint ($mintUrl) is not in your wallet. Add it first.") + CashuRedeemFlowState.Error("Token mint ($unknownMint) is not in your wallet. Add it first.") return } _redeemState.value = CashuRedeemFlowState.Redeeming vm.launchSigner { try { - val result = ops.redeemToken(trimmed, proofs, mintUrl) - _redeemState.value = CashuRedeemFlowState.Completed(result.amount) + val total = parsedTokens.sumOf { ops.redeemToken(trimmed, it.proofs, it.mint).amount } + _redeemState.value = CashuRedeemFlowState.Completed(total) } catch (e: Exception) { _redeemState.value = CashuRedeemFlowState.Error(describeMintError(e)) } 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 99be94fc04..2889b4aaeb 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 @@ -64,11 +64,7 @@ object CashuTokenB64Parser { // 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) - .decode(payload) - .decodeToString() + val decoded = decodeStandardOrUrlSafe(payload).decodeToString() val parsed = json.decodeFromString(V3TokenJson.serializer(), decoded) parsed.token?.map { entry -> val proofs = @@ -117,6 +113,21 @@ object CashuTokenB64Parser { if (e is CancellationException) throw e null } + + /** + * NUT-00 v3 specifies base64-urlsafe, but historical encoders (and older + * Amethyst builds) emitted standard base64. Standard and url-safe only + * differ in two characters, so an all-alphanumeric payload decodes the + * same under either; try standard first and fall back to url-safe so both + * encodings round-trip. + */ + @OptIn(ExperimentalEncodingApi::class) + private fun decodeStandardOrUrlSafe(payload: String): ByteArray = + try { + Base64.Default.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(payload) + } catch (_: IllegalArgumentException) { + Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(payload) + } } /** NUT-00 v3 (`cashuA`) JSON envelope. Proofs reuse [CashuProofJson] (`C` → c). */ 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 a40482bb2a..fa838255bd 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 @@ -20,6 +20,8 @@ */ package com.vitorpamplona.quartz.nip60Cashu.token +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -126,6 +128,29 @@ class CashuTokenB64ParserTest { assertTrue(CashuTokenB64Parser.parse(cashuTokenB1)!!.isNotEmpty()) } + @OptIn(ExperimentalEncodingApi::class) + @Test + fun parsesBothBase64Alphabets() { + // NUT-00 v3 specifies base64-urlsafe, but legacy encoders used standard + // base64. The "????????" memo forces the two alphabets to diverge + // (index-63 sextets render as '/' vs '_'), so both paths are exercised. + val jsonBody = + """{"token":[{"mint":"https://m.example","proofs":[{"amount":1,"id":"009a1f293253e41e","secret":"s","C":"02bc"}]}],"unit":"sat","memo":"????????"}""" + val bytes = jsonBody.encodeToByteArray() + val standard = "cashuA" + Base64.Default.withPadding(Base64.PaddingOption.ABSENT).encode(bytes) + val urlSafe = "cashuA" + Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(bytes) + + assertTrue(standard.contains('/'), "expected standard alphabet payload to contain '/'") + assertTrue(urlSafe.contains('_'), "expected url-safe alphabet payload to contain '_'") + + val fromStandard = CashuTokenB64Parser.parse(standard)!![0] + val fromUrlSafe = CashuTokenB64Parser.parse(urlSafe)!![0] + + assertEquals("https://m.example", fromStandard.mint) + assertEquals(fromStandard.mint, fromUrlSafe.mint) + assertEquals("????????", fromUrlSafe.memo) + } + @Test fun acceptsMixedCasePrefix() { // parse() dispatches case-insensitively, so prefix stripping must too —