diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/CashuBTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/CashuBTest.kt index 3c2d30ed82..87ffc59e81 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/CashuBTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/CashuBTest.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.amethyst import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken import com.vitorpamplona.amethyst.service.cashu.CashuParser import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals @@ -48,12 +48,12 @@ class CashuBTest { assertEquals(2, parsed.proofs[0].amount) assertEquals("407915bc212be61a77e3e6d2aeb4c727980bda51cd06a6afc29e2861768a7837", parsed.proofs[0].secret) assertEquals("009a1f293253e41e", parsed.proofs[0].id) - assertEquals("02bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea", parsed.proofs[0].C) + assertEquals("02bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea", parsed.proofs[0].c) assertEquals(8, parsed.proofs[1].amount) assertEquals("fe15109314e61d7756b0f8ee0f23a624acaa3f4e042f61433c728c7057b931be", parsed.proofs[1].secret) assertEquals("009a1f293253e41e", parsed.proofs[1].id) - assertEquals("029e8e5050b890a7d6c0968db16bc1d5d5fa040ea1de284f6ec69d61299f671059", parsed.proofs[1].C) + assertEquals("029e8e5050b890a7d6c0968db16bc1d5d5fa040ea1de284f6ec69d61299f671059", parsed.proofs[1].c) } } @@ -68,18 +68,18 @@ class CashuBTest { assertEquals(1, parsed[0].proofs[0].amount) assertEquals("acc12435e7b8484c3cf1850149218af90f716a52bf4a5ed347e48ecc13f77388", parsed[0].proofs[0].secret) assertEquals("00ffd48b8f5ecf80", parsed[0].proofs[0].id) - assertEquals("0244538319de485d55bed3b29a642bee5879375ab9e7a620e11e48ba482421f3cf", parsed[0].proofs[0].C) + assertEquals("0244538319de485d55bed3b29a642bee5879375ab9e7a620e11e48ba482421f3cf", parsed[0].proofs[0].c) assertEquals(3, parsed[1].totalAmount) assertEquals(2, parsed[1].proofs[0].amount) assertEquals("1323d3d4707a58ad2e23ada4e9f1f49f5a5b4ac7b708eb0d61f738f48307e8ee", parsed[1].proofs[0].secret) assertEquals("00ad268c4d1f5826", parsed[1].proofs[0].id) - assertEquals("023456aa110d84b4ac747aebd82c3b005aca50bf457ebd5737a4414fac3ae7d94d", parsed[1].proofs[0].C) + assertEquals("023456aa110d84b4ac747aebd82c3b005aca50bf457ebd5737a4414fac3ae7d94d", parsed[1].proofs[0].c) assertEquals(1, parsed[1].proofs[1].amount) assertEquals("56bcbcbb7cc6406b3fa5d57d2174f4eff8b4402b176926d3a57d3c3dcbb59d57", parsed[1].proofs[1].secret) assertEquals("00ad268c4d1f5826", parsed[1].proofs[1].id) - assertEquals("0273129c5719e599379a974a626363c333c56cafc0e6d01abe46d5808280789c63", parsed[1].proofs[1].C) + assertEquals("0273129c5719e599379a974a626363c333c56cafc0e6d01abe46d5808280789c63", parsed[1].proofs[1].c) } @Test() @@ -93,11 +93,11 @@ class CashuBTest { assertEquals(64, parsed[0].proofs[0].amount) assertEquals("7a8dcf9b3e8a247ce339e7369e9b4a19f31eacb69d8b0c65daaeb72d1acb9ad3", parsed[0].proofs[0].secret) assertEquals("009bb23d3a912e4e", parsed[0].proofs[0].id) - assertEquals("03df591d261bcd176c69e3e833bcab5348ca31d218620492859303a1f5e874e9c7", parsed[0].proofs[0].C) + assertEquals("03df591d261bcd176c69e3e833bcab5348ca31d218620492859303a1f5e874e9c7", parsed[0].proofs[0].c) assertEquals(32, parsed[0].proofs[1].amount) assertEquals("9d81c1a2616853ad8049cbcd1c7c247add83b373828620bac2fd7f3e5a58aceb", parsed[0].proofs[1].secret) assertEquals("009bb23d3a912e4e", parsed[0].proofs[1].id) - assertEquals("039e52c02141738e9dc278db91bf3b333a37d13d8413d5acfe77a6b859de0de806", parsed[0].proofs[1].C) + assertEquals("039e52c02141738e9dc278db91bf3b333a37d13d8413d5acfe77a6b859de0de806", parsed[0].proofs[1].c) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CachedCashuParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CachedCashuParser.kt index 887e7f9c6f..a5f8581761 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CachedCashuParser.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CachedCashuParser.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.amethyst.service.cashu import android.util.LruCache -import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken import kotlinx.collections.immutable.ImmutableList object CachedCashuParser { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuParser.kt index cb76b27bcb..0c5b5092d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuParser.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/CashuParser.kt @@ -20,25 +20,28 @@ */ package com.vitorpamplona.amethyst.service.cashu -import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken -import com.vitorpamplona.amethyst.service.cashu.v3.V3Parser -import com.vitorpamplona.amethyst.service.cashu.v4.V4Parser import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken +import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenB64Parser import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +/** + * Thin Amethyst adapter over the protocol-level [CashuTokenB64Parser] in + * Quartz: it adds the off-main-thread guard and wraps the result in the UI's + * [GenericLoadable] so composables can render loading/error/loaded states. + */ class CashuParser { fun parse(cashuToken: String): GenericLoadable> { checkNotInMainThread() - if (cashuToken.startsWith("cashuA")) { - return V3Parser.parseCashuA(cashuToken) - } + val parsed = + CashuTokenB64Parser.parse(cashuToken) + ?: return GenericLoadable.Error("Could not parse this cashu token") - if (cashuToken.startsWith("cashuB")) { - return V4Parser.parseCashuB(cashuToken) - } + if (parsed.isEmpty()) return GenericLoadable.Error("No token found") - return GenericLoadable.Error("Could not parse this cashu token") + return GenericLoadable.Loaded(parsed.toImmutableList()) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt index 07d5cad742..48724428b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt @@ -22,12 +22,11 @@ package com.vitorpamplona.amethyst.service.cashu.melt import android.content.Context import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip60Cashu.mintApi.CashuMintOperations import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpClient -import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof +import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken import okhttp3.OkHttpClient import kotlin.coroutines.cancellation.CancellationException @@ -54,10 +53,7 @@ class MeltProcessor { ): MeltResult { try { val ops = CashuMintOperations(MintHttpClient(token.mint, okHttpClient)) - val proofs = - token.proofs.map { - CashuProof(id = it.id, amount = it.amount.toLong(), secret = it.secret, c = it.C) - } + val proofs = token.proofs // A Lightning address must commit to an amount before we know the // fees, so probe with an invoice for the full token value to learn diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltResult.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltResult.kt index d700a6dd1e..ec31f05624 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltResult.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltResult.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.service.cashu.melt -import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken +import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken class MeltResult( val token: CashuToken, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Parser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Parser.kt deleted file mode 100644 index e7010f78e5..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Parser.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.service.cashu.v3 - -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue -import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken -import com.vitorpamplona.amethyst.ui.components.GenericLoadable -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList -import java.util.Base64 -import kotlin.coroutines.cancellation.CancellationException - -class V3Parser { - companion object { - fun parseCashuA(cashuToken: String): GenericLoadable> { - try { - val base64token = cashuToken.replace("cashuA", "") - val cashu = jacksonObjectMapper().readValue(String(Base64.getDecoder().decode(base64token))) - - if (cashu.token == null) { - return GenericLoadable.Error("No token found") - } - - val converted = - cashu.token.map { token -> - val proofs = token.proofs - val mint = token.mint - - var totalAmount = 0L - for (proof in proofs) { - totalAmount += proof.amount - } - - CashuToken(cashuToken, mint, totalAmount, proofs, cashu.unit, cashu.memo?.takeIf { it.isNotBlank() }) - } - - return GenericLoadable.Loaded(converted.toImmutableList()) - } catch (e: Exception) { - if (e is CancellationException) throw e - return GenericLoadable.Error("Could not parse this cashu token") - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Token.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Token.kt deleted file mode 100644 index 07a49a98a7..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v3/V3Token.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.service.cashu.v3 - -import com.vitorpamplona.amethyst.commons.model.nip60Cashu.Proof -import kotlinx.serialization.Serializable - -@Serializable -class V3Token( - val unit: String?, - val memo: String?, - val token: List?, -) - -@Serializable -class V3T( - val mint: String, - val proofs: List, -) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Parser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Parser.kt deleted file mode 100644 index e70e8c7387..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Parser.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.service.cashu.v4 - -import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken -import com.vitorpamplona.amethyst.commons.model.nip60Cashu.Proof -import com.vitorpamplona.amethyst.ui.components.GenericLoadable -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip60Cashu.token.V4Token -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.cbor.Cbor -import kotlinx.serialization.decodeFromByteArray -import java.util.Base64 -import kotlin.coroutines.cancellation.CancellationException - -class V4Parser { - companion object { - @OptIn(ExperimentalSerializationApi::class) - fun parseCashuB(cashuToken: String): GenericLoadable> { - try { - val base64token = cashuToken.replace("cashuB", "") - val parser = Cbor { ignoreUnknownKeys = true } - val v4Token = parser.decodeFromByteArray(Base64.getUrlDecoder().decode(base64token)) - val v4proofs = v4Token.t ?: return GenericLoadable.Error("No token found") - - val converted = - v4proofs.map { id -> - val proofs = - id.p.map { - Proof( - it.a, - id.i.toHexKey(), - it.s, - it.c.toHexKey(), - ) - } - val mint = v4Token.m - - var totalAmount = 0L - for (proof in proofs) { - totalAmount += proof.amount - } - - CashuToken(cashuToken, mint, totalAmount, proofs, v4Token.u, v4Token.d?.takeIf { it.isNotBlank() }) - } - - return GenericLoadable.Loaded(converted.toImmutableList()) - } catch (e: Exception) { - if (e is CancellationException) throw e - return GenericLoadable.Error("Could not parse this cashu token") - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt index c6eeb24862..74734e3061 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt @@ -52,7 +52,6 @@ import androidx.core.net.toUri import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.hashtags.Cashu import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons -import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken import com.vitorpamplona.amethyst.service.cashu.CachedCashuParser import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.note.OpenInNewIcon @@ -64,6 +63,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size18Modifier import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index a9b2c09304..2666f0a1b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -42,7 +42,6 @@ import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel -import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtComparator import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastTracker @@ -151,6 +150,7 @@ import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip92IMeta.imeta import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag 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 dda54daa4b..c29880f46d 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 @@ -27,14 +27,11 @@ import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState import com.vitorpamplona.amethyst.model.nip60Cashu.MintQuoteStarted import com.vitorpamplona.amethyst.model.nip60Cashu.TokenEntry import com.vitorpamplona.amethyst.model.nip60Cashu.describeMintError -import com.vitorpamplona.amethyst.service.cashu.v3.V3Parser -import com.vitorpamplona.amethyst.service.cashu.v4.V4Parser -import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.nip60Cashu.mintApi.MeltQuoteBolt11ResponseDto import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpException -import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof +import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenB64Parser import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.flow.MutableStateFlow @@ -841,54 +838,20 @@ class CashuWalletViewModel : ViewModel() { return } - val (mintUrl, proofs) = - when { - trimmed.startsWith("cashuB") -> { - when (val parsed = V4Parser.parseCashuB(trimmed)) { - is GenericLoadable.Loaded -> { - val tok = parsed.loaded.firstOrNull() - if (tok == null) { - _redeemState.value = CashuRedeemFlowState.Error("Token has no proofs") - return - } - tok.mint to tok.proofs.map { CashuProof(it.id, it.amount.toLong(), it.secret, it.C) } - } - is GenericLoadable.Error -> { - _redeemState.value = CashuRedeemFlowState.Error(parsed.errorMessage) - return - } - else -> { - _redeemState.value = CashuRedeemFlowState.Error("Could not parse token") - return - } - } - } - trimmed.startsWith("cashuA") -> { - when (val parsed = V3Parser.parseCashuA(trimmed)) { - is GenericLoadable.Loaded -> { - val tok = parsed.loaded.firstOrNull() - if (tok == null) { - _redeemState.value = CashuRedeemFlowState.Error("Token has no proofs") - return - } - tok.mint to tok.proofs.map { CashuProof(it.id, it.amount.toLong(), it.secret, it.C) } - } - is GenericLoadable.Error -> { - _redeemState.value = CashuRedeemFlowState.Error(parsed.errorMessage) - return - } - else -> { - _redeemState.value = CashuRedeemFlowState.Error("Could not parse token") - return - } - } - } - else -> { - _redeemState.value = - CashuRedeemFlowState.Error("Not a Cashu token (must start with cashuA or cashuB)") + if (!trimmed.startsWith("cashuA", ignoreCase = true) && !trimmed.startsWith("cashuB", ignoreCase = true)) { + _redeemState.value = + CashuRedeemFlowState.Error("Not a Cashu token (must start with cashuA or cashuB)") + return + } + + val parsedToken = + CashuTokenB64Parser.parse(trimmed)?.firstOrNull() + ?: run { + _redeemState.value = CashuRedeemFlowState.Error("Could not parse token") return } - } + val mintUrl = parsedToken.mint + val proofs = parsedToken.proofs if (mintUrl !in mints.value) { _redeemState.value = diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/CashuV4ParserTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/CashuV4ParserTest.kt deleted file mode 100644 index 7337ac5c02..0000000000 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/CashuV4ParserTest.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst - -import com.vitorpamplona.amethyst.service.cashu.v4.V4Parser -import com.vitorpamplona.amethyst.ui.components.GenericLoadable -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Test - -class CashuV4ParserTest { - @Test - fun decodesMinibitsTestToken() { - val token = - "cashuBv2FteCJodHRwczovL21pbnQubWluaWJpdHMuY2FzaC9CaXRjb2luYXVjc2F0YWRkVEVTVGF0n79haUgAEHk32wzI" + - "ZWFwn79hYQJhc3hAZWM1YWI3Yjc1NjViYjBjZTZhNzg2NzBkMDA0OGExMjVlZGQzMjJhYmVjMTEzYWMwZTBmZGVkZmE3NTQ4Mzg3OWFj" + - "WCED0-ops8Ta6NjKChNJPe_jgIbXLlyxg2KSy2WaSTADo5D_v2FhCGFzeEBkNDZlODU5MDExNjU0NmNjZjAwNTE3ZTQ1NmU0MTY0N2Fm" + - "ZWUxOWNlMzY2N2IzYTcxODZkMzEwZDY1MjM3OTM4YWNYIQMTDGTY943O4ojhKopoYdemsUE2rSLfzwNBODL8WgOX0v______" - - val outcome = V4Parser.parseCashuB(token) - when (outcome) { - is GenericLoadable.Loaded -> { - val list = outcome.loaded - assertTrue("Expected at least one CashuToken", list.isNotEmpty()) - val first = list.first() - assertEquals("https://mint.minibits.cash/Bitcoin", first.mint) - assertEquals("sat", first.unit) - assertEquals("TEST", first.memo) - } - is GenericLoadable.Error -> fail("V4Parser failed: ${outcome.errorMessage}") - else -> fail("V4Parser returned $outcome") - } - } -} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip60Cashu/CashuToken.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/CashuToken.kt similarity index 75% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip60Cashu/CashuToken.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/CashuToken.kt index d2b2dcc188..e47d201856 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip60Cashu/CashuToken.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/CashuToken.kt @@ -18,28 +18,27 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.model.nip60Cashu +package com.vitorpamplona.quartz.nip60Cashu.token import androidx.compose.runtime.Immutable -import kotlinx.serialization.Serializable +/** + * A NUT-00 out-of-band cashu token (`cashuA` v3 / `cashuB` v4) decoded from its + * string form, ready to display or redeem. + * + * Distinct from [TokenContent], which models the NIP-60 kind:7375 *wallet* + * token stored inside an encrypted event. This type is what a user receives + * pasted into a note or DM. Produced by [CashuTokenB64Parser] and serialised + * back out by [V4Encoder]. + */ @Immutable data class CashuToken( val token: String, val mint: String, val totalAmount: Long, - val proofs: List, + val proofs: List, /** NUT-00 currency unit ("sat" when absent). Non-sat units are denominated in minor units (e.g. usd = cents). */ val unit: String? = null, /** Free-text memo the sender attached to the token. */ val memo: String? = null, ) - -@Serializable -@Immutable -class Proof( - val amount: Int, - val id: String, - val secret: String, - val C: String, -) 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 new file mode 100644 index 0000000000..39620a82f2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/CashuTokenB64Parser.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +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 +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Parses NUT-00 out-of-band cashu token strings into [CashuToken]s — the + * inverse of [V4Encoder]. + * + * - `cashuA` (v3): standard-Base64-encoded JSON. + * - `cashuB` (v4): Base64URL-encoded CBOR (shares the [V4Token] wire models). + * + * Returns null on any malformed input — callers decide how to surface the + * failure (the Amethyst UI wraps it in a `GenericLoadable.Error`). A single + * token string can carry proofs from multiple mints, hence the list result + * (one [CashuToken] per mint/keyset group). + */ +object CashuTokenB64Parser { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + fun parse(token: String): List? = + when { + token.startsWith("cashuA", ignoreCase = true) -> parseCashuA(token) + token.startsWith("cashuB", ignoreCase = true) -> parseCashuB(token) + else -> null + } + + @OptIn(ExperimentalEncodingApi::class) + fun parseCashuA(token: String): List? = + try { + val payload = token.removePrefix("cashuA") + val decoded = + Base64.Default + .withPadding(Base64.PaddingOption.PRESENT_OPTIONAL) + .decode(payload) + .decodeToString() + val parsed = json.decodeFromString(V3TokenJson.serializer(), decoded) + parsed.token?.map { entry -> + val proofs = + entry.proofs.map { + CashuProof(id = it.id, amount = it.amount, secret = it.secret, c = it.c) + } + CashuToken( + token = token, + mint = entry.mint, + totalAmount = proofs.sumOf { it.amount }, + proofs = proofs, + unit = parsed.unit, + memo = parsed.memo?.takeIf { it.isNotBlank() }, + ) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + + @OptIn(ExperimentalEncodingApi::class, ExperimentalSerializationApi::class) + fun parseCashuB(token: String): List? = + try { + val payload = token.removePrefix("cashuB") + val bytes = + Base64.UrlSafe + .withPadding(Base64.PaddingOption.PRESENT_OPTIONAL) + .decode(payload) + val parsed = Cbor { ignoreUnknownKeys = true }.decodeFromByteArray(bytes) + parsed.t?.map { group -> + val keysetId = group.i.toHexKey() + val proofs = + group.p.map { + CashuProof(id = keysetId, amount = it.a.toLong(), secret = it.s, c = it.c.toHexKey()) + } + CashuToken( + token = token, + mint = parsed.m, + totalAmount = proofs.sumOf { it.amount }, + proofs = proofs, + unit = parsed.u, + memo = parsed.d?.takeIf { it.isNotBlank() }, + ) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } +} + +/** NUT-00 v3 (`cashuA`) JSON envelope. Proofs reuse [CashuProofJson] (`C` → c). */ +@Serializable +internal data class V3TokenJson( + val token: List? = null, + val unit: String? = null, + val memo: String? = null, +) + +@Serializable +internal data class V3TokenEntryJson( + val mint: String, + val proofs: List, +) 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 new file mode 100644 index 0000000000..83e3b53629 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/CashuTokenB64ParserTest.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip60Cashu.token + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CashuTokenB64ParserTest { + private val cashuTokenA = + "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9" + private val cashuTokenB1 = + "cashuBo2F0gqJhaUgA_9SLj17PgGFwgaNhYQFhc3hAYWNjMTI0MzVlN2I4NDg0YzNjZjE4NTAxNDkyMThhZjkwZjcxNmE1MmJmNGE1ZWQzNDdlNDhlY2MxM2Y3NzM4OGFjWCECRFODGd5IXVW-07KaZCvuWHk3WrnnpiDhHki6SCQh88-iYWlIAK0mjE0fWCZhcIKjYWECYXN4QDEzMjNkM2Q0NzA3YTU4YWQyZTIzYWRhNGU5ZjFmNDlmNWE1YjRhYzdiNzA4ZWIwZDYxZjczOGY0ODMwN2U4ZWVhY1ghAjRWqhENhLSsdHrr2Cw7AFrKUL9Ffr1XN6RBT6w659lNo2FhAWFzeEA1NmJjYmNiYjdjYzY0MDZiM2ZhNWQ1N2QyMTc0ZjRlZmY4YjQ0MDJiMTc2OTI2ZDNhNTdkM2MzZGNiYjU5ZDU3YWNYIQJzEpxXGeWZN5qXSmJjY8MzxWyvwObQGr5G1YCCgHicY2FtdWh0dHA6Ly9sb2NhbGhvc3Q6MzMzOGF1Y3NhdA" + private val cashuTokenB2 = + "cashuBpGFkb01pbmliaXRzIHJ1bGVzIWFteEpodHRwOi8vbGJ1dGxoNWxmZ2dxNXI3eHBpd2hyYWpkbDdzeHB1cGdhZ2F6eGw2NXc0YzVjZzcyd3RvZmFzYWQub25pb246MzMzOGF0gaJhaUgAm7I9OpEuTmFwg6NhYRhAYWNYIQPfWR0mG80XbGnj6DO8q1NIyjHSGGIEkoWTA6H16HTpx2FzeEA3YThkY2Y5YjNlOGEyNDdjZTMzOWU3MzY5ZTliNGExOWYzMWVhY2I2OWQ4YjBjNjVkYWFlYjcyZDFhY2I5YWQzo2FhGCBhY1ghA55SwCFBc46dwnjbkb87Mzo30T2EE9Ws_nemuFneDegGYXN4QDlkODFjMWEyNjE2ODUzYWQ4MDQ5Y2JjZDFjN2MyNDdhZGQ4M2IzNzM4Mjg2MjBiYWMyZmQ3ZjNlNWE1OGFjZWKjYWEEYWNYIQM-yAQQTR2t6pIAmfmGM8Wxy7ajKVLOaUg7TrV8o-EdVWFzeEBmNmViMTI4ZmJlMDM3MTEzZTkzZjM3NjllYTYwMTk1NmY1N2NkZWNhNTYwOGY0NWUzMDhhZDU0ZmQ4YTQxNWVhYXVjc2F0" + + @Test + fun parseCashuA() { + val parsed = CashuTokenB64Parser.parse(cashuTokenA)!![0] + + assertEquals(cashuTokenA, parsed.token) + assertEquals("https://8333.space:3338", parsed.mint) + assertEquals("sat", parsed.unit) + assertEquals("Thank you.", parsed.memo) + assertEquals(10, parsed.totalAmount) + + assertEquals(2, parsed.proofs[0].amount) + assertEquals("407915bc212be61a77e3e6d2aeb4c727980bda51cd06a6afc29e2861768a7837", parsed.proofs[0].secret) + assertEquals("009a1f293253e41e", parsed.proofs[0].id) + assertEquals("02bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea", parsed.proofs[0].c) + + assertEquals(8, parsed.proofs[1].amount) + assertEquals("fe15109314e61d7756b0f8ee0f23a624acaa3f4e042f61433c728c7057b931be", parsed.proofs[1].secret) + assertEquals("009a1f293253e41e", parsed.proofs[1].id) + assertEquals("029e8e5050b890a7d6c0968db16bc1d5d5fa040ea1de284f6ec69d61299f671059", parsed.proofs[1].c) + } + + @Test + fun parseCashuB() { + val parsed = CashuTokenB64Parser.parse(cashuTokenB1)!! + + assertEquals(cashuTokenB1, parsed[0].token) + assertEquals("http://localhost:3338", parsed[0].mint) + assertEquals(1, parsed[0].totalAmount) + assertEquals(1, parsed[0].proofs[0].amount) + assertEquals("acc12435e7b8484c3cf1850149218af90f716a52bf4a5ed347e48ecc13f77388", parsed[0].proofs[0].secret) + assertEquals("00ffd48b8f5ecf80", parsed[0].proofs[0].id) + assertEquals("0244538319de485d55bed3b29a642bee5879375ab9e7a620e11e48ba482421f3cf", parsed[0].proofs[0].c) + + assertEquals(3, parsed[1].totalAmount) + assertEquals(2, parsed[1].proofs[0].amount) + assertEquals("1323d3d4707a58ad2e23ada4e9f1f49f5a5b4ac7b708eb0d61f738f48307e8ee", parsed[1].proofs[0].secret) + assertEquals("00ad268c4d1f5826", parsed[1].proofs[0].id) + assertEquals("023456aa110d84b4ac747aebd82c3b005aca50bf457ebd5737a4414fac3ae7d94d", parsed[1].proofs[0].c) + + assertEquals(1, parsed[1].proofs[1].amount) + assertEquals("56bcbcbb7cc6406b3fa5d57d2174f4eff8b4402b176926d3a57d3c3dcbb59d57", parsed[1].proofs[1].secret) + assertEquals("00ad268c4d1f5826", parsed[1].proofs[1].id) + assertEquals("0273129c5719e599379a974a626363c333c56cafc0e6d01abe46d5808280789c63", parsed[1].proofs[1].c) + } + + @Test + fun parseCashuB2() { + val parsed = CashuTokenB64Parser.parse(cashuTokenB2)!! + + assertEquals(cashuTokenB2, parsed[0].token) + assertEquals("http://lbutlh5lfggq5r7xpiwhrajdl7sxpupgagazxl65w4c5cg72wtofasad.onion:3338", parsed[0].mint) + assertEquals("Minibits rules!", parsed[0].memo) + assertEquals(100, parsed[0].totalAmount) + assertEquals(64, parsed[0].proofs[0].amount) + assertEquals("7a8dcf9b3e8a247ce339e7369e9b4a19f31eacb69d8b0c65daaeb72d1acb9ad3", parsed[0].proofs[0].secret) + assertEquals("009bb23d3a912e4e", parsed[0].proofs[0].id) + assertEquals("03df591d261bcd176c69e3e833bcab5348ca31d218620492859303a1f5e874e9c7", parsed[0].proofs[0].c) + + assertEquals(32, parsed[0].proofs[1].amount) + assertEquals("9d81c1a2616853ad8049cbcd1c7c247add83b373828620bac2fd7f3e5a58aceb", parsed[0].proofs[1].secret) + assertEquals("009bb23d3a912e4e", parsed[0].proofs[1].id) + assertEquals("039e52c02141738e9dc278db91bf3b333a37d13d8413d5acfe77a6b859de0de806", parsed[0].proofs[1].c) + } + + @Test + fun roundTripThroughEncoder() { + // Parse a real token, re-encode its proofs, and parse again — the + // proof set must survive a cashuB encode/decode cycle. + val original = CashuTokenB64Parser.parse(cashuTokenB1)!!.flatMap { it.proofs } + val reEncoded = V4Encoder.encode("http://localhost:3338", original) + val reParsed = CashuTokenB64Parser.parse(reEncoded)!!.flatMap { it.proofs } + + assertEquals(original.map { it.secret }.toSet(), reParsed.map { it.secret }.toSet()) + assertEquals(original.sumOf { it.amount }, reParsed.sumOf { it.amount }) + } + + @Test + fun rejectsUnknownPrefix() { + assertNull(CashuTokenB64Parser.parse("lightning:whatever")) + } + + @Test + fun rejectsMalformedToken() { + assertNull(CashuTokenB64Parser.parse("cashuBnot-valid-cbor")) + assertNull(CashuTokenB64Parser.parse("cashuAnot-valid-json")) + } + + @Test + fun dispatchesByPrefix() { + assertTrue(CashuTokenB64Parser.parse(cashuTokenA)!!.isNotEmpty()) + assertTrue(CashuTokenB64Parser.parse(cashuTokenB1)!!.isNotEmpty()) + } +}