diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt new file mode 100644 index 0000000000..9a545e8aeb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt @@ -0,0 +1,253 @@ +/* + * 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 + +import android.content.Context +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord +import com.vitorpamplona.quartz.podcasts.PodcastBoostagram +import com.vitorpamplona.quartz.podcasts.PodcastValue +import com.vitorpamplona.quartz.podcasts.PodcastValueShare +import com.vitorpamplona.quartz.utils.mapNotNullAsync +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient + +/** + * Executes a Podcasting-2.0 value-for-value (V4V) split: takes a [PodcastValue] block and a total + * amount, computes each recipient's share ([PodcastValue.computeShares]) and pays them. + * + * This is the V4V analogue of [ZapPaymentHandler], but the recipients are raw Lightning destinations + * declared in the value block (not Nostr users), so there is no zap request and no zap receipt. Two + * recipient kinds are handled: + * + * - [PodcastValue.TYPE_LNADDRESS] — resolved to a BOLT-11 via LNURL-pay and paid through the user's + * default payment source (NWC, CLINK debit, or — when none is set — handed to an external wallet + * via [onPayInvoicesViaIntent]). Same rails as a zap. + * - [PodcastValue.TYPE_NODE] — paid by **keysend** (NIP-47 `pay_keysend`) carrying the Podcasting-2.0 + * boostagram TLV ([PodcastValue.PODCAST_TLV_RECORD]) plus any per-recipient custom TLV. Keysend is + * only available over NWC, so node recipients are skipped (with an error) when no NWC wallet is set + * up. + */ +class V4VPaymentHandler( + val account: Account, +) { + /** A resolved lnaddress share ready to pay: the share plus the BOLT-11 fetched for it. */ + class InvoicePayable( + val share: PodcastValueShare, + val invoice: String, + ) + + suspend fun pay( + value: PodcastValue, + totalMilliSats: Long, + boostagram: PodcastBoostagram, + zappedNote: Note?, + context: Context, + okHttpClient: (String) -> OkHttpClient, + onError: (title: String, message: String) -> Unit, + onProgress: (percent: Float) -> Unit, + onPayInvoicesViaIntent: (invoices: List) -> Unit, + ) = withContext(Dispatchers.IO) { + val shares = value.computeShares(totalMilliSats) + if (shares.isEmpty()) { + onError( + stringRes(context, R.string.podcast_value_error_title), + stringRes(context, R.string.podcast_value_no_recipients), + ) + return@withContext + } + + val nodeShares = shares.filter { it.recipient.type == PodcastValue.TYPE_NODE } + val lnAddressShares = shares.filter { it.recipient.type == PodcastValue.TYPE_LNADDRESS } + + onProgress(0.05f) + + // Keysend (node) recipients can only be paid over NWC. + if (nodeShares.isNotEmpty()) { + if (account.nip47SignerState.hasWalletConnectSetup()) { + payNodeSharesViaKeysend(nodeShares, boostagram, context, onError) + } else { + onError( + stringRes(context, R.string.podcast_value_error_title), + stringRes(context, R.string.podcast_value_keysend_requires_nwc), + ) + } + } + + if (lnAddressShares.isNotEmpty()) { + val payables = + assembleInvoices( + shares = lnAddressShares, + message = boostagram.message.orEmpty(), + okHttpClient = okHttpClient, + context = context, + onError = onError, + onProgress = { onProgress(it * 0.6f + 0.1f) }, + ) + payInvoices(payables, zappedNote, context, onError, onPayInvoicesViaIntent) { + onProgress(it * 0.25f + 0.7f) + } + } + + onProgress(1f) + } + + /** Hex-encodes a TLV value string as NIP-47 `pay_keysend` requires (UTF-8 bytes → hex). */ + private fun hexTlv(value: String): String = value.encodeToByteArray().toHexKey() + + private suspend fun payNodeSharesViaKeysend( + shares: List, + boostagram: PodcastBoostagram, + context: Context, + onError: (String, String) -> Unit, + ) { + val metadataTlv = TlvRecord(PodcastValue.PODCAST_TLV_RECORD, hexTlv(boostagram.toJson())) + + shares.forEach { share -> + val pubkey = share.recipient.address ?: return@forEach + + val tlvRecords = mutableListOf(metadataTlv) + val customType = share.recipient.customKey?.toLongOrNull() + val customValue = share.recipient.customValue + if (customType != null && customValue != null) { + tlvRecords.add(TlvRecord(customType, hexTlv(customValue))) + } + + val request = + PayKeysendMethod.create( + amount = share.amountMilliSats, + pubkey = pubkey, + tlvRecords = tlvRecords, + ) + + account.sendNwcRequest(request) { response: Response? -> + if (response is IErrorResponseLike) { + onError( + stringRes(context, R.string.error_dialog_pay_invoice_error), + response.errorMessage() + ?: stringRes(context, R.string.error_parsing_error_message), + ) + } + } + } + } + + private suspend fun assembleInvoices( + shares: List, + message: String, + okHttpClient: (String) -> OkHttpClient, + context: Context, + onError: (String, String) -> Unit, + onProgress: (percent: Float) -> Unit, + ): List { + var progress = 0f + return mapNotNullAsync(shares) { share: PodcastValueShare -> + val lnAddress = share.recipient.address ?: return@mapNotNullAsync null + try { + val invoice = + LightningAddressResolver().lnAddressInvoice( + lnAddress = lnAddress, + milliSats = share.amountMilliSats, + message = message, + nostrRequest = null, + okHttpClient = okHttpClient, + onProgress = {}, + context = context, + ) + progress += 1f / shares.size + onProgress(progress) + InvoicePayable(share, invoice) + } catch (e: LightningAddressResolver.LightningAddressError) { + onError(e.title, e.msg) + null + } catch (e: Exception) { + if (e is CancellationException) throw e + onError( + stringRes(context, R.string.error_unable_to_fetch_invoice), + e.message ?: stringRes(context, R.string.error_parsing_error_message), + ) + null + } + } + } + + private suspend fun payInvoices( + payables: List, + zappedNote: Note?, + context: Context, + onError: (String, String) -> Unit, + onPayInvoicesViaIntent: (List) -> Unit, + onProgress: (percent: Float) -> Unit, + ) { + if (payables.isEmpty()) return + + when (val source = account.settings.defaultPaymentSource()) { + is PaymentSource.Nwc -> { + var done = 0 + payables.forEach { payable -> + account.sendZapPaymentRequestFor(payable.invoice, zappedNote) { response -> + if (response is IErrorResponseLike) { + onError( + stringRes(context, R.string.error_dialog_pay_invoice_error), + response.errorMessage() + ?: stringRes(context, R.string.error_parsing_error_message), + ) + } + } + done++ + onProgress(done.toFloat() / payables.size) + } + } + + is PaymentSource.ClinkDebit -> { + var done = 0 + payables.forEach { payable -> + val response = ClinkDebitPayer.payInvoice(account, source.wallet.pointer, payable.invoice) + if (response?.isOk() != true) { + onError( + stringRes(context, R.string.error_dialog_pay_invoice_error), + response?.failureDetail() + ?: stringRes(context, R.string.clink_debit_no_response), + ) + } + done++ + onProgress(done.toFloat() / payables.size) + } + } + + null -> { + onPayInvoicesViaIntent(payables.map { it.invoice }) + onProgress(1f) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt index 9004502454..a00834012c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt @@ -194,7 +194,15 @@ fun RenderPodcastEpisode( ) } - value?.takeIf { !makeItShort }?.let { PodcastValueSplits(it) } + value?.takeIf { !makeItShort }?.let { + PodcastValueSplits( + value = it, + note = note, + episodeName = title, + podcastName = null, + accountViewModel = accountViewModel, + ) + } markdown?.takeIf { !makeItShort }?.let { Spacer(Modifier.padding(top = 4.dp)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt index 24d6edbfdc..fb461c5907 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt @@ -175,7 +175,15 @@ fun RenderPodcastMetadata( ) } - value?.takeIf { !makeItShort }?.let { PodcastValueSplits(it) } + value?.takeIf { !makeItShort }?.let { + PodcastValueSplits( + value = it, + note = note, + episodeName = null, + podcastName = title, + accountViewModel = accountViewModel, + ) + } if (fundingUrls.isNotEmpty() && !makeItShort) { val uriHandler = LocalUriHandler.current diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastValueSplits.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastValueSplits.kt index fc82848a9a..e25d8232c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastValueSplits.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastValueSplits.kt @@ -22,36 +22,53 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.quartz.podcasts.PodcastValue /** - * Renders a Podcasting-2.0 value-for-value split as a tinted card: a "Value-for-Value" header and - * one row per recipient (name/address + its share of the split). This shows where the show or - * episode directs incoming sats; it does not (yet) execute the Lightning payments. + * Renders a Podcasting-2.0 value-for-value split as a tinted card: a "Value-for-Value" header, a + * "Send value" button (amount picker that fires the weighted Lightning split via + * [AccountViewModel.payV4V]), and one row per recipient (name/address + its share of the split). */ @Composable -fun PodcastValueSplits(value: PodcastValue) { +fun PodcastValueSplits( + value: PodcastValue, + note: Note, + episodeName: String?, + podcastName: String?, + accountViewModel: AccountViewModel, +) { val recipients = value.recipients.filter { it.split > 0 || it.address != null } if (recipients.isEmpty()) return @@ -69,6 +86,7 @@ fun PodcastValueSplits(value: PodcastValue) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth(), ) { Icon( symbol = MaterialSymbols.Bolt, @@ -81,7 +99,9 @@ fun PodcastValueSplits(value: PodcastValue) { style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f), ) + SendValueButton(value, note, episodeName, podcastName, accountViewModel) } recipients.forEach { recipient -> @@ -122,3 +142,65 @@ fun PodcastValueSplits(value: PodcastValue) { } } } + +/** + * "Send value" button: opens a dropdown of the account's configured zap amounts. Picking one fires + * the V4V split for that many sats through [AccountViewModel.payV4V] (which fans the weighted shares + * out to each recipient). The recipient list is fixed by the show/episode, so the only choice the + * user makes is the total amount. + */ +@Composable +private fun SendValueButton( + value: PodcastValue, + note: Note, + episodeName: String?, + podcastName: String?, + accountViewModel: AccountViewModel, +) { + val context = LocalContext.current + var expanded by remember { mutableStateOf(false) } + val choices = remember { accountViewModel.zapAmountChoices() } + + Box { + FilledTonalButton( + onClick = { expanded = true }, + enabled = choices.isNotEmpty(), + ) { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Text( + text = stringRes(R.string.podcast_value_send), + modifier = Modifier.padding(start = 6.dp), + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + choices.forEach { sats -> + DropdownMenuItem( + text = { Text("$sats ${stringRes(R.string.sats)}") }, + onClick = { + expanded = false + accountViewModel.toastManager.toast( + R.string.podcast_value_for_value, + R.string.podcast_value_sending, + ) + accountViewModel.payV4V( + value = value, + totalSats = sats, + podcastName = podcastName, + episodeName = episodeName, + zappedNote = note, + context = context, + ) + }, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 23def5aa6b..03acc10578 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuild import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.service.ClinkDebitPayer import com.vitorpamplona.amethyst.service.OnlineChecker +import com.vitorpamplona.amethyst.service.V4VPaymentHandler import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor import com.vitorpamplona.amethyst.service.checkNotInMainThread @@ -83,6 +84,7 @@ import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus +import com.vitorpamplona.amethyst.ui.note.payViaIntent import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.showAmountInteger import com.vitorpamplona.amethyst.ui.screen.UiSettingsState @@ -157,6 +159,8 @@ import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip92IMeta.imeta import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.podcasts.PodcastBoostagram +import com.vitorpamplona.quartz.podcasts.PodcastValue import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -952,6 +956,51 @@ class AccountViewModel( ) } + /** + * Executes a Podcasting-2.0 value-for-value split for [totalSats] sats: pays every recipient in + * the show/episode's [PodcastValue] block their weighted share (lnaddress via LNURL-pay, node via + * NWC keysend with the boostagram TLV). Errors surface on [toastManager]; progress on [onProgress]. + */ + fun payV4V( + value: PodcastValue, + totalSats: Long, + podcastName: String?, + episodeName: String?, + zappedNote: Note?, + context: Context, + onProgress: (Float) -> Unit = {}, + ) = launchSigner { + val boostagram = + PodcastBoostagram( + podcast = podcastName, + episode = episodeName, + action = PodcastBoostagram.ACTION_BOOST, + appName = "Amethyst", + valueMsatTotal = totalSats * 1000, + senderName = account.userProfile().toBestDisplayName(), + ) + + V4VPaymentHandler(account).pay( + value = value, + totalMilliSats = totalSats * 1000, + boostagram = boostagram, + zappedNote = zappedNote, + context = context, + okHttpClient = httpClientBuilder::okHttpClientForMoney, + onError = { title, message -> + toastManager.toast(title, message) + }, + onProgress = onProgress, + onPayInvoicesViaIntent = { invoices -> + invoices.forEach { invoice -> + payViaIntent(invoice, context, onPaid = {}, onError = { + toastManager.toast(stringRes(context, R.string.error_dialog_zap_error), it) + }) + } + }, + ) + } + /** * Fire-and-forget NIP-61 nutzap from the zap picker. Picks a mint the * recipient accepts (via their kind:10019) that we also have proofs at, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 10595a9fc1..a53d94fd1a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -932,6 +932,12 @@ Co-host Editor Verified author + Send value + Sending value… + Value sent + Value-for-Value error + This podcast has no payable value recipients. + Connect a Nostr Wallet Connect wallet to send to keysend (node) recipients. %1$d episode %1$d episodes diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastBoostagram.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastBoostagram.kt new file mode 100644 index 0000000000..f45bb32a82 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastBoostagram.kt @@ -0,0 +1,57 @@ +/* + * 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.quartz.podcasts + +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * The Podcasting-2.0 keysend metadata blob ("boostagram") carried in TLV record + * [PodcastValue.PODCAST_TLV_RECORD] (7629169). It tells the receiving node which podcast/episode the + * payment is for and how much was sent in total. Field names follow the satoshis.stream convention + * (). + * + * Unset fields are omitted from the JSON ([JsonMapper] does not encode defaults), keeping the record + * small enough to fit comfortably inside a keysend onion. + */ +@Serializable +class PodcastBoostagram( + val podcast: String? = null, + val episode: String? = null, + /** "stream" for per-minute streaming sats, "boost" for a deliberate lump-sum tip. */ + val action: String? = null, + @SerialName("app_name") + val appName: String? = null, + /** Total sats (not millisats) the listener sent across all splits. */ + @SerialName("value_msat_total") + val valueMsatTotal: Long? = null, + val message: String? = null, + @SerialName("sender_name") + val senderName: String? = null, +) { + fun toJson(): String = JsonMapper.toJson(this) + + companion object { + const val ACTION_STREAM = "stream" + const val ACTION_BOOST = "boost" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValue.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValue.kt index 1d32d44956..5f5cf7c270 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValue.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValue.kt @@ -46,8 +46,79 @@ class PodcastValue( ) { /** Sum of recipient splits, used to turn each [PodcastValueRecipient.split] into a share. */ fun totalSplit(): Int = recipients.sumOf { it.split } + + /** + * Splits [totalMilliSats] across the recipients per the Podcasting-2.0 value rules and returns + * the non-zero shares (recipient + amount in millisats), preserving recipient order. + * + * - A recipient with [PodcastValueRecipient.fee] = true takes its [PodcastValueRecipient.split] + * as a **percentage of the total**, off the top (e.g. an app/host fee). + * - The remainder is divided among the non-fee recipients **by relative weight** + * ([PodcastValueRecipient.split] / sum of non-fee splits). + * + * Recipients without a payable [PodcastValueRecipient.address] or with a non-positive split are + * ignored. Integer division floors each share, so a few millisats may go unallocated (dust) — + * acceptable for value-for-value streaming. + */ + fun computeShares(totalMilliSats: Long): List { + if (totalMilliSats <= 0) return emptyList() + + val active = recipients.filter { it.split > 0 && !it.address.isNullOrBlank() } + if (active.isEmpty()) return emptyList() + + var feeTotalMillis = 0L + val feeAmounts = HashMap() + for (recipient in active) { + if (recipient.fee == true) { + val millis = totalMilliSats * recipient.split / 100 + if (millis > 0) { + feeAmounts[recipient] = millis + feeTotalMillis += millis + } + } + } + + val remainder = (totalMilliSats - feeTotalMillis).coerceAtLeast(0) + val sharedWeight = active.filter { it.fee != true }.sumOf { it.split } + + val shares = ArrayList(active.size) + for (recipient in active) { + val millis = + if (recipient.fee == true) { + feeAmounts[recipient] ?: 0L + } else if (sharedWeight > 0 && remainder > 0) { + remainder * recipient.split / sharedWeight + } else { + 0L + } + if (millis > 0) shares.add(PodcastValueShare(recipient, millis)) + } + return shares + } + + companion object { + /** + * TLV record type for the Podcasting-2.0 keysend metadata blob (the "boostagram"), a JSON + * object carrying podcast/episode/app/value context. Registered value, used by the whole + * Podcasting-2.0 ecosystem. See . + */ + const val PODCAST_TLV_RECORD: Long = 7629169L + + /** Recipient [PodcastValueRecipient.type] for a keysend to a raw Lightning node pubkey. */ + const val TYPE_NODE = "node" + + /** Recipient [PodcastValueRecipient.type] for an LNURL-pay to a lightning address. */ + const val TYPE_LNADDRESS = "lnaddress" + } } +/** One recipient's resolved share of a [PodcastValue] split, in millisats. */ +@Immutable +class PodcastValueShare( + val recipient: PodcastValueRecipient, + val amountMilliSats: Long, +) + /** One destination in a [PodcastValue] split. */ @Immutable @Serializable diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastBoostagramTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastBoostagramTest.kt new file mode 100644 index 0000000000..da9e03d261 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastBoostagramTest.kt @@ -0,0 +1,66 @@ +/* + * 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.quartz.podcasts + +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PodcastBoostagramTest { + @Test + fun `uses satoshis-stream field names and omits unset fields`() { + val json = + PodcastBoostagram( + podcast = "My Show", + episode = "Ep 1", + action = PodcastBoostagram.ACTION_BOOST, + appName = "Amethyst", + valueMsatTotal = 21_000_000L, + ).toJson() + + assertTrue(json.contains("\"podcast\":\"My Show\"")) + assertTrue(json.contains("\"app_name\":\"Amethyst\"")) + assertTrue(json.contains("\"value_msat_total\":21000000")) + assertTrue(json.contains("\"action\":\"boost\"")) + // Unset optionals (message, sender_name) must not appear. + assertFalse(json.contains("message")) + assertFalse(json.contains("sender_name")) + } + + @Test + fun `round-trips through json`() { + val original = + PodcastBoostagram( + podcast = "Show", + action = PodcastBoostagram.ACTION_STREAM, + valueMsatTotal = 1000L, + senderName = "alice", + ) + val parsed = JsonMapper.fromJson(original.toJson()) + + assertEquals("Show", parsed.podcast) + assertEquals(PodcastBoostagram.ACTION_STREAM, parsed.action) + assertEquals(1000L, parsed.valueMsatTotal) + assertEquals("alice", parsed.senderName) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValueShareTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValueShareTest.kt new file mode 100644 index 0000000000..81d3ce1b10 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValueShareTest.kt @@ -0,0 +1,93 @@ +/* + * 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.quartz.podcasts + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PodcastValueShareTest { + private fun node( + name: String, + split: Int, + fee: Boolean? = null, + ) = PodcastValueRecipient(name = name, type = PodcastValue.TYPE_NODE, address = "node-$name", split = split, fee = fee) + + @Test + fun `weighted split with no fees divides by relative weight`() { + val value = + PodcastValue( + recipients = listOf(node("host", 90), node("producer", 10)), + ) + // 100k sats total in millisats. + val shares = value.computeShares(100_000_000L).associate { it.recipient.name to it.amountMilliSats } + + assertEquals(90_000_000L, shares["host"]) + assertEquals(10_000_000L, shares["producer"]) + } + + @Test + fun `fee recipient takes its split as a percent off the top, remainder split by weight`() { + val value = + PodcastValue( + recipients = + listOf( + node("app", 5, fee = true), // 5% fee off the top + node("host", 80), + node("cohost", 20), + ), + ) + val shares = value.computeShares(1_000_000L).associate { it.recipient.name to it.amountMilliSats } + + // 5% of 1,000,000 = 50,000 fee. Remainder 950,000 split 80/20. + assertEquals(50_000L, shares["app"]) + assertEquals(760_000L, shares["host"]) + assertEquals(190_000L, shares["cohost"]) + // No more than the total is ever allocated. + assertTrue(shares.values.sum() <= 1_000_000L) + } + + @Test + fun `recipients without an address or with non-positive split are ignored`() { + val value = + PodcastValue( + recipients = + listOf( + node("host", 100), + PodcastValueRecipient(name = "noaddr", type = PodcastValue.TYPE_NODE, address = null, split = 50), + node("zero", 0), + ), + ) + val shares = value.computeShares(10_000L) + + assertEquals(1, shares.size) + assertEquals("host", shares.single().recipient.name) + assertEquals(10_000L, shares.single().amountMilliSats) + } + + @Test + fun `non-positive total or empty recipients yields no shares`() { + val value = PodcastValue(recipients = listOf(node("host", 100))) + assertTrue(value.computeShares(0L).isEmpty()) + assertTrue(value.computeShares(-5L).isEmpty()) + assertTrue(PodcastValue(recipients = emptyList()).computeShares(1_000L).isEmpty()) + } +}