From 9bc436b0c28f674739655e29f7f1bbc2dc3a09c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 01:18:46 +0000 Subject: [PATCH] feat(blossom): BUD-07 confirm-then-pay for paid-server mirroring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP --- .../uploads/blossom/BlossomPaymentHandler.kt | 80 +++++++++++++++++++ .../mediaServers/BlossomBlobManagerScreen.kt | 47 +++++++++++ .../BlossomBlobManagerViewModel.kt | 73 ++++++++++++++--- amethyst/src/main/res/values/strings.xml | 7 ++ .../commons/service/upload/BlossomClient.kt | 8 +- .../nipB7Blossom/BlossomPaymentRequired.kt | 17 ++++ .../nipB7Blossom/BlossomUploadResultTest.kt | 13 +++ 7 files changed, 234 insertions(+), 11 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt new file mode 100644 index 0000000000..1de97b9753 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt @@ -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() + 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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt index 7bbec1f65d..344f4bf900 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt @@ -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" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt index 39acd1ee82..4ac5b63efc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt @@ -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, ) +/** 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/` (BUD-02) across every server in the user's kind-10063 list, @@ -74,6 +87,9 @@ class BlossomBlobManagerViewModel : ViewModel() { private val _error = MutableStateFlow(null) val error = _error.asStateFlow() + private val _pendingPayment = MutableStateFlow(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, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 65338562ce..0dc07c6ec3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1557,6 +1557,13 @@ Report Open This server requires payment to upload: %1$s + Payment required + This server charges a lightning payment to store the file. Pay from your connected wallet to continue. + Pay + + Pay %1$d sat + Pay %1$d sats + Report blob Reason (optional) Send diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt index 510631c6d2..4bafe94247 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt @@ -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) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomPaymentRequired.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomPaymentRequired.kt index 7b13a75332..63a80b0611 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomPaymentRequired.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomPaymentRequired.kt @@ -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> = + buildList { + cashu?.let { add(BlossomServerUrl.X_CASHU_HEADER to it) } + lightningPreimage?.let { add(BlossomServerUrl.X_LIGHTNING_HEADER to it) } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUploadResultTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUploadResultTest.kt index be732c00fd..6682cc79d7 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUploadResultTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUploadResultTest.kt @@ -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()) + } }