From 6a65680e61a81e9a8a14cc8d0ec2137541a414d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 14:48:40 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_013UMNKix4qEfiAPP9s2a4gB --- .../model/nip60Cashu/CashuWalletOps.kt | 2 +- .../amethyst/service/cashu/v4/V4Parser.kt | 1 + quartz/build.gradle.kts | 3 + .../quartz/nip60Cashu/token}/V4Encoder.kt | 15 +-- .../quartz/nip60Cashu/token}/V4Models.kt | 2 +- .../quartz/nip60Cashu/token/V4EncoderTest.kt | 94 +++++++++++++++++++ 6 files changed, 108 insertions(+), 9 deletions(-) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4 => quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token}/V4Encoder.kt (85%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4 => quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token}/V4Models.kt (97%) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/V4EncoderTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt index f98577bc87..bd8472aa6a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt @@ -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 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 index a12fca689e..e70e8c7387 100644 --- 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 @@ -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 diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 918277dc5a..fa64d66e21 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Encoder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/V4Encoder.kt similarity index 85% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Encoder.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/V4Encoder.kt index 073bfae9ba..94a25469e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Encoder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/V4Encoder.kt @@ -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, @@ -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" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Models.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/V4Models.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Models.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/V4Models.kt index efd6e12fd0..e8f5733733 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Models.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/V4Models.kt @@ -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 diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/V4EncoderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/V4EncoderTest.kt new file mode 100644 index 0000000000..72a27b259f --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/V4EncoderTest.kt @@ -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 { + 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(bytes) + } +}