refactor(cashu): move out-of-band token parser + model to quartz

Follow-up to the V4Encoder move. Quartz could encode a cashuB string but
could not parse cashuA/cashuB back, and the parsing lived in amethyst even
though it is pure NUT-00 wire-format protocol. Worse, commons.RichTextParser
already detects cashuA/cashuB words while the parser sat up in the app, so
Desktop (its own rich-text viewer) could not parse a received token at all.

Consolidate the legacy out-of-band redeem stack onto quartz:

- new quartz CashuTokenB64Parser parses cashuA (standard-Base64 JSON, rewritten
  off Jackson onto kotlinx.serialization to satisfy quartz's no-Jackson rule)
  and cashuB (Base64URL CBOR, reusing the V4Token models), returning quartz
  types. It is the inverse of V4Encoder.
- move the CashuToken container model from commons to quartz, switching its
  proofs from the duplicate commons Proof (field C, amount Int) onto the
  canonical quartz CashuProof (field c, amount Long). The duplicate Proof
  type is deleted.
- delete amethyst V3Parser/V3Token/V4Parser; CashuParser/CachedCashuParser
  stay as thin amethyst adapters (off-main-thread guard + GenericLoadable +
  LruCache) over the quartz parser.
- this removes the manual Proof -> CashuProof conversion shims that
  MeltProcessor and CashuWalletViewModel previously carried.

Tests: full cashuA + cashuB vector coverage moves to quartz commonTest
(CashuTokenB64ParserTest, runs on JVM) plus an encode/parse round-trip; the
superseded amethyst CashuV4ParserTest is removed and CashuBTest stays as the
adapter integration test.

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 15:32:48 +00:00
parent 6a65680e61
commit c5b1d0051a
15 changed files with 309 additions and 317 deletions
@@ -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)
}
}
@@ -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 {
@@ -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<ImmutableList<CashuToken>> {
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())
}
}
@@ -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
@@ -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,
@@ -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<ImmutableList<CashuToken>> {
try {
val base64token = cashuToken.replace("cashuA", "")
val cashu = jacksonObjectMapper().readValue<V3Token>(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")
}
}
}
}
@@ -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<V3T>?,
)
@Serializable
class V3T(
val mint: String,
val proofs: List<Proof>,
)
@@ -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<ImmutableList<CashuToken>> {
try {
val base64token = cashuToken.replace("cashuB", "")
val parser = Cbor { ignoreUnknownKeys = true }
val v4Token = parser.decodeFromByteArray<V4Token>(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")
}
}
}
}
@@ -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
@@ -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
@@ -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 =
@@ -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")
}
}
}
@@ -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<Proof>,
val proofs: List<CashuProof>,
/** 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,
)
@@ -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<CashuToken>? =
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<CashuToken>? =
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<CashuToken>? =
try {
val payload = token.removePrefix("cashuB")
val bytes =
Base64.UrlSafe
.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL)
.decode(payload)
val parsed = Cbor { ignoreUnknownKeys = true }.decodeFromByteArray<V4Token>(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<V3TokenEntryJson>? = null,
val unit: String? = null,
val memo: String? = null,
)
@Serializable
internal data class V3TokenEntryJson(
val mint: String,
val proofs: List<CashuProofJson>,
)
@@ -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())
}
}