mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
feat(blossom): BUD-07 confirm-then-pay for paid-server mirroring
Adds a confirm-then-pay flow so a 402 from a paid Blossom server can be settled from the app instead of only being reported. - quartz: BlossomPaymentProof (settled Cashu token / lightning preimage) with the X-Cashu / X-Lightning retry headers; BlossomClient.mirror accepts a proof. - BlossomPaymentHandler (Android): pays the challenge's BOLT-11 invoice via the account's existing NIP-47 (NWC) wallet and returns the preimage — it never handles keys or funds itself, only drives the connected wallet. Decodes the invoice amount for display. - Blob manager: a mirror that hits 402 now raises a payment prompt; a dialog shows the amount and, on confirm, pays and retries the mirror, then continues with the remaining servers. Cancel leaves the blob unmirrored. Cashu-only servers and the composer upload path still surface a clear message; auto-settlement there can reuse this handler next. Not yet validated against a live paid server. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
This commit is contained in:
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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.amethyst.service.uploads.blossom
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentProof
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentRequired
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/**
|
||||
* Settles a BUD-07 [BlossomPaymentRequired] challenge so a blocked upload/mirror can
|
||||
* be retried. Reuses the account's existing NIP-47 (Nostr Wallet Connect) lightning
|
||||
* path — this handler never touches keys or moves funds itself, it only asks the
|
||||
* user's connected wallet to pay the invoice and returns the resulting preimage as
|
||||
* the [BlossomPaymentProof].
|
||||
*
|
||||
* Cashu-only servers (`X-Cashu`, no `X-Lightning`) are not yet supported here; the
|
||||
* caller should surface that a lightning wallet is required.
|
||||
*/
|
||||
object BlossomPaymentHandler {
|
||||
/** True when this account has a wallet we can pay the lightning invoice with. */
|
||||
fun canPay(
|
||||
account: Account,
|
||||
payment: BlossomPaymentRequired,
|
||||
): Boolean = payment.lightning != null && account.nip47SignerState.hasWalletConnectSetup()
|
||||
|
||||
/** The invoice amount in sats, for display in a confirmation prompt. */
|
||||
fun amountSats(payment: BlossomPaymentRequired): Long? =
|
||||
payment.lightning?.let {
|
||||
runCatching { LnInvoiceUtil.getAmountInSats(it).toLong() }.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pays the challenge's BOLT-11 invoice via NWC and returns the proof, or null if
|
||||
* there is no payable invoice, no wallet, or the wallet didn't confirm in time.
|
||||
*/
|
||||
suspend fun pay(
|
||||
account: Account,
|
||||
payment: BlossomPaymentRequired,
|
||||
): BlossomPaymentProof? {
|
||||
val invoice = payment.lightning ?: return null
|
||||
if (!account.nip47SignerState.hasWalletConnectSetup()) return null
|
||||
|
||||
val preimageResult = CompletableDeferred<String?>()
|
||||
try {
|
||||
account.sendZapPaymentRequestFor(invoice, null) { response ->
|
||||
// CompletableDeferred.complete is idempotent, so extra callbacks are harmless.
|
||||
preimageResult.complete((response as? PayInvoiceSuccessResponse)?.result?.preimage)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("BlossomPayment", "Failed to send NWC payment request", e)
|
||||
return null
|
||||
}
|
||||
|
||||
val preimage = withTimeoutOrNull(90_000) { preimageResult.await() } ?: return null
|
||||
return BlossomPaymentProof(lightningPreimage = preimage)
|
||||
}
|
||||
}
|
||||
+47
@@ -60,6 +60,7 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -90,6 +91,16 @@ fun BlossomBlobManagerScreen(
|
||||
val blobs by vm.blobs.collectAsStateWithLifecycle()
|
||||
val loading by vm.isLoading.collectAsStateWithLifecycle()
|
||||
val error by vm.error.collectAsStateWithLifecycle()
|
||||
val pendingPayment by vm.pendingPayment.collectAsStateWithLifecycle()
|
||||
|
||||
pendingPayment?.let { pending ->
|
||||
BlossomPaymentDialog(
|
||||
amountSats = pending.amountSats,
|
||||
reason = pending.payment.reason,
|
||||
onConfirm = { vm.confirmPendingPayment() },
|
||||
onDismiss = { vm.cancelPendingPayment() },
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopBarWithBackButton(stringRes(R.string.manage_stored_files), nav) },
|
||||
@@ -279,6 +290,42 @@ private fun BlobCard(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BlossomPaymentDialog(
|
||||
amountSats: Long?,
|
||||
reason: String?,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringRes(R.string.blossom_payment_title)) },
|
||||
text = {
|
||||
Text(
|
||||
text =
|
||||
listOfNotNull(
|
||||
stringRes(R.string.blossom_payment_message),
|
||||
reason,
|
||||
).joinToString("\n\n"),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = onConfirm) {
|
||||
Text(
|
||||
if (amountSats != null) {
|
||||
pluralStringResource(R.plurals.blossom_pay_sats, amountSats.toInt(), amountSats.toInt())
|
||||
} else {
|
||||
stringRes(R.string.blossom_pay)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun humanBytes(bytes: Long): String =
|
||||
when {
|
||||
bytes >= 1_000_000 -> "${bytes / 1_000_000} MB"
|
||||
|
||||
+64
-9
@@ -26,10 +26,14 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomPaymentHandler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportType
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentProof
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentRequired
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomReport
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
@@ -54,6 +58,15 @@ data class BlobRow(
|
||||
val serversMissing: List<String>,
|
||||
)
|
||||
|
||||
/** A BUD-07 payment prompt raised while mirroring [row] to [target]. */
|
||||
@Immutable
|
||||
data class PendingMirrorPayment(
|
||||
val row: BlobRow,
|
||||
val target: String,
|
||||
val payment: BlossomPaymentRequired,
|
||||
val amountSats: Long?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Backs the Blossom blob-manager screen. For the active account it fans a
|
||||
* `GET /list/<pubkey>` (BUD-02) across every server in the user's kind-10063 list,
|
||||
@@ -74,6 +87,9 @@ class BlossomBlobManagerViewModel : ViewModel() {
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error = _error.asStateFlow()
|
||||
|
||||
private val _pendingPayment = MutableStateFlow<PendingMirrorPayment?>(null)
|
||||
val pendingPayment = _pendingPayment.asStateFlow()
|
||||
|
||||
fun init(accountViewModel: AccountViewModel) {
|
||||
this.account = accountViewModel.account
|
||||
}
|
||||
@@ -176,27 +192,66 @@ class BlossomBlobManagerViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
/** BUD-04: mirror a blob to every server in the user's list that doesn't have it yet. */
|
||||
fun mirrorToMissing(
|
||||
row: BlobRow,
|
||||
onDone: (Int) -> Unit = {},
|
||||
) {
|
||||
val source = row.url ?: return onDone(0)
|
||||
fun mirrorToMissing(row: BlobRow) {
|
||||
val source = row.url ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
var mirrored = 0
|
||||
row.serversMissing.forEach { target ->
|
||||
for (target in row.serversMissing) {
|
||||
try {
|
||||
val auth = account.createBlossomUploadAuth(row.hash, row.size ?: 0L, "Mirror ${row.hash}", listOf(target)).toAuthorizationHeader()
|
||||
clientFor(target).mirror(source, target, auth)
|
||||
mirrorOne(source, row, target, null)
|
||||
mirrored++
|
||||
} catch (e: BlossomPaymentException) {
|
||||
// BUD-07: this server wants payment. Pause and ask the user to confirm;
|
||||
// the rest of the servers are retried after they decide.
|
||||
if (BlossomPaymentHandler.canPay(account, e.payment)) {
|
||||
_pendingPayment.value = PendingMirrorPayment(row, target, e.payment, BlossomPaymentHandler.amountSats(e.payment))
|
||||
return@launch
|
||||
}
|
||||
Log.w("BlossomBlobManager", "mirror to $target needs unsupported payment", e)
|
||||
} catch (e: Exception) {
|
||||
Log.w("BlossomBlobManager", "mirror to $target failed", e)
|
||||
}
|
||||
}
|
||||
if (mirrored > 0) refresh()
|
||||
withContext(Dispatchers.Main) { onDone(mirrored) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun mirrorOne(
|
||||
source: String,
|
||||
row: BlobRow,
|
||||
target: String,
|
||||
proof: BlossomPaymentProof?,
|
||||
) {
|
||||
val auth = account.createBlossomUploadAuth(row.hash, row.size ?: 0L, "Mirror ${row.hash}", listOf(target)).toAuthorizationHeader()
|
||||
clientFor(target).mirror(source, target, auth, proof)
|
||||
}
|
||||
|
||||
/** User confirmed the BUD-07 prompt: pay via the wallet, retry, then continue with the rest. */
|
||||
fun confirmPendingPayment() {
|
||||
val pending = _pendingPayment.value ?: return
|
||||
_pendingPayment.value = null
|
||||
val source = pending.row.url ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val proof = BlossomPaymentHandler.pay(account, pending.payment)
|
||||
if (proof == null) {
|
||||
_error.value = "Payment failed or was not confirmed by the wallet."
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
mirrorOne(source, pending.row, pending.target, proof)
|
||||
} catch (e: Exception) {
|
||||
Log.w("BlossomBlobManager", "paid mirror to ${pending.target} failed", e)
|
||||
}
|
||||
// Continue mirroring to any remaining servers (which may prompt again).
|
||||
refresh()
|
||||
mirrorToMissing(pending.row.copy(serversMissing = pending.row.serversMissing.filter { it != pending.target }))
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelPendingPayment() {
|
||||
_pendingPayment.value = null
|
||||
}
|
||||
|
||||
/** BUD-09: report a blob to a server as problematic content. */
|
||||
fun report(
|
||||
hash: HexKey,
|
||||
|
||||
@@ -1557,6 +1557,13 @@
|
||||
<string name="blossom_report">Report</string>
|
||||
<string name="blossom_open">Open</string>
|
||||
<string name="blossom_payment_required">This server requires payment to upload: %1$s</string>
|
||||
<string name="blossom_payment_title">Payment required</string>
|
||||
<string name="blossom_payment_message">This server charges a lightning payment to store the file. Pay from your connected wallet to continue.</string>
|
||||
<string name="blossom_pay">Pay</string>
|
||||
<plurals name="blossom_pay_sats">
|
||||
<item quantity="one">Pay %1$d sat</item>
|
||||
<item quantity="other">Pay %1$d sats</item>
|
||||
</plurals>
|
||||
<string name="blossom_report_title">Report blob</string>
|
||||
<string name="blossom_report_comment_hint">Reason (optional)</string>
|
||||
<string name="blossom_send">Send</string>
|
||||
|
||||
+6
-2
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.service.upload
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentProof
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentRequired
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
|
||||
@@ -114,6 +115,7 @@ open class BlossomClient(
|
||||
sourceUrl: String,
|
||||
serverBaseUrl: String,
|
||||
authHeader: String?,
|
||||
paymentProof: BlossomPaymentProof? = null,
|
||||
): BlossomUploadResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val body = JsonMapper.toJson(MirrorRequest(sourceUrl)).toRequestBody("application/json".toMediaType())
|
||||
@@ -121,8 +123,10 @@ open class BlossomClient(
|
||||
Request
|
||||
.Builder()
|
||||
.url(BlossomServerUrl.mirror(serverBaseUrl))
|
||||
.apply { authHeader?.let { addHeader("Authorization", it) } }
|
||||
.put(body)
|
||||
.apply {
|
||||
authHeader?.let { addHeader("Authorization", it) }
|
||||
paymentProof?.headers()?.forEach { (name, value) -> addHeader(name, value) }
|
||||
}.put(body)
|
||||
.build()
|
||||
okHttpClient.newCall(request).execute().use { parseDescriptor(it, serverBaseUrl) }
|
||||
}
|
||||
|
||||
+17
@@ -56,3 +56,20 @@ data class BlossomPaymentRequired(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The proof a client sends when retrying a request after settling a BUD-07 [BlossomPaymentRequired]:
|
||||
* a settled Cashu token (echoed in `X-Cashu`) or the preimage of the paid BOLT-11 invoice
|
||||
* (echoed in `X-Lightning`).
|
||||
*/
|
||||
data class BlossomPaymentProof(
|
||||
val cashu: String? = null,
|
||||
val lightningPreimage: String? = null,
|
||||
) {
|
||||
/** The header name/value pairs to attach to the retried request. */
|
||||
fun headers(): List<Pair<String, String>> =
|
||||
buildList {
|
||||
cashu?.let { add(BlossomServerUrl.X_CASHU_HEADER to it) }
|
||||
lightningPreimage?.let { add(BlossomServerUrl.X_LIGHTNING_HEADER to it) }
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -105,4 +105,17 @@ class BlossomUploadResultTest {
|
||||
val payment = BlossomPaymentRequired.fromHeaders { null }
|
||||
assertEquals(false, payment.hasPaymentOption())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paymentProofBuildsRetryHeaders() {
|
||||
assertEquals(
|
||||
listOf(BlossomServerUrl.X_LIGHTNING_HEADER to "preimageabc"),
|
||||
BlossomPaymentProof(lightningPreimage = "preimageabc").headers(),
|
||||
)
|
||||
assertEquals(
|
||||
listOf(BlossomServerUrl.X_CASHU_HEADER to "cashuToken"),
|
||||
BlossomPaymentProof(cashu = "cashuToken").headers(),
|
||||
)
|
||||
assertEquals(emptyList(), BlossomPaymentProof().headers())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user