mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+1
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+8
-7
@@ -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"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
+94
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user