mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
+9
-8
@@ -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))
|
||||
}
|
||||
|
||||
+16
-5
@@ -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). */
|
||||
|
||||
+25
@@ -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 —
|
||||
|
||||
Reference in New Issue
Block a user