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:
Claude
2026-07-18 01:18:46 +00:00
parent 4f661a501f
commit 9bc436b0c2
7 changed files with 234 additions and 11 deletions
@@ -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)
}
}
@@ -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"
@@ -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,
+7
View File
@@ -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>
@@ -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) }
}
@@ -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) }
}
}
@@ -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())
}
}