Skip to content

Commit

Permalink
Adds support for CashuB tokens using CBOR
Browse files Browse the repository at this point in the history
  • Loading branch information
vitorpamplona committed Jul 31, 2024
1 parent 3bbb780 commit fc98442
Show file tree
Hide file tree
Showing 6 changed files with 279 additions and 182 deletions.
4 changes: 4 additions & 0 deletions amethyst/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ plugins {
alias(libs.plugins.jetbrainsKotlinAndroid)
alias(libs.plugins.googleServices)
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
}

android {
Expand Down Expand Up @@ -282,6 +283,9 @@ dependencies {
// Image compression lib
implementation libs.zelory.video.compressor

// Cbor for cashuB format
implementation libs.kotlinx.serialization.cbor

testImplementation libs.junit
testImplementation libs.mockk

Expand Down
102 changes: 102 additions & 0 deletions amethyst/src/androidTest/java/com/vitorpamplona/amethyst/CashuBTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* Copyright (c) 2024 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 androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.service.CashuProcessor
import com.vitorpamplona.amethyst.service.CashuToken
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.runBlocking
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class CashuBTest {
val cashuTokenA = "cashuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9"
val cashuTokenB1 = "cashuBo2F0gqJhaUgA_9SLj17PgGFwgaNhYQFhc3hAYWNjMTI0MzVlN2I4NDg0YzNjZjE4NTAxNDkyMThhZjkwZjcxNmE1MmJmNGE1ZWQzNDdlNDhlY2MxM2Y3NzM4OGFjWCECRFODGd5IXVW-07KaZCvuWHk3WrnnpiDhHki6SCQh88-iYWlIAK0mjE0fWCZhcIKjYWECYXN4QDEzMjNkM2Q0NzA3YTU4YWQyZTIzYWRhNGU5ZjFmNDlmNWE1YjRhYzdiNzA4ZWIwZDYxZjczOGY0ODMwN2U4ZWVhY1ghAjRWqhENhLSsdHrr2Cw7AFrKUL9Ffr1XN6RBT6w659lNo2FhAWFzeEA1NmJjYmNiYjdjYzY0MDZiM2ZhNWQ1N2QyMTc0ZjRlZmY4YjQ0MDJiMTc2OTI2ZDNhNTdkM2MzZGNiYjU5ZDU3YWNYIQJzEpxXGeWZN5qXSmJjY8MzxWyvwObQGr5G1YCCgHicY2FtdWh0dHA6Ly9sb2NhbGhvc3Q6MzMzOGF1Y3NhdA"
val cashuTokenB2 = "cashuBpGFkb01pbmliaXRzIHJ1bGVzIWFteEpodHRwOi8vbGJ1dGxoNWxmZ2dxNXI3eHBpd2hyYWpkbDdzeHB1cGdhZ2F6eGw2NXc0YzVjZzcyd3RvZmFzYWQub25pb246MzMzOGF0gaJhaUgAm7I9OpEuTmFwg6NhYRhAYWNYIQPfWR0mG80XbGnj6DO8q1NIyjHSGGIEkoWTA6H16HTpx2FzeEA3YThkY2Y5YjNlOGEyNDdjZTMzOWU3MzY5ZTliNGExOWYzMWVhY2I2OWQ4YjBjNjVkYWFlYjcyZDFhY2I5YWQzo2FhGCBhY1ghA55SwCFBc46dwnjbkb87Mzo30T2EE9Ws_nemuFneDegGYXN4QDlkODFjMWEyNjE2ODUzYWQ4MDQ5Y2JjZDFjN2MyNDdhZGQ4M2IzNzM4Mjg2MjBiYWMyZmQ3ZjNlNWE1OGFjZWKjYWEEYWNYIQM-yAQQTR2t6pIAmfmGM8Wxy7ajKVLOaUg7TrV8o-EdVWFzeEBmNmViMTI4ZmJlMDM3MTEzZTkzZjM3NjllYTYwMTk1NmY1N2NkZWNhNTYwOGY0NWUzMDhhZDU0ZmQ4YTQxNWVhYXVjc2F0"

@Test()
fun parseCashuA() {
runBlocking {
val parsed = (CashuProcessor().parse(cashuTokenA) as GenericLoadable.Loaded<List<CashuToken>>).loaded[0]

assertEquals(cashuTokenA, parsed.token)
assertEquals("https://8333.space:3338", parsed.mint)
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() =
runBlocking {
val parsed = (CashuProcessor().parse(cashuTokenB1) as GenericLoadable.Loaded<List<CashuToken>>).loaded

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() =
runBlocking {
val parsed = (CashuProcessor().parse(cashuTokenB2) as GenericLoadable.Loaded<List<CashuToken>>).loaded

assertEquals(cashuTokenB2, parsed[0].token)
assertEquals("http://lbutlh5lfggq5r7xpiwhrajdl7sxpupgagazxl65w4c5cg72wtofasad.onion:3338", parsed[0].mint)
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,20 @@ package com.vitorpamplona.amethyst.service
import android.content.Context
import android.util.LruCache
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.ammolite.service.HttpClientManager
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.Event
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.cbor.ByteString
import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.decodeFromByteArray
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
Expand All @@ -42,16 +48,26 @@ data class CashuToken(
val token: String,
val mint: String,
val totalAmount: Long,
val proofs: JsonNode,
val proofs: List<Proof>,
)

@Serializable
@Immutable
class Proof(
val amount: Int,
val id: String,
val secret: String,
val C: String,
)

object CachedCashuProcessor {
val cashuCache = LruCache<String, GenericLoadable<CashuToken>>(20)
val cashuCache = LruCache<String, GenericLoadable<List<CashuToken>>>(20)

fun cached(token: String): GenericLoadable<CashuToken> = cashuCache[token] ?: GenericLoadable.Loading()
fun cached(token: String): GenericLoadable<List<CashuToken>> = cashuCache[token] ?: GenericLoadable.Loading()

fun parse(token: String): GenericLoadable<CashuToken> {
fun parse(token: String): GenericLoadable<List<CashuToken>> {
if (cashuCache[token] !is GenericLoadable.Loaded) {
checkNotInMainThread()
val newCachuData = CashuProcessor().parse(token)

cashuCache.put(token, newCachuData)
Expand All @@ -62,25 +78,138 @@ object CachedCashuProcessor {
}

class CashuProcessor {
fun parse(cashuToken: String): GenericLoadable<CashuToken> {
@Serializable
class V3Token(
val unit: String?, // unit
val memo: String?, // memo
val token: List<V3T>?,
)

@Serializable
class V3T(
val mint: String,
val proofs: List<Proof>,
)

fun parse(cashuToken: String): GenericLoadable<List<CashuToken>> {
checkNotInMainThread()

if (cashuToken.startsWith("cashuA")) {
return parseCashuA(cashuToken)
}

if (cashuToken.startsWith("cashuB")) {
return parseCashuB(cashuToken)
}

return GenericLoadable.Error("Could not parse this cashu token")
}

fun parseCashuA(cashuToken: String): GenericLoadable<List<CashuToken>> {
checkNotInMainThread()

try {
val base64token = cashuToken.replace("cashuA", "")
val cashu = jacksonObjectMapper().readTree(String(Base64.getDecoder().decode(base64token)))
val token = cashu.get("token").get(0)
val proofs = token.get("proofs")
val mint = token.get("mint").asText()

var totalAmount = 0L
for (proof in proofs) {
totalAmount += proof.get("amount").asLong()
val cashu = jacksonObjectMapper().readValue<V3Token>(String(Base64.getDecoder().decode(base64token)))

if (cashu.token == null) {
return GenericLoadable.Error("No token found")
}

return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, proofs))
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)
}

return GenericLoadable.Loaded(converted)
} catch (e: Exception) {
if (e is CancellationException) throw e
return GenericLoadable.Error<CashuToken>("Could not parse this cashu token")
return GenericLoadable.Error<List<CashuToken>>("Could not parse this cashu token")
}
}

@Serializable
class V4Token(
val m: String, // mint
val u: String, // unit
val d: String? = null, // memo
val t: Array<V4T>?,
)

@Serializable
class V4T(
@ByteString
val i: ByteArray, // identifier
val p: Array<V4Proof>,
)

@Serializable
class V4Proof(
val a: Int, // amount
val s: String, // secret
@ByteString
val c: ByteArray, // signature
val d: V4DleqProof? = null, // no idea what this is
val w: String? = null, // witness
)

@Serializable
class V4DleqProof(
@ByteString
val e: ByteArray,
@ByteString
val s: ByteArray,
@ByteString
val r: ByteArray,
)

@OptIn(ExperimentalSerializationApi::class)
fun parseCashuB(cashuToken: String): GenericLoadable<List<CashuToken>> {
checkNotInMainThread()

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

return GenericLoadable.Loaded(converted)
} catch (e: Exception) {
e.printStackTrace()
if (e is CancellationException) throw e
return GenericLoadable.Error("Could not parse this cashu token")
}
}

Expand Down Expand Up @@ -209,7 +338,21 @@ class CashuProcessor {
val factory = Event.mapper.nodeFactory

val jsonObject = factory.objectNode()
jsonObject.put("proofs", token.proofs)

jsonObject.replace(
"proofs",
factory.arrayNode(token.proofs.size).apply {
token.proofs.forEach {
addObject().apply {
put("amount", it.amount)
put("id", it.id)
put("secret", it.secret)
put("C", it.C)
}
}
},
)

jsonObject.put("pr", invoice)

val mediaType = "application/json; charset=utf-8".toMediaType()
Expand Down
Loading

0 comments on commit fc98442

Please sign in to comment.