feat(cashu): tolerate both base64 alphabets + redeem multi-mint tokens

- parseCashuA now decodes standard *and* url-safe base64. NUT-00 v3
  specifies base64-urlsafe, but legacy encoders (and older Amethyst
  builds) emitted standard base64; try standard first, fall back to
  url-safe so both round-trip.
- CashuWalletViewModel.redeemToken now redeems every mint/keyset group
  in a pasted token instead of only the first, validating all mints are
  in the wallet up front and summing the redeemed amounts.

Adds parser coverage for both base64 alphabets.

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 16:01:22 +00:00
parent 3515afa34c
commit ebdf18139b
3 changed files with 50 additions and 13 deletions
@@ -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))
}
@@ -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). */
@@ -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 —