diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt index 9c2e2637c..c7c20e920 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt @@ -18,8 +18,6 @@ data class CashuToken( val token: String, val mint: String, val totalAmount: Long, - val fees: Int, - val redeemInvoiceAmount: Long, val proofs: JsonNode ) @@ -38,10 +36,8 @@ class CashuProcessor { for (proof in proofs) { totalAmount += proof.get("amount").asLong() } - val fees = Math.max(((totalAmount * 0.02).toInt()), 2) - val redeemInvoiceAmount = totalAmount - fees - return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, fees, redeemInvoiceAmount, proofs)) + return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, proofs)) } catch (e: Exception) { return GenericLoadable.Error("Could not parse this cashu token") } @@ -53,10 +49,29 @@ class CashuProcessor { runCatching { LightningAddressResolver().lnAddressInvoice( lnaddress = lud16, - milliSats = token.redeemInvoiceAmount * 1000, // Make invoice and leave room for fees - message = "Redeem Cashu", - onSuccess = { invoice -> - meltInvoice(token, invoice, onSuccess, onError, context) + milliSats = token.totalAmount * 1000, // Make invoice and leave room for fees + message = "Calculate Fees for Cashu", + onSuccess = { baseInvoice -> + feeCalculator( + token.mint, + baseInvoice, + onSuccess = { fees -> + LightningAddressResolver().lnAddressInvoice( + lnaddress = lud16, + milliSats = (token.totalAmount - fees) * 1000, // Make invoice and leave room for fees + message = "Redeem Cashu", + onSuccess = { invoice -> + meltInvoice(token, invoice, fees, onSuccess, onError, context) + }, + onProgress = { + }, + onError = onError, + context = context + ) + }, + onError = onError, + context + ) }, onProgress = { }, @@ -66,7 +81,59 @@ class CashuProcessor { } } - private fun meltInvoice(token: CashuToken, invoice: String, onSuccess: (String, String) -> Unit, onError: (String, String) -> Unit, context: Context) { + fun feeCalculator( + mintAddress: String, + invoice: String, + onSuccess: (Int) -> Unit, + onError: (String, String) -> Unit, + context: Context + ) { + checkNotInMainThread() + + try { + val client = HttpClient.getHttpClient() + val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint + + val factory = Event.mapper.nodeFactory + + val jsonObject = factory.objectNode() + jsonObject.put("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() + + client.newCall(request).execute().use { + val body = it.body.string() + val tree = jacksonObjectMapper().readTree(body) + + val feeCost = tree?.get("fee")?.asInt() + + if (feeCost != null) { + onSuccess( + feeCost + ) + } else { + val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null } + onError( + context.getString(R.string.cashu_failed_redemption), + if (msg != null) { + context.getString(R.string.cashu_failed_redemption_explainer_error_msg, msg) + } else { + context.getString(R.string.cashu_failed_redemption_explainer_error_msg) + } + ) + } + } + } catch (e: Exception) { + onError(context.getString(R.string.cashu_sucessful_redemption), context.getString(R.string.cashu_failed_redemption_explainer_error_msg, e.message)) + } + } + + private fun meltInvoice(token: CashuToken, invoice: String, fees: Int, onSuccess: (String, String) -> Unit, onError: (String, String) -> Unit, context: Context) { try { val client = HttpClient.getHttpClient() val url = token.mint + "/melt" // Melt cashu tokens at Mint @@ -93,7 +160,7 @@ class CashuProcessor { if (successful) { onSuccess( context.getString(R.string.cashu_sucessful_redemption), - context.getString(R.string.cashu_sucessful_redemption_explainer, token.totalAmount.toString(), token.fees.toString()) + context.getString(R.string.cashu_sucessful_redemption_explainer, token.totalAmount.toString(), fees.toString()) ) } else { val msg = tree?.get("detail")?.asText()?.split('.')?.getOrNull(0)?.ifBlank { null } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt index 1c93bd1cd..86d37e81c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt @@ -6,6 +6,7 @@ import androidx.compose.animation.Crossfade import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -40,15 +41,19 @@ import androidx.core.content.ContextCompat.startActivity import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.CashuProcessor import com.vitorpamplona.amethyst.service.CashuToken +import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.subtleBorder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @Composable fun CashuPreview(cashutoken: String, accountViewModel: AccountViewModel) { - var cachuData by remember { mutableStateOf>(GenericLoadable.Loading()) } + var cachuData by remember { + mutableStateOf>(GenericLoadable.Loading()) + } LaunchedEffect(key1 = cashutoken) { launch(Dispatchers.IO) { @@ -59,7 +64,7 @@ fun CashuPreview(cashutoken: String, accountViewModel: AccountViewModel) { } } - Crossfade(targetState = cachuData) { + Crossfade(targetState = cachuData, label = "CashuPreview(") { when (it) { is GenericLoadable.Loaded -> CashuPreview(it.loaded, accountViewModel) is GenericLoadable.Error -> Text( @@ -85,14 +90,14 @@ fun CashuPreview(token: CashuToken, accountViewModel: AccountViewModel) { Column( modifier = Modifier .fillMaxWidth() - .padding(start = 30.dp, end = 30.dp) + .padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp) .clip(shape = QuoteBorder) .border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder) ) { Column( modifier = Modifier .fillMaxWidth() - .padding(30.dp) + .padding(20.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -117,37 +122,38 @@ fun CashuPreview(token: CashuToken, accountViewModel: AccountViewModel) { Divider() - token.totalAmount.let { - Text( - text = "$it ${stringResource(id = R.string.sats)}", - fontSize = 25.sp, - fontWeight = FontWeight.W500, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 10.dp) - ) - } - - Row( - + Text( + text = "${token.totalAmount} ${stringResource(id = R.string.sats)}", + fontSize = 25.sp, + fontWeight = FontWeight.W500, modifier = Modifier .fillMaxWidth() - .padding(bottom = 10.dp) - ) { - Button( + .padding(vertical = 10.dp) + ) - modifier = Modifier - .padding(vertical = 10.dp).padding(horizontal = 2.dp), + Row( + modifier = Modifier + .padding(top = 5.dp) + .fillMaxWidth() + ) { + var isRedeeming by remember { + mutableStateOf(false) + } + + Button( onClick = { if (lud16 != null) { scope.launch(Dispatchers.IO) { + isRedeeming = true CashuProcessor().melt( token, lud16, onSuccess = { title, message -> + isRedeeming = false accountViewModel.toast(title, message) }, onError = { title, message -> + isRedeeming = false accountViewModel.toast(title, message) }, context @@ -165,15 +171,22 @@ fun CashuPreview(token: CashuToken, accountViewModel: AccountViewModel) { containerColor = MaterialTheme.colorScheme.primary ) ) { + if (isRedeeming) { + LoadingAnimation() + } + + Spacer(modifier = StdHorzSpacer) + Text( stringResource(R.string.cashu_redeem), color = Color.White, fontSize = 18.sp ) } + + Spacer(modifier = StdHorzSpacer) + Button( - modifier = Modifier - .padding(vertical = 10.dp).padding(horizontal = 1.dp), onClick = { if (useWebService) { // In case we want to use the cashu.me webservice