refactor(cashu): move v4 (cashuB) token codec to quartz

The NUT-00 v4 `cashuB` wire-format encoder + CBOR models lived in
`amethyst/service/cashu/v4`, even though they are pure protocol code with
no app/UI dependencies. quartz had the proof/mint primitives but no
`cashuB` string codec at all, so this was a layering gap rather than a
duplicate.

Move `V4Encoder` and the `V4Token`/`V4T`/`V4Proof`/`V4DleqProof` wire
models into `quartz/nip60Cashu/token`, rewriting the JVM-only
`java.util.Base64` to the multiplatform `kotlin.io.encoding.Base64.UrlSafe`
so the codec lives in commonMain. This unblocks sharing CashuWalletOps /
CashuWalletState into commons (V4Encoder was one of the Android-only ties).

- add kotlinx-serialization-cbor (Apache-2.0) to quartz commonMain
- repoint CashuWalletOps (encoder) and V4Parser (V4Token) imports to quartz
- add V4EncoderTest round-trip coverage in quartz commonTest

amethyst's V4Parser stays put: it returns UI types (GenericLoadable +
commons CashuToken) and now just reuses the relocated quartz V4Token.

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 14:48:40 +00:00
parent 8ef4e42c16
commit 6a65680e61
6 changed files with 108 additions and 9 deletions
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.model.nip60Cashu
import com.vitorpamplona.amethyst.service.cashu.v4.V4Encoder
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -51,6 +50,7 @@ import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent
import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent
import com.vitorpamplona.quartz.nip60Cashu.token.TokenContent
import com.vitorpamplona.quartz.nip60Cashu.token.V4Encoder
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapMintTag
@@ -24,6 +24,7 @@ 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
+3
View File
@@ -127,6 +127,9 @@ kotlin {
// Kotlin serialization for the times where we need the Json tree and performance is not that important.
implementation(libs.kotlinx.serialization.json)
// CBOR serialization for the NUT-00 v4 (cashuB) token wire format.
implementation(libs.kotlinx.serialization.cbor)
// immutable collections to avoid recomposition
implementation(libs.kotlinx.collections.immutable)
@@ -18,24 +18,25 @@
* 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
package com.vitorpamplona.quartz.nip60Cashu.token
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.encodeToByteArray
import java.util.Base64
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
/**
* Inverse of [V4Parser]: encode a list of NIP-60 proofs into a `cashuB` token
* string suitable for sending out-of-band.
* Inverse of [com.vitorpamplona.quartz.nip60Cashu.token.V4Token] parsing:
* encode a list of NIP-60 proofs into a `cashuB` token string suitable for
* sending out-of-band.
*
* V4 (NUT-00) wire format:
* "cashuB" + Base64URL(CBOR(V4Token { m, u, d?, t: [V4T { i, p: [V4Proof] }] }))
*/
object V4Encoder {
@OptIn(ExperimentalSerializationApi::class)
@OptIn(ExperimentalSerializationApi::class, ExperimentalEncodingApi::class)
fun encode(
mintUrl: String,
proofs: List<CashuProof>,
@@ -76,7 +77,7 @@ object V4Encoder {
ignoreUnknownKeys = true
}
val bytes = cbor.encodeToByteArray(token)
val base64 = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
val base64 = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(bytes)
return "cashuB$base64"
}
}
@@ -18,7 +18,7 @@
* 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
package com.vitorpamplona.quartz.nip60Cashu.token
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
@@ -0,0 +1,94 @@
/*
* 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 kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.decodeFromByteArray
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class V4EncoderTest {
private val mintUrl = "http://localhost:3338"
// keyset id "009a1f293253e41e" + two proofs from the NUT-00 v4 example.
private val proofs =
listOf(
CashuProof(
id = "009a1f293253e41e",
amount = 1,
secret = "acc12435e7b8484c3cf1850149218af90f716a52bf4a5ed347e48ecc13f77388",
c = "0244538319d5485d55bed3b29a642bee587937dab9e7a620e11e48ba4824f1cf3f",
),
CashuProof(
id = "009a1f293253e41e",
amount = 2,
secret = "1323d3d4707a58ad2e23ada4e9f1f49f5a5b4ac7b708eb0d61f738f48307e8ee",
c = "023456aa110d84b4ac747aebd82c3b005aca50bf457ebd5737a4414fac3ae7d8c5",
),
)
@Test
fun encodesCashuBPrefixAndRoundTrips() {
val encoded = V4Encoder.encode(mintUrl, proofs, unit = "sat", memo = null)
assertTrue(encoded.startsWith("cashuB"), "must carry the v4 wire prefix")
val token = decode(encoded)
assertEquals(mintUrl, token.m)
assertEquals("sat", token.u)
assertEquals(null, token.d)
val groups = token.t!!
// both proofs share a keyset id, so they collapse into a single group.
assertEquals(1, groups.size)
val decodedProofs = groups[0].p
assertEquals(2, decodedProofs.size)
assertEquals(setOf(1, 2), decodedProofs.map { it.a }.toSet())
assertEquals(
proofs.map { it.secret }.toSet(),
decodedProofs.map { it.s }.toSet(),
)
}
@Test
fun carriesMemoWhenProvided() {
val token = decode(V4Encoder.encode(mintUrl, proofs, memo = "minibits rules!"))
assertEquals("minibits rules!", token.d)
}
@Test
fun rejectsEmptyProofList() {
assertFailsWith<IllegalArgumentException> {
V4Encoder.encode(mintUrl, emptyList())
}
}
@OptIn(ExperimentalSerializationApi::class, ExperimentalEncodingApi::class)
private fun decode(cashuB: String): V4Token {
val raw = cashuB.removePrefix("cashuB")
val bytes = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(raw)
return Cbor { ignoreUnknownKeys = true }.decodeFromByteArray<V4Token>(bytes)
}
}