mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2025-11-10 22:46:49 +01:00
Extracts Cashu logic into a service class.
This commit is contained in:
@@ -0,0 +1,88 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import com.google.gson.JsonArray
|
||||||
|
import com.google.gson.JsonObject
|
||||||
|
import com.google.gson.JsonParser
|
||||||
|
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import java.util.Base64
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class CashuToken(
|
||||||
|
val mint: String,
|
||||||
|
val totalAmount: Long,
|
||||||
|
val fees: Int,
|
||||||
|
val redeemInvoiceAmount: Long,
|
||||||
|
val proofs: JsonArray
|
||||||
|
)
|
||||||
|
|
||||||
|
class CashuProcessor {
|
||||||
|
fun parse(cashutoken: String): CashuToken {
|
||||||
|
val base64token = cashutoken.replace("cashuA", "")
|
||||||
|
val cashu = JsonParser.parseString(String(Base64.getDecoder().decode(base64token)))
|
||||||
|
val token = cashu.asJsonObject.get("token").asJsonArray[0].asJsonObject
|
||||||
|
val proofs = token["proofs"].asJsonArray
|
||||||
|
val mint = token["mint"].asString
|
||||||
|
|
||||||
|
var totalAmount = 0L
|
||||||
|
for (proof in proofs) {
|
||||||
|
totalAmount += proof.asJsonObject["amount"].asLong
|
||||||
|
}
|
||||||
|
val fees = Math.max(((totalAmount * 0.02).toInt()), 2)
|
||||||
|
val redeemInvoiceAmount = totalAmount - fees
|
||||||
|
|
||||||
|
return CashuToken(mint, totalAmount, fees, redeemInvoiceAmount, proofs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun melt(token: CashuToken, lud16: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||||
|
runCatching {
|
||||||
|
LightningAddressResolver().lnAddressInvoice(
|
||||||
|
lnaddress = lud16,
|
||||||
|
milliSats = token.redeemInvoiceAmount * 1000, // Make invoice and leave room for fees
|
||||||
|
message = "Reedem Cashu",
|
||||||
|
onSuccess = { invoice ->
|
||||||
|
meltInvoice(token, invoice, onSuccess, onError)
|
||||||
|
},
|
||||||
|
onProgress = {
|
||||||
|
},
|
||||||
|
onError = onError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun meltInvoice(token: CashuToken, invoice: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||||
|
try {
|
||||||
|
val client = HttpClient.getHttpClient()
|
||||||
|
val url = token.mint + "/melt" // Melt cashu tokens at Mint
|
||||||
|
|
||||||
|
val jsonObject = JsonObject()
|
||||||
|
jsonObject.add("proofs", token.proofs)
|
||||||
|
jsonObject.addProperty("pr", invoice)
|
||||||
|
|
||||||
|
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||||
|
val requestBody = jsonObject.toString().toRequestBody(mediaType)
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.post(requestBody)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val response = client.newCall(request).execute()
|
||||||
|
val body = response.body.string()
|
||||||
|
val tree = jacksonObjectMapper().readTree(body)
|
||||||
|
|
||||||
|
val successful = tree?.get("paid")?.asText() == "true"
|
||||||
|
|
||||||
|
if (successful) {
|
||||||
|
onSuccess("Redeemed ${token.totalAmount} Sats" + " (Fees: ${token.fees} Sats)")
|
||||||
|
} else {
|
||||||
|
onError(tree?.get("detail")?.asText()?.split('.')?.getOrNull(0) ?: "Error")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError("Token melt failure: " + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.components
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -19,41 +20,51 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.content.ContextCompat.startActivity
|
import androidx.core.content.ContextCompat.startActivity
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
|
||||||
import com.google.gson.JsonObject
|
|
||||||
import com.google.gson.JsonParser
|
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
import com.vitorpamplona.amethyst.service.CashuProcessor
|
||||||
|
import com.vitorpamplona.amethyst.service.CashuToken
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import java.util.Base64
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CashuPreview(cashutoken: String, accountViewModel: AccountViewModel) {
|
fun CashuPreview(cashutoken: String, accountViewModel: AccountViewModel) {
|
||||||
|
var cachuData by remember { mutableStateOf<CashuToken?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(key1 = cashutoken) {
|
||||||
|
launch(Dispatchers.IO) {
|
||||||
|
cachuData = CashuProcessor().parse(cashutoken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Crossfade(targetState = cachuData) {
|
||||||
|
if (it != null) {
|
||||||
|
CashuPreview(it, accountViewModel)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "$cashutoken ",
|
||||||
|
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CashuPreview(token: CashuToken, accountViewModel: AccountViewModel) {
|
||||||
|
val lud16 = remember(accountViewModel) {
|
||||||
|
accountViewModel.account.userProfile().info?.lud16
|
||||||
|
}
|
||||||
|
|
||||||
val useWebService = false
|
val useWebService = false
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val lud16 = accountViewModel.account.userProfile().info?.lud16
|
|
||||||
val base64token = cashutoken.replace("cashuA", "")
|
|
||||||
val cashu = JsonParser.parseString(String(Base64.getDecoder().decode(base64token)))
|
|
||||||
val token = cashu.asJsonObject.get("token").asJsonArray[0].asJsonObject
|
|
||||||
val proofs = token["proofs"].asJsonArray
|
|
||||||
val mint = token["mint"]
|
|
||||||
|
|
||||||
var totalamount = 0
|
|
||||||
for (proof in proofs) {
|
|
||||||
totalamount += proof.asJsonObject["amount"].asInt
|
|
||||||
}
|
|
||||||
var fees = Math.max(((totalamount * 0.02).toInt()), 2)
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -89,7 +100,7 @@ fun CashuPreview(cashutoken: String, accountViewModel: AccountViewModel) {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
totalamount?.let {
|
token.totalAmount.let {
|
||||||
Text(
|
Text(
|
||||||
text = "$it ${stringResource(id = R.string.sats)}",
|
text = "$it ${stringResource(id = R.string.sats)}",
|
||||||
fontSize = 25.sp,
|
fontSize = 25.sp,
|
||||||
@@ -107,54 +118,25 @@ fun CashuPreview(cashutoken: String, accountViewModel: AccountViewModel) {
|
|||||||
onClick = {
|
onClick = {
|
||||||
// Just in case we want to use a webservice instead of directly contacting the mint
|
// Just in case we want to use a webservice instead of directly contacting the mint
|
||||||
if (useWebService) {
|
if (useWebService) {
|
||||||
val url = "https://redeem.cashu.me?token=$cashutoken&lightning=$lud16&autopay=true"
|
val url = "https://redeem.cashu.me?token=$token&lightning=$lud16&autopay=true"
|
||||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||||
startActivity(context, intent, null)
|
startActivity(context, intent, null)
|
||||||
} else {
|
} else {
|
||||||
if (lud16 != null) {
|
if (lud16 != null) {
|
||||||
runCatching {
|
CashuProcessor().melt(
|
||||||
LightningAddressResolver().lnAddressInvoice(
|
token,
|
||||||
lud16,
|
lud16,
|
||||||
((totalamount - fees) * 1000).toLong(), // Make invoice and leave room for fees
|
onSuccess = {
|
||||||
"Reedem Cashu",
|
scope.launch {
|
||||||
onSuccess = {
|
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
|
||||||
val invoice = it
|
|
||||||
val client = OkHttpClient()
|
|
||||||
var url = mint.asString + "/melt" // Melt cashu tokens at Mint
|
|
||||||
|
|
||||||
val jsonObject = JsonObject()
|
|
||||||
jsonObject.add("proofs", proofs)
|
|
||||||
jsonObject.addProperty("pr", invoice)
|
|
||||||
|
|
||||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
|
||||||
val requestBody = jsonObject.toString().toRequestBody(mediaType)
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(url)
|
|
||||||
.post(requestBody)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val response = client.newCall(request).execute()
|
|
||||||
val body = response.body?.string()
|
|
||||||
val tree = jacksonObjectMapper().readTree(body)
|
|
||||||
if (tree?.get("paid")?.asText() == "true") {
|
|
||||||
scope.launch {
|
|
||||||
Toast.makeText(context, "Redeemed " + (totalamount - fees) + " Sats" + " (Fees: " + fees + " Sats)", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
scope.launch {
|
|
||||||
Toast.makeText(context, tree?.get("detail")?.asText()?.split('.')?.get(0) + "." ?: "Error", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onProgress = {
|
|
||||||
},
|
|
||||||
onError = {
|
|
||||||
scope.launch {
|
|
||||||
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
},
|
||||||
}
|
onError = {
|
||||||
|
scope.launch {
|
||||||
|
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
Toast.makeText(context, "No Lightning Address set", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "No Lightning Address set", Toast.LENGTH_SHORT).show()
|
||||||
|
|||||||
@@ -456,7 +456,7 @@
|
|||||||
<string name="minimum_pow">Minimum PoW</string>
|
<string name="minimum_pow">Minimum PoW</string>
|
||||||
<string name="auth">Auth</string>
|
<string name="auth">Auth</string>
|
||||||
<string name="payment">Payment</string>
|
<string name="payment">Payment</string>
|
||||||
<string name="cashu">Cashu ecash</string>
|
<string name="cashu">Cashu Token</string>
|
||||||
<string name="cashu_redeem">Redeem</string>
|
<string name="cashu_redeem">Redeem</string>
|
||||||
|
|
||||||
<string name="live_stream_live_tag">LIVE</string>
|
<string name="live_stream_live_tag">LIVE</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user