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) + } +}